From 87d37380c269b64ca16fd0cff827010f66c8114d Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:02:43 +0200 Subject: [PATCH 001/106] chore(spike): main screen in egui, with startup bench and captures --- Cargo.toml | 2 +- crates/accshift-egui/Cargo.lock | 5404 ++++++++++++++++++++++++++ crates/accshift-egui/Cargo.toml | 55 + crates/accshift-egui/README.md | 56 + crates/accshift-egui/src/app.rs | 1125 ++++++ crates/accshift-egui/src/avatars.rs | 109 + crates/accshift-egui/src/backdrop.rs | 83 + crates/accshift-egui/src/bench.rs | 60 + crates/accshift-egui/src/capture.rs | 195 + crates/accshift-egui/src/data.rs | 226 ++ crates/accshift-egui/src/icons.rs | 101 + crates/accshift-egui/src/lib.rs | 38 + crates/accshift-egui/src/main.rs | 126 + crates/accshift-egui/src/theme.rs | 173 + 14 files changed, 7752 insertions(+), 1 deletion(-) create mode 100644 crates/accshift-egui/Cargo.lock create mode 100644 crates/accshift-egui/Cargo.toml create mode 100644 crates/accshift-egui/README.md create mode 100644 crates/accshift-egui/src/app.rs create mode 100644 crates/accshift-egui/src/avatars.rs create mode 100644 crates/accshift-egui/src/backdrop.rs create mode 100644 crates/accshift-egui/src/bench.rs create mode 100644 crates/accshift-egui/src/capture.rs create mode 100644 crates/accshift-egui/src/data.rs create mode 100644 crates/accshift-egui/src/icons.rs create mode 100644 crates/accshift-egui/src/lib.rs create mode 100644 crates/accshift-egui/src/main.rs create mode 100644 crates/accshift-egui/src/theme.rs diff --git a/Cargo.toml b/Cargo.toml index d119f25..a6be599 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "2" members = ["src-tauri", "crates/accshift-core", "crates/accshift-cli"] -exclude = ["vendor"] +exclude = ["vendor", "crates/accshift-egui"] [workspace.package] version = "1.0.5" diff --git a/crates/accshift-egui/Cargo.lock b/crates/accshift-egui/Cargo.lock new file mode 100644 index 0000000..7ac2b81 --- /dev/null +++ b/crates/accshift-egui/Cargo.lock @@ -0,0 +1,5404 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "atspi-common", + "phf", + "serde", + "zvariant 5.14.0", +] + +[[package]] +name = "accesskit_consumer" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer 0.38.0", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus 5.19.0", +] + +[[package]] +name = "accesskit_windows" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", + "hashbrown 0.16.1", + "static_assertions", + "windows", + "windows-core", +] + +[[package]] +name = "accesskit_winit" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "accshift-core" +version = "1.0.5" +dependencies = [ + "fs4", + "keyring", + "open", + "pbkdf2", + "reqwest", + "rusqlite", + "serde", + "serde_json", + "sha2 0.11.0", + "sysinfo", + "thiserror 2.0.20", + "tokio", + "uuid", + "windows-sys 0.61.2", + "winreg", + "zeroize", +] + +[[package]] +name = "accshift-egui" +version = "0.0.0" +dependencies = [ + "accshift-core", + "eframe", + "egui", + "egui_extras", + "raw-window-handle", + "resvg", + "window-vibrancy", + "windows-sys 0.61.2", +] + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[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.20", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[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 = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "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 = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus 5.19.0", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names 4.3.4", + "zvariant 5.14.0", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus 5.19.0", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[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 = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[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 = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" +dependencies = [ + "bitflags 2.13.1", + "polling", + "rustix 1.1.4", + "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.5", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +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 = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[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 = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[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" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +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 0.9.4", + "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 0.9.4", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +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 = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2 0.10.9", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[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 = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading 0.7.4", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[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 = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecolor" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "eframe" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-winit", + "egui_glow", + "glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "profiling", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "web-sys", + "web-time", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "egui" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.13.1", + "emath", + "epaint", + "itertools", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", + "web-sys", +] + +[[package]] +name = "egui-winit" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +dependencies = [ + "accesskit_winit", + "arboard", + "bytemuck", + "egui", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "profiling", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "winit", +] + +[[package]] +name = "egui_extras" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf" +dependencies = [ + "ahash", + "egui", + "enum-map", + "itertools", + "log", + "profiling", + "resvg", +] + +[[package]] +name = "egui_glow" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "profiling", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "emath" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enum-map" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +dependencies = [ + "enum-map-derive", +] + +[[package]] +name = "enum-map-derive" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "epaint" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +dependencies = [ + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "font-types", + "harfrust", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "skrifa", + "smallvec", + "unicode-general-category", + "unicode-segmentation", + "vello_cpu", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + +[[package]] +name = "find-msvc-tools" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs4" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e72ed92b67c146290f88e9c89d60ca163ea417a446f61ffd7b72df3e7f1dfd5" +dependencies = [ + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glifo" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa", + "smallvec", + "vello_common", +] + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.1", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_wgl_sys", + "libloading 0.8.9", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + +[[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.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "read-fonts", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "tiff", +] + +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "io-uring" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64d8ca234d152948ceaede1f419b6a83983a5ecccaac05fb337a809c96d3aa6" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "libc", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[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.20", + "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.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "dbus-secret-service", + "log", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.7.0", + "zeroize", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.4", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[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 = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[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 0.5.1", + "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-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.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 0.5.1", + "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 0.5.1", + "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 0.5.1", + "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 0.5.1", + "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 0.5.1", + "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 0.5.1", + "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", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "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 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "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 0.5.1", + "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 0.5.1", + "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 0.5.1", + "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-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "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 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c603ab8300cf18bc3b14146b19fe3dfcc4843ae5a400cd0e7a30b95aa366634" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +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 = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", + "hmac 0.13.0", +] + +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo 0.13.1", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[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 = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[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.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[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.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.20", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +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", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[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 = "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 = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.7", + "serde", + "sha2 0.10.9", + "zbus 4.4.0", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +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 = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.12" +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 = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.14.5", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.20", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo 0.11.3", + "siphasher", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sysinfo" +version = "0.39.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "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.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[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", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[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", + "png 0.17.16", + "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 = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[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.47.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f885cc8c20014400b9ec7f7d5fa7917176d306f1c0241c0fe183af95f9a3c1f7" +dependencies = [ + "backtrace", + "bytes", + "io-uring", + "libc", + "mio", + "pin-project-lite", + "slab", + "socket2", + "tokio-macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64", + "data-url", + "flate2", + "imagesize", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "xmlwriter", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vello_common" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "log", + "peniko", + "smallvec", + "thiserror 2.0.20", +] + +[[package]] +name = "vello_cpu" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "vello_common", +] + +[[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 = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +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", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.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", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +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 = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[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 = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "111e51caca442cafd9bab396628ac4d814880eaea4e63dfd76a12e9f342b5580" +dependencies = [ + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "raw-window-handle", + "windows-sys 0.61.2", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[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", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[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 0.52.6", + "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", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link", +] + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[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 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "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", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading 0.8.9", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.7", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros 5.19.0", + "zbus_names 4.3.4", + "zvariant 5.14.0", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant 5.14.0", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "zvariant 5.14.0", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", + "zbus_names 4.3.4", + "zvariant 5.14.0", + "zvariant_utils 4.0.0", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant 5.14.0", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow", + "zbus_names 4.3.4", + "zvariant 5.14.0", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + +[[package]] +name = "zvariant" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive 5.14.0", + "zvariant_utils 4.0.0", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zvariant_derive" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", + "zvariant_utils 4.0.0", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zvariant_utils" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.3", + "winnow", +] diff --git a/crates/accshift-egui/Cargo.toml b/crates/accshift-egui/Cargo.toml new file mode 100644 index 0000000..15604a5 --- /dev/null +++ b/crates/accshift-egui/Cargo.toml @@ -0,0 +1,55 @@ +[package] +name = "accshift-egui" +description = "Spike: the accshift main screen in egui, to compare with the Tauri shell" +version = "0.0.0" +edition = "2021" +publish = false + +# Standalone on purpose: the root workspace runs `cargo clippy --workspace` and +# `cargo test --workspace` in CI on three OSes, and a spike has no business +# adding eframe, glow and wgpu to that build. The root `Cargo.toml` excludes +# this folder. +[workspace] + +[features] +# Bench build: the window is never shown, boot marks go to +# %TEMP%\accshift-egui-bench\.jsonl. The harness refuses any exe that +# lacks the marker string, so a bench run can never pop a window. +startup-bench = [] + +[dependencies] +accshift-core = { path = "../accshift-core" } +# Same backend choice as spotifast: glow keeps the binary and the tree small, +# wgpu buys nothing for a 2D grid. +eframe = { version = "0.36", default-features = false, features = [ + "accesskit", + "glow", + "default_fonts", +] } +egui = "0.36" +egui_extras = { version = "0.36", default-features = false, features = ["svg"] } +# The same resvg egui_extras rasterizes with, for its options type. +resvg = { version = "0.45", default-features = false } +raw-window-handle = "0.6" + +[target.'cfg(windows)'.dependencies] +# The OS acrylic behind a transparent window: what Glass Dark uses in Tauri. +window-vibrancy = "0.8" +# DWM cloak for the bench build: the window exists and renders, but is never +# composited on screen. +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Dwm"] } + +# Mirrors the root workspace profile so binary sizes compare. +[profile.release] +panic = "abort" +codegen-units = 1 +lto = "fat" +opt-level = 2 +strip = true + +# The `windows` bindings crate alone needs about 2 GB of RAM at opt-level 2 +# with one codegen unit. Fat LTO optimizes its bitcode again at the final link, +# so building it unoptimized changes the compile peak, not the binary. +[profile.release.package.windows] +opt-level = 0 +codegen-units = 16 diff --git a/crates/accshift-egui/README.md b/crates/accshift-egui/README.md new file mode 100644 index 0000000..a419f71 --- /dev/null +++ b/crates/accshift-egui/README.md @@ -0,0 +1,56 @@ +# accshift-egui (spike) + +The accshift main screen redrawn in [egui](https://github.com/emilk/egui) 0.36 (eframe, glow backend), to weigh a move away from Tauri and WebView2 with numbers and real renders instead of guesses. It is not the app: there are no settings, no personas page, no real switch, and no CI coverage. The root workspace excludes this crate on purpose. + +## What it reproduces + +- The custom title bar: drag, double-click to maximize, the four left actions, the platform tabs, the caption buttons. +- The Steam grid of the demo dataset (`src/lib/mock/scenarios.ts`): 17 accounts, the "Smurfs" folder, custom card colours, the active rings, the hover lift and avatar zoom, the two-click arm then switch with the blurred avatar, the play icon and the hint, and the 900 ms switch delay. +- Search, which flattens folders like the web grid; the grid and list toggle; the Riot tab (gradient and initials fallback) and the Roblox tab. +- Glass Dark: OS acrylic behind a transparent window, the same tint, fills and text colours as `builtin/glass-dark.json`, and Segoe UI (the webview's fallback, since Inter is not installed). +- `accshift-core` called directly, without IPC: a background thread reads the real Steam login list (read-only) and times it. + +## Run it + +```powershell +cargo run --release # normal window +cargo run --release -- --capture # scripted shots, hidden window +cargo build --release --features startup-bench +``` + +- `--capture ` runs a fixed script (hover, arm, search, switch, tabs, folder, list) and writes one PAM file per shot, read back from the GL framebuffer of the real renderer at 860x440 points and 1.5 pixels per point, over the recorder's stand-in desktop (`src/backdrop.rs`). `ffmpeg -i shot.pam shot.png` converts them. +- `startup-bench` never shows the window: it is created inactive, outside the taskbar, at -32000,-32000, and DWM-cloaked before eframe makes it visible. Boot marks go to `%TEMP%\accshift-egui-bench\.jsonl` (`main`, `app_created`, `ui_done`, `first_frame`, `ready` once every avatar is on screen, `core_accounts`). The binary carries the string `accshift-egui-startup-bench-build`, and a harness refuses to launch any build without it. +- `--capture` uses the same hidden-window recipe in any build. +- Two environment knobs exist only for the boot breakdown: `ACCSHIFT_EGUI_NO_VSYNC` and `ACCSHIFT_EGUI_OPAQUE` (no transparency, no acrylic). + +## Results (2026-10-10, Windows 11, NVIDIA desktop GPU) + +Interleaved A/B against the Tauri `startup-bench` build of the same commit, 15 launches each, first launch dropped, fresh copies of both binaries. Tauri's boot is its `Boot completed` log line, written just before it shows the window; egui's is its first frame, already presented. + +| | Tauri 2 + WebView2 | egui 0.36 + glow | +| ----------------------------- | --------------------------------------------------- | ---------------- | +| Boot, median (quiet machine) | 285 ms | 324 ms | +| Boot, median (loaded machine) | 435 ms | 468 ms | +| Working set, 3 s after boot | 502 MB | 103 MB | +| Private memory | 434 MB | 141 MB | +| Processes | 7 | 1 | +| Shipped files | exe 238 KB + DLL 11.9 MB, plus the WebView2 runtime | one 6.5 MB exe | + +- Memory and process count drop by a factor of 4 to 7. Boot does not improve: egui spends about 180 ms creating the window and the WGL context and about 115 ms on its first paint and present, against 3 ms for the UI pass itself. Turning vsync off or making the window opaque without acrylic changes nothing measurable, so the cost sits in the OpenGL driver. +- The renders match the Tauri frames of `demo-switch` closely: metrics, colours, rings, the blurred armed avatar and the hint all line up. Text is the visible difference, because egui has no variable-font weights (the 500 weight of card names is drawn with the semibold face) and no subpixel positioning like Chromium. +- The spike covers one screen in about 2,200 lines of Rust. The Svelte front-end it would replace is about 15,600 lines of Svelte and 23,300 of TypeScript (settings, personas, dialogs, bulk edit, theming, i18n, the theme editor). + +Not measured: a wgpu backend (DX12 might create its device faster than the NVIDIA OpenGL driver, but a transparent DX12 window needs DirectComposition), other GPUs, real input in a visible window, accessibility with a screen reader. + +## Layout + +| File | Role | +| ----------------- | ---------------------------------------------------------------- | +| `src/app.rs` | The screen: title bar, header, grid, list, cards, animations | +| `src/theme.rs` | Glass Dark tokens, CSS `color-mix`, the avatar fallback gradient | +| `src/data.rs` | The demo dataset, with the mock SVG avatars embedded | +| `src/avatars.rs` | SVG rasterizing and the pre-blurred textures, on a worker thread | +| `src/icons.rs` | The Lucide and platform icons of the Svelte components | +| `src/capture.rs` | The scripted capture run | +| `src/bench.rs` | Boot marks | +| `src/backdrop.rs` | The recorder's stand-in desktop, for captures only | diff --git a/crates/accshift-egui/src/app.rs b/crates/accshift-egui/src/app.rs new file mode 100644 index 0000000..37b9bce --- /dev/null +++ b/crates/accshift-egui/src/app.rs @@ -0,0 +1,1125 @@ +//! The main screen: title bar, platform tabs, header, account grid. +//! +//! Everything is painted by hand at the metrics of the Svelte shell +//! (`TitleBar.svelte`, `AccountCard.svelte`, `FolderCard.svelte`, +//! `ViewToggle.svelte`, the `--grid-card-*` tokens of `app.css`), so the two +//! can be laid side by side. + +use std::collections::HashMap; +use std::sync::mpsc::Receiver; +use std::sync::Arc; + +use egui::epaint::{RectShape, TextShape}; +use egui::{ + pos2, vec2, Align2, Color32, ColorImage, CornerRadius, FontData, FontDefinitions, FontFamily, + FontId, Id, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, TextureHandle, TextureOptions, Ui, + Vec2, +}; + +use crate::avatars::{self, Decoded}; +use crate::bench; +use crate::data::{self, Dataset, Item, Platform}; +use crate::icons; +use crate::theme::{ease_out, fallback_gradient, Rgba, Theme, GLASS_DARK}; + +const TITLE_H: f32 = 36.0; +const GRID_TOP: f32 = 84.0; +const CARD_W: f32 = 100.0; +const CARD_H: f32 = 136.0; +const CARD_PAD: f32 = 8.0; +const CARD_GAP: f32 = 10.0; +const AVATAR: f32 = 68.0; +const SCROLL_GUTTER: f32 = 10.0; +const SWITCH_DELAY: f64 = 0.9; +/// Avatars are rasterized once at this size: 68 pt at 2x, plus the 1.04 hover zoom. +pub const AVATAR_PX: u32 = 160; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Tab { + Personas, + Platform(Platform), +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ViewMode { + Grid, + List, +} + +#[derive(Default, Clone, Copy)] +pub struct Options { + /// Paint the recorder's stand-in desktop under the window, for offscreen + /// captures where no OS backdrop exists. + pub desktop_backdrop: bool, +} + +struct AvatarTex { + sharp: TextureHandle, + blurred: TextureHandle, + loaded_at: f64, +} + +pub struct AccshiftApp { + theme: &'static Theme, + data: Dataset, + tab: Tab, + folder: Option<&'static str>, + view: ViewMode, + search: String, + armed: Option, + switching: Option<(String, f64)>, + page_key: (Tab, Option<&'static str>, ViewMode), + page_since: f64, + icons: HashMap<(usize, u32), TextureHandle>, + avatars: Vec>, + avatar_rx: Option>, + gradients: HashMap, + backdrop: Option, + frame_index: u64, + ready_marked: bool, + all_avatars_drawn_at: Option, +} + +impl AccshiftApp { + pub fn new(ctx: &egui::Context, options: Options) -> Self { + install_fonts(ctx); + let avatar_rx = Some(avatars::spawn(ctx.clone(), AVATAR_PX)); + let backdrop = options.desktop_backdrop.then(|| { + ctx.load_texture( + "desktop", + crate::backdrop::render(860, 440, 2.0), + TextureOptions::LINEAR, + ) + }); + Self { + theme: &GLASS_DARK, + data: data::demo(), + tab: Tab::Platform(Platform::Steam), + folder: None, + view: ViewMode::Grid, + search: String::new(), + armed: None, + switching: None, + page_key: (Tab::Platform(Platform::Steam), None, ViewMode::Grid), + page_since: 0.0, + icons: HashMap::new(), + avatars: (0..data::AVATARS.len()).map(|_| None).collect(), + avatar_rx, + gradients: HashMap::new(), + backdrop, + frame_index: 0, + ready_marked: false, + all_avatars_drawn_at: None, + } + } + + /// Every avatar texture has been uploaded (the offscreen captures wait + /// for this before taking a frame). + pub fn avatars_ready(&self) -> bool { + self.avatar_rx.is_none() + } + + pub fn ui(&mut self, ui: &mut Ui) { + let ctx = ui.ctx().clone(); + let now = ctx.input(|i| i.time); + if self.frame_index == 1 { + // Frame 0 has been painted and swapped by now. + bench::mark("first_frame"); + } + if let (Some(at), false) = (self.all_avatars_drawn_at, self.ready_marked) { + if self.frame_index > at { + bench::mark("ready"); + self.ready_marked = true; + } + } + + self.receive_avatars(&ctx, now); + self.finish_switch(now); + + let key = (self.tab, self.folder, self.view); + if key != self.page_key { + self.page_key = key; + self.page_since = now; + } + + let full = ui.max_rect(); + let painter = ui.painter().clone(); + if let Some(desk) = &self.backdrop { + painter.image( + desk.id(), + full, + Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), + Color32::WHITE, + ); + } + painter.rect_filled( + full, + CornerRadius::ZERO, + self.theme.window_fill.to_color32(), + ); + + self.title_bar(ui, full, now); + match self.tab { + Tab::Personas => self.personas_page(ui, full), + Tab::Platform(platform) => self.platform_page(ui, full, platform, now), + } + + // A press anywhere but on the armed card disarms it, like the + // document mousedown listener of AccountCard.svelte. + if self.armed.is_some() && ctx.input(|i| i.pointer.any_pressed()) { + let over_armed = ctx + .read_response(Id::new(("card", self.armed.clone().unwrap_or_default()))) + .is_some_and(|r| r.hovered()); + if !over_armed { + self.armed = None; + } + } + + if self.frame_index == 0 { + bench::mark("ui_done"); + ctx.request_repaint(); + } + if self.all_avatars_drawn_at.is_some() && !self.ready_marked { + ctx.request_repaint(); + } + self.frame_index += 1; + } + + // ------------------------------------------------------------- loading + + fn receive_avatars(&mut self, ctx: &egui::Context, now: f64) { + let Some(rx) = &self.avatar_rx else { return }; + while let Ok(decoded) = rx.try_recv() { + let sharp = ctx.load_texture( + format!("avatar-{}", decoded.index), + decoded.sharp, + TextureOptions::LINEAR, + ); + let blurred = ctx.load_texture( + format!("avatar-blur-{}", decoded.index), + decoded.blurred, + TextureOptions::LINEAR, + ); + self.avatars[decoded.index] = Some(AvatarTex { + sharp, + blurred, + loaded_at: now, + }); + } + if self.avatars.iter().all(Option::is_some) { + self.avatar_rx = None; + self.all_avatars_drawn_at.get_or_insert(self.frame_index); + } + } + + fn finish_switch(&mut self, now: f64) { + if let Some((id, since)) = &self.switching { + if now - since >= SWITCH_DELAY { + let platform = match self.tab { + Tab::Platform(p) => p, + Tab::Personas => Platform::Steam, + }; + self.data.set_active(platform, id.clone()); + self.switching = None; + } + } + } + + fn icon(&mut self, ctx: &egui::Context, svg: &'static [u8], size: f32) -> TextureHandle { + let ppp = ctx.pixels_per_point(); + let px = (size * ppp).round().max(1.0) as u32; + let key = (svg.as_ptr() as usize, px); + self.icons + .entry(key) + .or_insert_with(|| { + let image = avatars::raster(svg, px) + .unwrap_or_else(|_| ColorImage::filled([1, 1], Color32::TRANSPARENT)); + ctx.load_texture(format!("icon-{px}-{key:?}"), image, TextureOptions::LINEAR) + }) + .clone() + } + + fn paint_icon(&mut self, ui: &Ui, svg: &'static [u8], center: Pos2, size: f32, color: Rgba) { + let tex = self.icon(ui.ctx(), svg, size); + let rect = Rect::from_center_size(center, Vec2::splat(size)); + ui.painter().image( + tex.id(), + rect, + Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), + color.to_color32(), + ); + } + + fn gradient(&mut self, ctx: &egui::Context, seed: &str) -> TextureHandle { + self.gradients + .entry(seed.to_string()) + .or_insert_with(|| { + let (a, b) = fallback_gradient(seed); + ctx.load_texture( + format!("grad-{seed}"), + gradient_image(a, b, 48), + TextureOptions::LINEAR, + ) + }) + .clone() + } + + // ----------------------------------------------------------- title bar + + fn title_bar(&mut self, ui: &mut Ui, full: Rect, now: f64) { + let t = self.theme; + let bar = Rect::from_min_size(full.min, vec2(full.width(), TITLE_H)); + let drag = ui.interact(bar, Id::new("titlebar"), Sense::click_and_drag()); + if drag.drag_started() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + if drag.double_clicked() { + let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)); + ui.ctx() + .send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); + } + ui.painter().hline( + bar.x_range(), + bar.bottom() - 0.5, + Stroke::new(1.0, t.card.to_color32()), + ); + + // Left actions: 26x26 buttons from x = 10, 6 apart. + let actions: [(&'static [u8], &str); 4] = [ + (icons::REFRESH, "Refresh"), + (icons::PLUS, "Add account"), + (icons::SETTINGS, "Settings"), + (icons::BULK_EDIT, "Bulk edit"), + ]; + for (i, (svg, label)) in actions.into_iter().enumerate() { + let rect = Rect::from_min_size( + pos2(full.left() + 10.0 + i as f32 * 32.0, full.top() + 5.0), + Vec2::splat(26.0), + ); + let resp = button(ui, Id::new(("action", i)), rect, label); + let hover = anim(ui, resp.id, resp.hovered(), 0.12); + let bg = Rgba::TRANSPARENT.lerp(t.muted, hover); + ui.painter() + .rect_filled(rect, CornerRadius::same(4), bg.to_color32()); + let color = t.fg_muted.lerp(t.fg, hover); + self.paint_icon(ui, svg, rect.center(), 14.0, color); + } + + // Centered tabs: 32x28, 2 apart. + let tabs = [ + Tab::Personas, + Tab::Platform(Platform::Steam), + Tab::Platform(Platform::Riot), + Tab::Platform(Platform::Roblox), + ]; + let total = tabs.len() as f32 * 32.0 + (tabs.len() as f32 - 1.0) * 2.0; + let x0 = full.center().x - total / 2.0; + for (i, tab) in tabs.into_iter().enumerate() { + let rect = Rect::from_min_size( + pos2(x0 + i as f32 * 34.0, full.top() + 4.0), + vec2(32.0, 28.0), + ); + let (svg, accent, label) = match tab { + Tab::Personas => (icons::PERSONAS, Rgba::hex(0xa855f7, 1.0), "Personas"), + Tab::Platform(p) => ( + match p { + Platform::Steam => icons::STEAM, + Platform::Riot => icons::RIOT, + Platform::Roblox => icons::ROBLOX, + }, + Rgba::hex(p.accent(), 1.0), + p.name(), + ), + }; + let resp = button(ui, Id::new(("tab", i)), rect, label); + if resp.clicked() && self.tab != tab { + self.tab = tab; + self.folder = None; + self.search.clear(); + self.armed = None; + } + let active = self.tab == tab; + let hover = anim(ui, resp.id, resp.hovered(), 0.12); + let on = anim(ui, resp.id.with("on"), active, 0.12); + let bg = Rgba::TRANSPARENT.lerp(t.card, hover.max(on)); + ui.painter() + .rect_filled(rect, CornerRadius::same(4), bg.to_color32()); + let idle = t.fg_subtle.lerp(t.fg_muted, hover); + self.paint_icon(ui, svg, rect.center(), 16.0, idle.lerp(accent, on)); + } + + // Caption strip: 46 px buttons, full height, flush right. + let maximized = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)); + let caption: [(&'static [u8], &str); 3] = [ + (icons::MINIMIZE, "Minimize"), + ( + if maximized { + icons::RESTORE + } else { + icons::MAXIMIZE + }, + "Maximize", + ), + (icons::CLOSE, "Close"), + ]; + for (i, (svg, label)) in caption.into_iter().enumerate() { + let rect = Rect::from_min_size( + pos2(full.right() - 46.0 * (3 - i) as f32, full.top()), + vec2(46.0, TITLE_H - 1.0), + ); + let resp = button(ui, Id::new(("caption", i)), rect, label); + let hover = anim(ui, resp.id, resp.hovered(), 0.12); + let (bg, fg) = if i == 2 { + (t.danger, Rgba::hex(0xffffff, 1.0)) + } else { + (t.muted, t.fg) + }; + ui.painter().rect_filled( + rect, + CornerRadius::ZERO, + Rgba::TRANSPARENT.lerp(bg, hover).to_color32(), + ); + self.paint_icon(ui, svg, rect.center(), 12.0, t.fg_muted.lerp(fg, hover)); + if resp.clicked() { + let cmd = match i { + 0 => egui::ViewportCommand::Minimized(true), + 1 => egui::ViewportCommand::Maximized(!maximized), + _ => egui::ViewportCommand::Close, + }; + ui.ctx().send_viewport_cmd(cmd); + } + } + let _ = now; + } + + // --------------------------------------------------------------- pages + + fn personas_page(&mut self, ui: &mut Ui, full: Rect) { + let t = self.theme; + let painter = ui.painter(); + painter.text( + pos2(full.center().x, full.center().y - 8.0), + Align2::CENTER_CENTER, + "Personas", + FontId::new(15.0, semibold()), + t.fg.to_color32(), + ); + painter.text( + pos2(full.center().x, full.center().y + 14.0), + Align2::CENTER_CENTER, + "Not part of the spike.", + FontId::proportional(12.0), + t.fg_subtle.to_color32(), + ); + } + + fn platform_page(&mut self, ui: &mut Ui, full: Rect, platform: Platform, now: f64) { + let t = self.theme; + let accent = Rgba::hex(platform.accent(), 1.0); + + // Header: section title or breadcrumb on the left, search and view + // toggle on the right. + let header_y = full.top() + 60.0; + let crumb_font = |current: bool| { + if current { + FontId::new(12.0, semibold()) + } else { + FontId::proportional(12.0) + } + }; + let at_root = self.folder.is_none(); + let root_color = if at_root { accent } else { t.fg_subtle }; + let crumb = ui.painter().layout_no_wrap( + platform.name().to_string(), + crumb_font(at_root), + root_color.to_color32(), + ); + let crumb_rect = Rect::from_min_size( + pos2(full.left() + 16.0, header_y - crumb.size().y / 2.0 - 2.0), + crumb.size() + vec2(8.0, 4.0), + ); + let crumb_resp = ui.interact(crumb_rect, Id::new("crumb-root"), Sense::click()); + if crumb_resp.clicked() { + self.folder = None; + } + ui.painter().galley( + crumb_rect.min + vec2(4.0, 2.0), + crumb, + root_color.to_color32(), + ); + let mut x = crumb_rect.right() + 4.0; + if let Some(folder) = self.folder { + let name = match folder { + "demo-folder-smurfs" => "Smurfs", + other => other, + }; + let sep = ui.painter().layout_no_wrap( + "/".into(), + FontId::proportional(11.0), + t.elevated.to_color32(), + ); + ui.painter().galley( + pos2(x, header_y - sep.size().y / 2.0), + sep.clone(), + t.elevated.to_color32(), + ); + x += sep.size().x + 4.0; + let g = ui + .painter() + .layout_no_wrap(name.into(), crumb_font(true), accent.to_color32()); + ui.painter().galley( + pos2(x + 4.0, header_y - g.size().y / 2.0), + g, + accent.to_color32(), + ); + } + + let right = full.right() - SCROLL_GUTTER - 17.0; + let toggle = Rect::from_min_size(pos2(right - 66.0, header_y - 15.0), vec2(66.0, 30.0)); + self.view_toggle(ui, toggle); + let search = Rect::from_min_size( + pos2(toggle.left() - 8.0 - 240.0, header_y - 15.0), + vec2(240.0, 30.0), + ); + self.search_box(ui, search); + + let body = Rect::from_min_max(pos2(full.left(), full.top() + GRID_TOP - 8.0), full.max); + let items = self.visible_items(platform); + let entrance = ease_out(((now - self.page_since) / 0.26) as f32); + if entrance < 1.0 { + ui.ctx().request_repaint(); + } + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(body)); + egui::ScrollArea::vertical() + .id_salt(( + "grid", + self.page_key.0 == Tab::Platform(platform), + self.folder, + )) + .auto_shrink([false, false]) + .show(&mut child, |ui| match self.view { + ViewMode::Grid => self.grid(ui, platform, &items, entrance, now), + ViewMode::List => self.list(ui, platform, &items, entrance), + }); + } + + fn visible_items(&self, platform: Platform) -> Vec { + let query = self.search.trim().to_lowercase(); + let accounts = self.data.accounts(platform); + if !query.is_empty() { + // Search flattens folders, like the web grid. + return accounts + .iter() + .enumerate() + .filter(|(_, a)| a.name.to_lowercase().contains(&query)) + .map(|(i, _)| Item::Account(i)) + .collect(); + } + match (platform, self.folder) { + (Platform::Steam, None) => self.data.steam_root.clone(), + (Platform::Steam, Some(_)) => { + let mut items = vec![Item::Folder { + id: "..", + name: "Back", + }]; + items.extend(self.data.smurfs.iter().cloned()); + items + } + _ => (0..accounts.len()).map(Item::Account).collect(), + } + } + + fn view_toggle(&mut self, ui: &mut Ui, rect: Rect) { + let t = self.theme; + ui.painter() + .rect_filled(rect, CornerRadius::same(8), t.muted.to_color32()); + for (i, (mode, svg, label)) in [ + (ViewMode::Grid, icons::GRID, "Grid view"), + (ViewMode::List, icons::LIST, "List view"), + ] + .into_iter() + .enumerate() + { + let b = Rect::from_min_size( + rect.min + vec2(2.0 + i as f32 * 32.0, 2.0), + vec2(30.0, 26.0), + ); + let resp = button(ui, Id::new(("view", i)), b, label); + if resp.clicked() { + self.view = mode; + } + let on = anim(ui, resp.id.with("on"), self.view == mode, 0.1); + let hover = anim(ui, resp.id, resp.hovered(), 0.1); + ui.painter().rect_filled( + b, + CornerRadius::same(6), + Rgba::TRANSPARENT.lerp(t.card, on).to_color32(), + ); + let color = t.fg_muted.lerp(t.fg, on.max(hover)); + self.paint_icon(ui, svg, b.center(), 14.0, color); + } + } + + fn search_box(&mut self, ui: &mut Ui, rect: Rect) { + let t = self.theme; + let id = Id::new("search"); + let focused = ui.ctx().memory(|m| m.has_focus(id)); + let border = if focused { + t.fg.mix(0.35, t.border) + } else { + t.border + }; + ui.painter().rect( + rect, + CornerRadius::same(8), + t.card.to_color32(), + Stroke::new(1.0, border.to_color32()), + StrokeKind::Inside, + ); + let edit = egui::TextEdit::singleline(&mut self.search) + .id(id) + .frame(egui::Frame::NONE) + .font(FontId::proportional(12.0)) + .text_color(t.fg.to_color32()) + .hint_text( + egui::RichText::new("Search account...") + .size(12.0) + .color(t.fg_subtle.mix(0.55, Rgba::TRANSPARENT).to_color32()), + ) + .margin(egui::Margin::symmetric(0, 0)) + .desired_width(rect.width() - 20.0 - 22.0); + let inner = Rect::from_min_max( + rect.min + vec2(10.0, 7.0), + rect.max - vec2(10.0 + 22.0, 7.0), + ); + let resp = ui.put(inner, edit); + if resp.changed() { + self.armed = None; + } + // Clear button, only with a query (the web field's X). + if !self.search.is_empty() { + let b = Rect::from_center_size( + pos2(rect.right() - 18.0, rect.center().y), + Vec2::splat(20.0), + ); + let clear = button(ui, Id::new("search-clear"), b, "Clear search"); + let hover = anim(ui, clear.id, clear.hovered(), 0.1); + self.paint_icon( + ui, + icons::CLOSE, + b.center(), + 11.0, + t.fg_muted.lerp(t.fg, hover.max(0.7)), + ); + if clear.clicked() { + self.search.clear(); + } + } + } + + // ---------------------------------------------------------------- grid + + fn grid(&mut self, ui: &mut Ui, platform: Platform, items: &[Item], entrance: f32, now: f64) { + let width = ui.available_width(); + let usable = width - SCROLL_GUTTER; + let cols = (((usable + CARD_GAP) / (CARD_W + CARD_GAP)).floor() as usize).max(1); + let grid_w = cols as f32 * CARD_W + (cols as f32 - 1.0) * CARD_GAP; + let rows = items.len().div_ceil(cols); + let height = 8.0 + rows as f32 * (CARD_H + CARD_GAP) + 8.0; + let (area, _) = ui.allocate_exact_size(vec2(width, height), Sense::hover()); + let x0 = area.left() + ((usable - grid_w) / 2.0).round(); + let y0 = area.top() + 8.0; + for (i, item) in items.iter().enumerate() { + let col = i % cols; + let row = i / cols; + let rect = Rect::from_min_size( + pos2( + x0 + col as f32 * (CARD_W + CARD_GAP), + y0 + row as f32 * (CARD_H + CARD_GAP), + ), + vec2(CARD_W, CARD_H), + ); + match item { + Item::Account(idx) => self.account_card(ui, platform, *idx, rect, entrance, now), + Item::Folder { id, name } => self.folder_card(ui, id, name, rect, entrance), + } + } + } + + fn folder_card( + &mut self, + ui: &mut Ui, + id: &'static str, + name: &'static str, + rect: Rect, + entrance: f32, + ) { + let t = self.theme; + let back = id == ".."; + let resp = ui.interact(rect, Id::new(("folder", id)), Sense::click()); + let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name)); + if resp.clicked() { + self.folder = if back { None } else { Some(id) }; + self.armed = None; + } + let h = ease_out(anim(ui, resp.id, resp.hovered(), 0.18)); + let mut painter = ui.painter().clone(); + painter.set_opacity(entrance); + let rect = scale_about_center(rect, 0.98 + 0.02 * entrance).translate(vec2(0.0, -2.0 * h)); + + if h > 0.0 { + shadow(&painter, rect, 0.18 * h); + } + painter.rect_filled( + rect, + CornerRadius::same(8), + Rgba::TRANSPARENT.lerp(t.card_hover, h).to_color32(), + ); + let outline = t.fg_subtle.mix(0.45, Rgba::TRANSPARENT); + painter.rect_stroke( + rect, + CornerRadius::same(8), + Stroke::new(1.0, Rgba::TRANSPARENT.lerp(outline, h).to_color32()), + StrokeKind::Outside, + ); + + let icon_box = Rect::from_min_size( + pos2(rect.center().x - AVATAR / 2.0, rect.top() + CARD_PAD - h), + Vec2::splat(AVATAR), + ); + let box_bg = t.muted.mix(0.52, Rgba::TRANSPARENT).lerp(t.elevated, h); + painter.rect_filled(icon_box, CornerRadius::same(6), box_bg.to_color32()); + let color = t.fg_muted.lerp(t.fg, h); + let svg = if back { icons::BACK } else { icons::FOLDER }; + let size = if back { 26.0 } else { 30.0 }; + let tex = self.icon(ui.ctx(), svg, size); + painter.image( + tex.id(), + Rect::from_center_size(icon_box.center(), Vec2::splat(size)), + Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), + color.to_color32(), + ); + name_label(&painter, rect, name, t.fg); + } + + fn account_card( + &mut self, + ui: &mut Ui, + platform: Platform, + idx: usize, + rect: Rect, + entrance: f32, + now: f64, + ) { + let t = self.theme; + let account = self.data.accounts(platform)[idx].clone(); + let id = Id::new(("card", account.id.clone())); + let resp = ui + .interact(rect, id, Sense::click()) + .on_hover_cursor(egui::CursorIcon::PointingHand); + resp.widget_info(|| { + egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &account.name) + }); + + let is_active = self.data.active(platform) == account.id; + let is_switching = self + .switching + .as_ref() + .is_some_and(|(s, _)| *s == account.id); + if resp.clicked() && self.switching.is_none() { + if self.armed.as_deref() == Some(account.id.as_str()) { + self.armed = None; + self.switching = Some((account.id.clone(), now)); + } else { + self.armed = Some(account.id.clone()); + } + } + let armed = self.armed.as_deref() == Some(account.id.as_str()); + + let lift = !is_active; + let h = ease_out(anim(ui, id.with("hover"), resp.hovered() && lift, 0.18)); + let press = anim( + ui, + id.with("press"), + resp.is_pointer_button_down_on() && lift, + 0.08, + ); + let blur = ease_out(anim(ui, id.with("blur"), armed || is_switching, 0.3)); + let confirm = ease_out(anim(ui, id.with("confirm"), armed, 0.15)); + + let mut painter = ui.painter().clone(); + painter.set_opacity(entrance); + let scale = (0.98 + 0.02 * entrance) * (1.0 - 0.015 * press); + let rect = scale_about_center(rect, scale).translate(vec2(0.0, -2.0 * h * (1.0 - press))); + + // Surface. + let custom = account.color.map(|c| Rgba::hex(c, 1.0)); + let rest = match custom { + Some(c) => c.mix(0.24, t.card), + None if is_active => t.card_hover, + None => t.card, + }; + let hover_bg = match custom { + Some(c) if !is_active => c.mix(0.32, t.card_hover), + _ => rest.lerp(t.card_hover, if is_active { 0.0 } else { 1.0 }), + }; + if h > 0.0 { + shadow(&painter, rect, 0.18 * h); + } + painter.rect_filled( + rect, + CornerRadius::same(8), + rest.lerp(hover_bg, h).to_color32(), + ); + + // Active rings: a bright halo, then a dark band (two spread shadows). + let active_t = ease_out(anim(ui, id.with("active"), is_active, 0.18)); + if active_t > 0.0 { + let (halo, band, band_w) = match custom { + Some(c) => ( + t.fg.mix(0.72, Rgba::TRANSPARENT), + c.mix(0.5, t.bg_solid.mix(0.62, Rgba::TRANSPARENT)), + 3.0, + ), + None => ( + t.fg.mix(0.62, Rgba::TRANSPARENT), + t.bg_solid.mix(0.45, Rgba::TRANSPARENT), + 2.0, + ), + }; + let fade = |c: Rgba| c.alpha(c.a * active_t).to_color32(); + painter.add(RectShape::stroke( + rect.expand(2.0), + CornerRadius::same(10), + Stroke::new(band_w, fade(band)), + StrokeKind::Outside, + )); + painter.add(RectShape::stroke( + rect, + CornerRadius::same(8), + Stroke::new(2.0, fade(halo)), + StrokeKind::Outside, + )); + } + + // Avatar box. + let avatar = Rect::from_min_size( + pos2(rect.center().x - AVATAR / 2.0, rect.top() + CARD_PAD - h), + Vec2::splat(AVATAR), + ); + let box_bg = t.muted.lerp(t.elevated, h); + painter.rect_filled(avatar, CornerRadius::same(6), box_bg.to_color32()); + let zoom = 1.0 + 0.04 * h; + let inset = (1.0 - 1.0 / zoom) / 2.0; + let uv = Rect::from_min_max(pos2(inset, inset), pos2(1.0 - inset, 1.0 - inset)); + let dim = 1.0 - 0.5 * blur; + let dim_color = |alpha: f32| { + let v = (255.0 * dim).round() as u8; + Color32::from_rgba_unmultiplied(v, v, v, (255.0 * alpha).round() as u8) + }; + match account.avatar.and_then(|i| self.avatars[i].as_ref()) { + Some(tex) => { + let fade_in = ease_out(((now - tex.loaded_at) / 0.22) as f32); + if fade_in < 1.0 { + ui.ctx().request_repaint(); + } + painter.add( + RectShape::filled( + avatar, + CornerRadius::same(6), + dim_color(fade_in * (1.0 - blur)), + ) + .with_texture(tex.sharp.id(), uv), + ); + if blur > 0.0 { + painter.add( + RectShape::filled(avatar, CornerRadius::same(6), dim_color(fade_in * blur)) + .with_texture(tex.blurred.id(), uv), + ); + } + } + None if account.avatar.is_none() => { + let seed = format!( + "{}::{}::{}", + account.name, + account.id, + account.name.chars().rev().collect::() + ); + let grad = self.gradient(ui.ctx(), &seed); + painter.add( + RectShape::filled(avatar, CornerRadius::same(6), dim_color(1.0)).with_texture( + grad.id(), + Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), + ), + ); + painter.text( + avatar.center(), + Align2::CENTER_CENTER, + data::initials(&account.name), + FontId::new(20.0, semibold()), + t.fg.alpha(1.0 - 0.5 * blur).to_color32(), + ); + } + None => {} + } + if is_active { + painter.rect_stroke( + avatar, + CornerRadius::same(6), + Stroke::new(2.0, t.fg.mix(0.2, Rgba::TRANSPARENT).to_color32()), + StrokeKind::Outside, + ); + } + + if is_switching { + spinner(&painter, avatar.center(), now, t); + ui.ctx().request_repaint(); + } + if confirm > 0.0 { + let size = 24.0 * (0.8 + 0.2 * confirm); + let tex = self.icon(ui.ctx(), icons::PLAY, 24.0); + painter.image( + tex.id(), + Rect::from_center_size(avatar.center(), Vec2::splat(size)), + Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), + t.fg.alpha(confirm).to_color32(), + ); + } + + name_label(&painter, rect, &account.name, t.fg); + + if confirm > 0.0 { + let inner_w = CARD_W - 2.0 * CARD_PAD - 6.0; + let mut job = egui::text::LayoutJob::single_section( + "Click again to switch".into(), + egui::TextFormat { + font_id: FontId::new(9.0, semibold()), + color: t.fg.alpha(confirm).to_color32(), + line_height: Some(9.0 * 1.2), + ..Default::default() + }, + ); + job.wrap.max_width = inner_w; + job.halign = egui::Align::Center; + let hint = painter.layout_job(job); + let hint_rect = Rect::from_min_max( + pos2( + rect.left() + CARD_PAD, + rect.bottom() - 4.0 - hint.size().y - 4.0, + ), + pos2(rect.right() - CARD_PAD, rect.bottom() - 4.0), + ); + painter.rect_filled( + hint_rect, + CornerRadius::same(4), + t.bg_solid + .mix(0.82, Rgba::TRANSPARENT) + .alpha(0.82 * confirm) + .to_color32(), + ); + // A centred job lays out around x = 0. + let pos = pos2(hint_rect.center().x, hint_rect.top() + 2.0); + painter.galley(pos, hint, t.fg.to_color32()); + } + } + + // ---------------------------------------------------------------- list + + fn list(&mut self, ui: &mut Ui, platform: Platform, items: &[Item], entrance: f32) { + let t = self.theme; + let width = ui.available_width() - SCROLL_GUTTER - 32.0; + let row_h = 44.0; + let height = 8.0 + items.len() as f32 * (row_h + 4.0); + let (area, _) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::hover()); + let mut painter = ui.painter().clone(); + painter.set_opacity(entrance); + for (i, item) in items.iter().enumerate() { + let rect = Rect::from_min_size( + pos2( + area.left() + 16.0, + area.top() + 8.0 + i as f32 * (row_h + 4.0), + ), + vec2(width, row_h), + ); + let (name, last, avatar, id) = match item { + Item::Account(idx) => { + let a = &self.data.accounts(platform)[*idx]; + ( + a.name.clone(), + data::relative_time(a.last_login), + a.avatar, + a.id.clone(), + ) + } + Item::Folder { id, name } => { + (name.to_string(), String::new(), None, id.to_string()) + } + }; + let resp = ui.interact(rect, Id::new(("row", id.clone())), Sense::click()); + let h = ease_out(anim(ui, resp.id, resp.hovered(), 0.15)); + let active = self.data.active(platform) == id; + let bg = if active { + t.card_hover + } else { + t.card.lerp(t.card_hover, h) + }; + painter.rect_filled(rect, CornerRadius::same(8), bg.to_color32()); + if let Item::Folder { id: fid, .. } = item { + if resp.clicked() { + self.folder = if *fid == ".." { None } else { Some(fid) }; + } + } + let av = Rect::from_min_size(rect.min + vec2(8.0, 8.0), Vec2::splat(28.0)); + painter.rect_filled(av, CornerRadius::same(4), t.muted.to_color32()); + if let Item::Folder { id: fid, .. } = item { + let svg = if *fid == ".." { + icons::BACK + } else { + icons::FOLDER + }; + let tex = self.icon(ui.ctx(), svg, 16.0); + painter.image( + tex.id(), + Rect::from_center_size(av.center(), Vec2::splat(16.0)), + Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), + t.fg_muted.to_color32(), + ); + } + if let Some(tex) = avatar.and_then(|a| self.avatars[a].as_ref()) { + painter.add( + RectShape::filled(av, CornerRadius::same(4), Color32::WHITE).with_texture( + tex.sharp.id(), + Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), + ), + ); + } + painter.text( + pos2(av.right() + 10.0, rect.center().y), + Align2::LEFT_CENTER, + name, + FontId::proportional(13.0), + t.fg.to_color32(), + ); + painter.text( + pos2(rect.right() - 12.0, rect.center().y), + Align2::RIGHT_CENTER, + last, + FontId::proportional(11.0), + t.fg_subtle.to_color32(), + ); + } + } +} + +// --------------------------------------------------------------- helpers + +fn semibold() -> FontFamily { + FontFamily::Name("semibold".into()) +} + +/// Segoe UI from the system, like the webview's `system-ui` fallback (Inter +/// is not installed here, so the Tauri build renders Segoe UI too). +fn install_fonts(ctx: &egui::Context) { + let mut fonts = FontDefinitions::default(); + let dir = std::env::var("WINDIR").unwrap_or_else(|_| r"C:\Windows".into()); + let read = |name: &str| std::fs::read(format!(r"{dir}\Fonts\{name}")).ok(); + let base: Vec = fonts.families[&FontFamily::Proportional].clone(); + if let Some(bytes) = read("segoeui.ttf") { + fonts + .font_data + .insert("segoe".into(), Arc::new(FontData::from_owned(bytes))); + fonts + .families + .get_mut(&FontFamily::Proportional) + .expect("proportional family") + .insert(0, "segoe".into()); + } + let mut semibold_family = Vec::new(); + if let Some(bytes) = read("seguisb.ttf") { + fonts.font_data.insert( + "segoe-semibold".into(), + Arc::new(FontData::from_owned(bytes)), + ); + semibold_family.push("segoe-semibold".to_string()); + } + semibold_family.extend(base); + fonts.families.insert(semibold(), semibold_family); + ctx.set_fonts(fonts); +} + +fn button(ui: &Ui, id: Id, rect: Rect, label: &str) -> egui::Response { + let resp = ui.interact(rect, id, Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + resp +} + +fn anim(ui: &Ui, id: Id, on: bool, secs: f32) -> f32 { + ui.ctx().animate_bool_with_time(id, on, secs) +} + +fn scale_about_center(rect: Rect, s: f32) -> Rect { + Rect::from_center_size(rect.center(), rect.size() * s) +} + +/// `0 12px 24px rgba(0,0,0,a)`. +fn shadow(painter: &egui::Painter, rect: Rect, alpha: f32) { + painter.add( + RectShape::filled( + rect.translate(vec2(0.0, 12.0)), + CornerRadius::same(8), + Color32::from_black_alpha((255.0 * alpha) as u8), + ) + .with_blur_width(24.0), + ); +} + +/// 12 px at weight 500, centred, ellipsis when too wide. The webview draws +/// 500 with the variable Segoe UI; the static semibold is the closest face. +fn name_label(painter: &egui::Painter, card: Rect, name: &str, fg: Rgba) { + let font = FontId::new(12.0, semibold()); + let max_w = card.width() - 2.0 * CARD_PAD; + let mut job = egui::text::LayoutJob::simple_singleline(name.to_string(), font, fg.to_color32()); + job.wrap = egui::text::TextWrapping::truncate_at_width(max_w); + let galley = painter.layout_job(job); + // Baseline where the webview puts it: line box of 14.4 px under the + // avatar, Segoe's ascent pushing the baseline ~12.2 px into it. + let line_top = card.top() + CARD_PAD + AVATAR + 8.0; + let pos = pos2( + card.center().x - galley.size().x / 2.0, + line_top + (14.4 - galley.size().y) / 2.0 + 1.0, + ); + painter.add(TextShape::new(pos, galley, fg.to_color32())); +} + +fn spinner(painter: &egui::Painter, center: Pos2, now: f64, t: &Theme) { + let r = 9.0; + painter.circle_stroke(center, r, Stroke::new(2.0, t.elevated.to_color32())); + let start = (now / 0.7 * std::f64::consts::TAU) as f32; + let points: Vec = (0..=16) + .map(|i| { + let a = start + i as f32 / 16.0 * std::f32::consts::FRAC_PI_2; + center + vec2(a.cos(), a.sin()) * r + }) + .collect(); + painter.add(Shape::line(points, Stroke::new(2.0, t.fg.to_color32()))); +} + +/// 145 degree linear gradient, as `getAvatarGradientStyle` paints it. +fn gradient_image(a: Rgba, b: Rgba, size: usize) -> ColorImage { + let angle = 145f32.to_radians(); + let dir = vec2(angle.sin(), -angle.cos()); + let half = (dir.x.abs() + dir.y.abs()) / 2.0; + let mut pixels = Vec::with_capacity(size * size); + for y in 0..size { + for x in 0..size { + let p = vec2( + x as f32 / (size - 1) as f32 - 0.5, + y as f32 / (size - 1) as f32 - 0.5, + ); + let t = ((p.dot(dir) / half) + 1.0) / 2.0; + pixels.push(a.lerp(b, t.clamp(0.0, 1.0)).to_color32()); + } + } + ColorImage::new([size, size], pixels) +} diff --git a/crates/accshift-egui/src/avatars.rs b/crates/accshift-egui/src/avatars.rs new file mode 100644 index 0000000..c68e36b --- /dev/null +++ b/crates/accshift-egui/src/avatars.rs @@ -0,0 +1,109 @@ +//! SVG rasterization off the UI thread, and the blurred copy the armed and +//! switching states fade to (the webview applies `filter: blur(6px)` live; a +//! GL 2D renderer has no filter, so the blur is baked once per avatar). + +use std::sync::mpsc::{channel, Receiver}; + +use egui::{Color32, ColorImage}; + +use crate::data::AVATARS; + +pub struct Decoded { + pub index: usize, + pub sharp: ColorImage, + pub blurred: ColorImage, +} + +pub fn raster(svg: &[u8], px: u32) -> Result { + egui_extras::image::load_svg_bytes_with_size( + svg, + egui::SizeHint::Size { + width: px, + height: px, + maintain_aspect_ratio: true, + }, + &resvg::usvg::Options::default(), + ) +} + +pub fn spawn(ctx: egui::Context, px: u32) -> Receiver { + let (tx, rx) = channel(); + std::thread::Builder::new() + .name("avatars".into()) + .spawn(move || { + for (index, svg) in AVATARS.iter().enumerate() { + let Ok(sharp) = raster(svg, px) else { continue }; + // blur(6px) on a 68 px box, at this raster's scale. + let sigma = 6.0 * px as f32 / 68.0; + let blurred = box_blur(&sharp, sigma); + if tx + .send(Decoded { + index, + sharp, + blurred, + }) + .is_err() + { + return; + } + ctx.request_repaint(); + } + }) + .expect("spawn avatar thread"); + rx +} + +/// Three box passes per axis approximate a gaussian of `sigma`. Works on the +/// premultiplied pixels, so transparent edges do not halo. +fn box_blur(image: &ColorImage, sigma: f32) -> ColorImage { + let [w, h] = image.size; + let width = (4.0 * sigma * sigma + 1.0).sqrt(); + let radius = ((width - 1.0) / 2.0).round().max(1.0) as usize; + let mut buf: Vec<[f32; 4]> = image + .pixels + .iter() + .map(|c| [c.r() as f32, c.g() as f32, c.b() as f32, c.a() as f32]) + .collect(); + let mut tmp = buf.clone(); + for _ in 0..3 { + pass(&buf, &mut tmp, w, h, radius, true); + pass(&tmp, &mut buf, w, h, radius, false); + } + let pixels = buf + .into_iter() + .map(|[r, g, b, a]| Color32::from_rgba_premultiplied(r as u8, g as u8, b as u8, a as u8)) + .collect(); + ColorImage::new([w, h], pixels) +} + +fn pass(src: &[[f32; 4]], dst: &mut [[f32; 4]], w: usize, h: usize, r: usize, horizontal: bool) { + let (len, lines) = if horizontal { (w, h) } else { (h, w) }; + let at = |line: usize, i: usize| { + if horizontal { + line * w + i + } else { + i * w + line + } + }; + let norm = 1.0 / (2 * r + 1) as f32; + for line in 0..lines { + let mut acc = [0f32; 4]; + for k in 0..=2 * r { + let i = (k as isize - r as isize).clamp(0, len as isize - 1) as usize; + for c in 0..4 { + acc[c] += src[at(line, i)][c]; + } + } + for i in 0..len { + let p = &mut dst[at(line, i)]; + for c in 0..4 { + p[c] = acc[c] * norm; + } + let out = (i as isize - r as isize).clamp(0, len as isize - 1) as usize; + let inn = (i as isize + r as isize + 1).clamp(0, len as isize - 1) as usize; + for c in 0..4 { + acc[c] += src[at(line, inn)][c] - src[at(line, out)][c]; + } + } + } +} diff --git a/crates/accshift-egui/src/backdrop.rs b/crates/accshift-egui/src/backdrop.rs new file mode 100644 index 0000000..4e9cc2f --- /dev/null +++ b/crates/accshift-egui/src/backdrop.rs @@ -0,0 +1,83 @@ +//! The stand-in desktop of `.agents/scripts/record-demo.mjs` (four soft +//! radial blooms on a dark linear gradient). Only painted for offscreen +//! captures: the real window shows the OS acrylic instead. + +use egui::{Color32, ColorImage}; + +use crate::theme::Rgba; + +struct Bloom { + rx: f32, + ry: f32, + cx: f32, + cy: f32, + color: Rgba, +} + +/// The recorder paints the desktop 36 px larger on each side and shifts it by +/// -36 px, so the window sees the middle of a 932x512 canvas. +const PAD: f32 = 36.0; + +pub fn render(width: usize, height: usize, scale: f32) -> ColorImage { + let desk_w = width as f32 + 2.0 * PAD; + let desk_h = height as f32 + 2.0 * PAD; + let blooms = [ + Bloom { + rx: 0.48, + ry: 0.62, + cx: 0.16, + cy: 0.20, + color: Rgba::new(109.0, 40.0, 217.0, 0.42), + }, + Bloom { + rx: 0.46, + ry: 0.60, + cx: 0.86, + cy: 0.84, + color: Rgba::new(29.0, 78.0, 216.0, 0.40), + }, + Bloom { + rx: 0.34, + ry: 0.44, + cx: 0.72, + cy: 0.10, + color: Rgba::new(190.0, 24.0, 93.0, 0.22), + }, + Bloom { + rx: 0.38, + ry: 0.48, + cx: 0.28, + cy: 0.96, + color: Rgba::new(8.0, 145.0, 178.0, 0.22), + }, + ]; + let (pw, ph) = ( + (width as f32 * scale) as usize, + (height as f32 * scale) as usize, + ); + let top = Rgba::hex(0x10121f, 1.0); + let bottom = Rgba::hex(0x0a0c16, 1.0); + let angle = 160f32.to_radians(); + let (dx, dy) = (angle.sin(), -angle.cos()); + let half = (desk_w * dx.abs() + desk_h * dy.abs()) / 2.0; + let mut pixels = Vec::with_capacity(pw * ph); + for py in 0..ph { + for px in 0..pw { + let x = px as f32 / scale + PAD; + let y = py as f32 / scale + PAD; + let along = ((x - desk_w / 2.0) * dx + (y - desk_h / 2.0) * dy) / half; + let mut c = top.lerp(bottom, ((along + 1.0) / 2.0).clamp(0.0, 1.0)); + // CSS paints the first layer on top: composite from the last. + for b in blooms.iter().rev() { + let ex = (x / desk_w - b.cx) / b.rx; + let ey = (y / desk_h - b.cy) / b.ry; + let d = (ex * ex + ey * ey).sqrt(); + // `transparent 72%`: full colour at the centre, gone at 0.72. + let a = b.color.a * (1.0 - (d / 0.72)).clamp(0.0, 1.0); + c = c.lerp(b.color.alpha(1.0), a); + } + pixels.push(Color32::from_rgb(c.r as u8, c.g as u8, c.b as u8)); + } + } + ColorImage::new([pw, ph], pixels) +} diff --git a/crates/accshift-egui/src/bench.rs b/crates/accshift-egui/src/bench.rs new file mode 100644 index 0000000..212f4f4 --- /dev/null +++ b/crates/accshift-egui/src/bench.rs @@ -0,0 +1,60 @@ +//! Boot marks for `.agents/perf/bench-egui.ps1`: one JSON line per event in +//! `%TEMP%\accshift-egui-bench\.jsonl`, epoch milliseconds. +//! +//! Active in a `startup-bench` build, or with `ACCSHIFT_EGUI_MARKS=1`. + +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// The harness greps the exe for this string and refuses to launch a build +/// without it: only a bench build keeps its window off screen. +pub const MARKER: &str = "accshift-egui-startup-bench-build"; + +static SINK: OnceLock>> = OnceLock::new(); + +pub fn enabled() -> bool { + cfg!(feature = "startup-bench") || std::env::var_os("ACCSHIFT_EGUI_MARKS").is_some() +} + +fn sink() -> &'static Option> { + SINK.get_or_init(|| { + if !enabled() { + return None; + } + let dir = std::env::temp_dir().join("accshift-egui-bench"); + std::fs::create_dir_all(&dir).ok()?; + let path = dir.join(format!("{}.jsonl", std::process::id())); + OpenOptions::new() + .create(true) + .append(true) + .open(path) + .ok() + .map(Mutex::new) + }) +} + +pub fn now_ms() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs_f64() * 1000.0) + .unwrap_or_default() +} + +pub fn mark(event: &str) { + mark_with(event, ""); +} + +pub fn mark_with(event: &str, extra: &str) { + let Some(sink) = sink() else { return }; + let ts = now_ms(); + let line = if extra.is_empty() { + format!("{{\"event\":\"{event}\",\"tsMs\":{ts:.3}}}\n") + } else { + format!("{{\"event\":\"{event}\",\"tsMs\":{ts:.3},{extra}}}\n") + }; + if let Ok(mut f) = sink.lock() { + let _ = f.write_all(line.as_bytes()); + } +} diff --git a/crates/accshift-egui/src/capture.rs b/crates/accshift-egui/src/capture.rs new file mode 100644 index 0000000..678ea7a --- /dev/null +++ b/crates/accshift-egui/src/capture.rs @@ -0,0 +1,195 @@ +//! Scripted capture run: `accshift-egui --capture `. +//! +//! The window gets the bench recipe (never activated, off screen, cloaked), a +//! fixed script feeds pointer and text events through `raw_input_hook`, and +//! each shot is read back from the GL framebuffer of the real glow renderer. +//! Shots are written as PAM files (a 7-line header plus raw RGBA), so the +//! binary needs no image encoder; `ffmpeg -i shot.pam shot.png` converts them. +//! +//! The script reproduces the frames of the Tauri recorder's `demo-switch` +//! (rest with hover, armed card, search, after the switch), then the other +//! tabs, a folder and the list view. + +use std::io::Write; +use std::path::PathBuf; +use std::time::Instant; + +use egui::{pos2, Event, Modifiers, PointerButton, Pos2, RawInput}; + +#[derive(Clone, Copy)] +enum Step { + Wait(f64), + Move(Pos2), + Press(Pos2), + Release(Pos2), + Text(&'static str), + Away, + Shot(&'static str), + Quit, +} + +pub struct Capture { + dir: PathBuf, + steps: Vec, + next: usize, + wake_at: f64, + clock: Instant, + pending_shot: Option<&'static str>, + pub done: bool, +} + +/// Card centres: 7 columns from x = 45, 110 apart; rows from y = 84, 146 apart. +fn card(col: f32, row: f32) -> Pos2 { + pos2(45.0 + col * 110.0 + 50.0, 84.0 + row * 146.0 + 68.0) +} + +fn click(steps: &mut Vec, p: Pos2) { + steps.extend([Step::Move(p), Step::Press(p), Step::Release(p)]); +} + +fn script() -> Vec { + use Step::*; + let mut s = vec![Wait(0.6)]; + // switch-0: grid at rest, pointer on "practice". + s.extend([Move(card(6.0, 1.0)), Wait(0.5), Shot("egui-0-hover")]); + // switch-2: "bro's account" armed, pointer still on it. + click(&mut s, card(2.0, 0.0)); + s.extend([Wait(0.6), Shot("egui-2-armed")]); + // switch-5: query "ra" typed in the search field. + click(&mut s, pos2(639.0, 60.0)); + s.extend([Text("ra"), Wait(0.6), Shot("egui-5-search")]); + // switch-9: query cleared, switch to "bro's account" done, pointer on + // the folder. + click(&mut s, pos2(741.0, 60.0)); + s.push(Wait(0.4)); + click(&mut s, card(2.0, 0.0)); + s.push(Wait(0.3)); + click(&mut s, card(2.0, 0.0)); + s.extend([ + Wait(1.4), + Move(card(0.0, 0.0)), + Wait(0.5), + Shot("egui-9-switched"), + ]); + // Beyond the recorded frames. + for (name, tab) in [ + ("egui-riot", pos2(447.0, 18.0)), + ("egui-roblox", pos2(481.0, 18.0)), + ] { + click(&mut s, tab); + s.extend([Away, Wait(0.7), Shot(name)]); + } + click(&mut s, pos2(413.0, 18.0)); + s.push(Wait(0.4)); + click(&mut s, card(0.0, 0.0)); + s.extend([Away, Wait(0.7), Shot("egui-folder")]); + click(&mut s, pos2(35.0, 60.0)); + s.push(Wait(0.3)); + click(&mut s, pos2(816.0, 60.0)); + s.extend([Away, Wait(0.7), Shot("egui-list"), Quit]); + s +} + +impl Capture { + pub fn new(dir: PathBuf) -> Self { + Self { + dir, + steps: script(), + next: 0, + wake_at: 0.0, + clock: Instant::now(), + pending_shot: None, + done: false, + } + } + + /// Inject this frame's scripted events. Nothing runs before `ready` + /// (every avatar uploaded) or while a screenshot is in flight. + pub fn feed(&mut self, ctx: &egui::Context, raw: &mut RawInput, ready: bool) { + // The window is never activated; egui must still treat it as focused + // for the text field to take the typed query. + raw.focused = true; + ctx.request_repaint(); + if !ready || self.pending_shot.is_some() || self.done { + return; + } + let now = self.clock.elapsed().as_secs_f64(); + if self.next == 0 && self.wake_at == 0.0 { + self.wake_at = now; + } + while now >= self.wake_at && self.next < self.steps.len() { + let step = self.steps[self.next]; + self.next += 1; + let button = |pos, pressed| Event::PointerButton { + pos, + button: PointerButton::Primary, + pressed, + modifiers: Modifiers::NONE, + }; + match step { + Step::Wait(secs) => { + self.wake_at = now + secs; + return; + } + Step::Move(p) => raw.events.push(Event::PointerMoved(p)), + Step::Press(p) => { + raw.events.push(button(p, true)); + // Release on the next frame, like a real click. + return; + } + Step::Release(p) => raw.events.push(button(p, false)), + Step::Text(text) => raw.events.push(Event::Text(text.into())), + Step::Away => raw.events.push(Event::PointerGone), + Step::Shot(name) => { + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(Default::default())); + self.pending_shot = Some(name); + return; + } + Step::Quit => { + self.done = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + return; + } + } + } + } + + /// Write the screenshot that arrived this frame, if any. + pub fn collect(&mut self, ctx: &egui::Context) { + let Some(name) = self.pending_shot else { + return; + }; + let image = ctx.input(|i| { + i.raw.events.iter().find_map(|e| match e { + Event::Screenshot { image, .. } => Some(image.clone()), + _ => None, + }) + }); + let Some(image) = image else { return }; + let path = self.dir.join(format!("{name}.pam")); + if let Err(err) = write_pam(&path, &image) { + eprintln!("capture: {}: {err}", path.display()); + } + crate::bench::mark_with( + "shot", + &format!( + "\"name\":\"{name}\",\"w\":{},\"h\":{}", + image.size[0], image.size[1] + ), + ); + self.pending_shot = None; + } +} + +fn write_pam(path: &std::path::Path, image: &egui::ColorImage) -> std::io::Result<()> { + let [w, h] = image.size; + let mut out = std::io::BufWriter::new(std::fs::File::create(path)?); + write!( + out, + "P7\nWIDTH {w}\nHEIGHT {h}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n" + )?; + for px in &image.pixels { + out.write_all(&px.to_srgba_unmultiplied())?; + } + out.flush() +} diff --git a/crates/accshift-egui/src/data.rs b/crates/accshift-egui/src/data.rs new file mode 100644 index 0000000..caace60 --- /dev/null +++ b/crates/accshift-egui/src/data.rs @@ -0,0 +1,226 @@ +//! The frozen `demo` scenario of `src/lib/mock/scenarios.ts`, ported as is so +//! the egui captures line up with the README clips frame for frame. + +pub const NOW: u64 = 1_791_000_000; +const HOUR: u64 = 3600; +const DAY: u64 = 24 * HOUR; + +/// The 21 demo faces, shared with the web mock (`src/lib/mock/avatars`). +pub const AVATARS: [&[u8]; 21] = [ + include_bytes!("../../../src/lib/mock/avatars/1.svg"), + include_bytes!("../../../src/lib/mock/avatars/2.svg"), + include_bytes!("../../../src/lib/mock/avatars/3.svg"), + include_bytes!("../../../src/lib/mock/avatars/4.svg"), + include_bytes!("../../../src/lib/mock/avatars/5.svg"), + include_bytes!("../../../src/lib/mock/avatars/6.svg"), + include_bytes!("../../../src/lib/mock/avatars/7.svg"), + include_bytes!("../../../src/lib/mock/avatars/8.svg"), + include_bytes!("../../../src/lib/mock/avatars/9.svg"), + include_bytes!("../../../src/lib/mock/avatars/10.svg"), + include_bytes!("../../../src/lib/mock/avatars/11.svg"), + include_bytes!("../../../src/lib/mock/avatars/12.svg"), + include_bytes!("../../../src/lib/mock/avatars/13.svg"), + include_bytes!("../../../src/lib/mock/avatars/14.svg"), + include_bytes!("../../../src/lib/mock/avatars/15.svg"), + include_bytes!("../../../src/lib/mock/avatars/16.svg"), + include_bytes!("../../../src/lib/mock/avatars/17.svg"), + include_bytes!("../../../src/lib/mock/avatars/18.svg"), + include_bytes!("../../../src/lib/mock/avatars/19.svg"), + include_bytes!("../../../src/lib/mock/avatars/20.svg"), + include_bytes!("../../../src/lib/mock/avatars/21.svg"), +]; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Platform { + Steam, + Riot, + Roblox, +} + +impl Platform { + pub const ALL: [Platform; 3] = [Platform::Steam, Platform::Riot, Platform::Roblox]; + + pub fn name(self) -> &'static str { + match self { + Platform::Steam => "Steam", + Platform::Riot => "Riot Games", + Platform::Roblox => "Roblox", + } + } + + /// `PLATFORM_CHROME[id].accent` in `src/lib/platforms/registry.ts`. + pub fn accent(self) -> u32 { + match self { + Platform::Steam => 0x2563eb, + Platform::Riot => 0xef4444, + Platform::Roblox => 0xe1242a, + } + } +} + +#[derive(Clone, Debug)] +pub struct Account { + pub id: String, + pub name: String, + pub last_login: Option, + /// Index into [`AVATARS`]; `None` draws the gradient and initials. + pub avatar: Option, + pub color: Option, +} + +#[derive(Clone, Debug)] +pub enum Item { + Account(usize), + Folder { + id: &'static str, + name: &'static str, + }, +} + +pub struct Dataset { + pub steam: Vec, + pub riot: Vec, + pub roblox: Vec, + pub steam_root: Vec, + pub smurfs: Vec, + pub active_steam: String, + pub active_riot: String, + pub active_roblox: String, +} + +impl Dataset { + pub fn accounts(&self, platform: Platform) -> &[Account] { + match platform { + Platform::Steam => &self.steam, + Platform::Riot => &self.riot, + Platform::Roblox => &self.roblox, + } + } + + pub fn active(&self, platform: Platform) -> &str { + match platform { + Platform::Steam => &self.active_steam, + Platform::Riot => &self.active_riot, + Platform::Roblox => &self.active_roblox, + } + } + + pub fn set_active(&mut self, platform: Platform, id: String) { + match platform { + Platform::Steam => self.active_steam = id, + Platform::Riot => self.active_riot = id, + Platform::Roblox => self.active_roblox = id, + } + } +} + +fn account(id: &str, name: &str, last_login: Option, avatar: Option) -> Account { + Account { + id: id.to_string(), + name: name.to_string(), + last_login, + avatar, + color: None, + } +} + +pub fn demo() -> Dataset { + let steam_names: [(&str, Option); 17] = [ + ("main", Some(NOW - 2 * HOUR)), + ("bro's account", Some(NOW - 3 * DAY)), + ("alt", Some(NOW - 9 * DAY)), + ("trading", Some(NOW - 21 * DAY)), + ("faceit", Some(NOW - HOUR)), + ("ranked grind", Some(NOW - 5 * DAY)), + ("chill", Some(NOW - 14 * DAY)), + ("lan party", Some(NOW - 40 * DAY)), + ("streams", Some(NOW - 4 * HOUR)), + ("esea", Some(NOW - 8 * DAY)), + ("tournaments", Some(NOW - 60 * DAY)), + ("family", Some(NOW - 2 * DAY)), + ("practice", Some(NOW - 6 * HOUR)), + ("smurf 1", Some(NOW - 12 * DAY)), + ("smurf 2", Some(NOW - 27 * DAY)), + ("smurf 3", Some(NOW - 33 * DAY)), + ("smurf 4", None), + ]; + let mut steam: Vec = steam_names + .iter() + .enumerate() + .map(|(i, (name, last))| { + account( + &format!("765611980000000{:02}", i + 1), + name, + *last, + Some(i), + ) + }) + .collect(); + // "client.account-card-colors" of the scenario. + steam[0].color = Some(0x8b5cf6); + steam[4].color = Some(0xf97316); + steam[8].color = Some(0xec4899); + + let riot = vec![ + account("riot-1", "main", Some(NOW - HOUR), None), + account("riot-2", "smurf", Some(NOW - 6 * DAY), None), + account("riot-3", "duo account", Some(NOW - 19 * DAY), None), + ]; + let roblox = vec![ + account("1000000001", "builder", Some(NOW - 2 * HOUR), Some(17)), + account("1000000002", "obby runs", Some(NOW - 3 * DAY), Some(18)), + account("1000000003", "tycoon alt", Some(NOW - 11 * DAY), Some(19)), + account("1000000004", "trading", Some(NOW - 25 * DAY), Some(20)), + ]; + + // The web grid puts folders first, then accounts in saved order. + let mut steam_root = vec![Item::Folder { + id: "demo-folder-smurfs", + name: "Smurfs", + }]; + steam_root.extend((0..13).map(Item::Account)); + let smurfs = (13..17).map(Item::Account).collect(); + + Dataset { + active_steam: steam[0].id.clone(), + active_riot: riot[0].id.clone(), + active_roblox: roblox[0].id.clone(), + steam, + riot, + roblox, + steam_root, + smurfs, + } +} + +/// "2h ago", "3d ago", in the spirit of `formatRelativeTimeCompact`. +pub fn relative_time(ts: Option) -> String { + let Some(ts) = ts else { + return "Never".to_string(); + }; + let diff = NOW.saturating_sub(ts); + if diff < HOUR { + format!("{}m ago", diff / 60) + } else if diff < DAY { + format!("{}h ago", diff / HOUR) + } else if diff < 30 * DAY { + format!("{}d ago", diff / DAY) + } else { + format!("{}mo ago", diff / (30 * DAY)) + } +} + +/// Initials as `getAvatarInitials` builds them. +pub fn initials(name: &str) -> String { + let parts: Vec<&str> = name.split_whitespace().collect(); + match parts.as_slice() { + [] => "?".to_string(), + [one] => one.chars().take(1).collect::().to_uppercase(), + [a, b, ..] => format!( + "{}{}", + a.chars().next().unwrap_or_default(), + b.chars().next().unwrap_or_default() + ) + .to_uppercase(), + } +} diff --git a/crates/accshift-egui/src/icons.rs b/crates/accshift-egui/src/icons.rs new file mode 100644 index 0000000..0c3914c --- /dev/null +++ b/crates/accshift-egui/src/icons.rs @@ -0,0 +1,101 @@ +//! The SVG icons the Svelte shell draws inline, copied from `TitleBar.svelte`, +//! `ViewToggle.svelte`, `FolderCard.svelte`, `BackCard.svelte` and +//! `platformIcons.ts`. `currentColor` is spelled white so a tint gives the +//! final colour. + +macro_rules! svg { + ($view:literal, $attrs:literal, $body:literal) => { + concat!( + "", + $body, + "" + ) + .as_bytes() + }; +} + +pub const REFRESH: &[u8] = svg!( + "0 0 24 24", + "fill=\"none\" stroke=\"white\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"", + "" +); +pub const PLUS: &[u8] = svg!( + "0 0 24 24", + "fill=\"none\" stroke=\"white\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"", + "" +); +pub const SETTINGS: &[u8] = svg!( + "0 0 24 24", + "fill=\"none\" stroke=\"white\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"", + "" +); +pub const BULK_EDIT: &[u8] = svg!( + "0 0 24 24", + "fill=\"none\" stroke=\"white\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"", + "" +); +pub const MINIMIZE: &[u8] = svg!( + "0 0 12 12", + "", + "" +); +pub const MAXIMIZE: &[u8] = svg!( + "0 0 12 12", + "", + "" +); +pub const RESTORE: &[u8] = svg!( + "0 0 12 12", + "", + "" +); +pub const CLOSE: &[u8] = svg!( + "0 0 12 12", + "", + "" +); +pub const GRID: &[u8] = svg!( + "0 0 24 24", + "fill=\"white\"", + "" +); +pub const LIST: &[u8] = svg!( + "0 0 24 24", + "fill=\"white\"", + "" +); +pub const FOLDER: &[u8] = svg!( + "0 0 24 24", + "fill=\"none\" stroke=\"white\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"", + "" +); +pub const BACK: &[u8] = svg!( + "0 0 24 24", + "fill=\"none\" stroke=\"white\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"", + "" +); +pub const PLAY: &[u8] = svg!("0 0 24 24", "fill=\"white\"", ""); +pub const PERSONAS: &[u8] = svg!( + "0 0 24 24", + "fill=\"white\"", + "" +); +pub const STEAM: &[u8] = svg!( + "0 0 24 24", + "fill=\"white\"", + "" +); +pub const RIOT: &[u8] = svg!( + "0 0 24 24", + "fill=\"white\"", + "" +); +pub const ROBLOX: &[u8] = svg!( + "0 0 24 24", + "fill=\"white\"", + "" +); diff --git a/crates/accshift-egui/src/lib.rs b/crates/accshift-egui/src/lib.rs new file mode 100644 index 0000000..5b7b237 --- /dev/null +++ b/crates/accshift-egui/src/lib.rs @@ -0,0 +1,38 @@ +//! Spike: the accshift main screen in egui. See `README.md` next to this crate. + +pub mod app; +pub mod avatars; +pub mod backdrop; +pub mod bench; +pub mod capture; +pub mod data; +pub mod icons; +pub mod theme; + +pub use app::{AccshiftApp, Options}; + +/// The eframe wrapper: transparent clear so the OS acrylic shows through the +/// 55 % window fill, exactly like the Tauri window. +pub struct Shell { + pub app: AccshiftApp, + pub capture: Option, +} + +impl eframe::App for Shell { + fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { + if let Some(capture) = &mut self.capture { + capture.feed(ctx, raw_input, self.app.avatars_ready()); + } + } + + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + if let Some(capture) = &mut self.capture { + capture.collect(ui.ctx()); + } + self.app.ui(ui); + } + + fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] { + [0.0, 0.0, 0.0, 0.0] + } +} diff --git a/crates/accshift-egui/src/main.rs b/crates/accshift-egui/src/main.rs new file mode 100644 index 0000000..a7a7cf0 --- /dev/null +++ b/crates/accshift-egui/src/main.rs @@ -0,0 +1,126 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +use accshift_egui::{bench, capture::Capture, AccshiftApp, Options, Shell}; + +fn main() -> eframe::Result { + let bench_build = cfg!(feature = "startup-bench"); + // `--capture `: scripted offscreen shots, see src/capture.rs. + let capture_dir = { + let mut args = std::env::args().skip(1); + let mut dir = None; + while let Some(arg) = args.next() { + if arg == "--capture" { + dir = args.next().map(std::path::PathBuf::from); + } + } + dir + }; + let hidden = bench_build || capture_dir.is_some(); + // Experiment knob for the boot breakdown: an opaque window without the + // acrylic, to price the transparent framebuffer. + let opaque = std::env::var_os("ACCSHIFT_EGUI_OPAQUE").is_some(); + bench::mark_with( + "main", + &format!( + "\"build\":\"{}\"", + if bench_build { bench::MARKER } else { "dev" } + ), + ); + if hidden { + // Safety net: a hidden process never outlives its run by much. + std::thread::spawn(|| { + std::thread::sleep(std::time::Duration::from_secs(60)); + std::process::exit(0); + }); + } + + // The core is called directly, no IPC: read the real Steam login list + // (read-only) off the UI thread, to prove the link and time it. + std::thread::spawn(|| { + let path = std::path::Path::new(r"C:\Program Files (x86)\Steam"); + let count = accshift_core::platforms::steam::accounts::get_accounts(path) + .map(|accounts| accounts.len()) + .unwrap_or(0); + bench::mark_with("core_accounts", &format!("\"count\":{count}")); + }); + + let mut viewport = egui::ViewportBuilder::default() + .with_title("Accshift (egui spike)") + .with_app_id("accshift-egui") + .with_inner_size([860.0, 440.0]) + .with_min_inner_size([520.0, 320.0]) + .with_decorations(false) + .with_transparent(!opaque) + .with_resizable(true); + if hidden { + // Never activated, never in the taskbar, off every monitor, and + // cloaked below: eframe shows the window after its first frame + // whatever the builder says, so this is what keeps it unseen. + viewport = viewport + .with_active(false) + .with_taskbar(false) + .with_position([-32000.0, -32000.0]); + } + let options = eframe::NativeOptions { + viewport, + persist_window: false, + // Experiment knob for the boot breakdown: does the first swap wait + // on the compositor? + glow_options: eframe::egui_glow::GlowConfiguration { + vsync: std::env::var_os("ACCSHIFT_EGUI_NO_VSYNC").is_none(), + ..Default::default() + }, + ..Default::default() + }; + eframe::run_native( + "accshift-egui", + options, + Box::new(move |cc| { + #[cfg(windows)] + window_effects(cc, hidden, !opaque); + bench::mark("app_created"); + let capture = capture_dir.map(|dir| { + std::fs::create_dir_all(&dir).ok(); + // The Tauri recorder's scale: 860x440 points at 1.5. + let native = cc.egui_ctx.native_pixels_per_point().unwrap_or(1.0); + cc.egui_ctx.set_zoom_factor(1.5 / native); + cc.egui_ctx + .send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(860.0, 440.0))); + Capture::new(dir) + }); + let options = Options { + desktop_backdrop: capture.is_some(), + }; + Ok(Box::new(Shell { + app: AccshiftApp::new(&cc.egui_ctx, options), + capture, + })) + }), + ) +} + +#[cfg(windows)] +fn window_effects(cc: &eframe::CreationContext<'_>, cloak: bool, acrylic: bool) { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + if cloak { + if let Ok(handle) = cc.window_handle() { + if let RawWindowHandle::Win32(h) = handle.as_raw() { + let on: i32 = 1; + // SAFETY: valid HWND owned by eframe, 4-byte BOOL attribute. + unsafe { + windows_sys::Win32::Graphics::Dwm::DwmSetWindowAttribute( + h.hwnd.get() as _, + windows_sys::Win32::Graphics::Dwm::DWMWA_CLOAK as u32, + &on as *const i32 as *const _, + 4, + ); + } + } + } + } + // Glass Dark's material in Tauri: acrylic with a heavy black tint + // (`ACRYLIC_TINTS` in src/lib/theme/backdrop.ts). + if acrylic { + let _ = window_vibrancy::apply_acrylic(cc, Some((0, 0, 0, 150))); + } +} diff --git a/crates/accshift-egui/src/theme.rs b/crates/accshift-egui/src/theme.rs new file mode 100644 index 0000000..e8501e7 --- /dev/null +++ b/crates/accshift-egui/src/theme.rs @@ -0,0 +1,173 @@ +//! Glass Dark, resolved the way `themes.ts` resolves it for the webview. +//! +//! The webview gets these as CSS custom properties; here they are plain +//! colours. Opacities follow `resolveThemeSurfaceOpacities` for a glass theme +//! with a live backdrop: window fill 0.55, card 0.65, hover 0.73, muted 0.69, +//! elevated 0.75, overlay 0.86. + +use egui::Color32; + +/// Unpremultiplied sRGB colour with a float alpha, the space CSS +/// `color-mix(in srgb, ...)` works in. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Rgba { + pub r: f32, + pub g: f32, + pub b: f32, + pub a: f32, +} + +impl Rgba { + pub const TRANSPARENT: Rgba = Rgba::new(0.0, 0.0, 0.0, 0.0); + + pub const fn new(r: f32, g: f32, b: f32, a: f32) -> Self { + Self { r, g, b, a } + } + + pub const fn hex(rgb: u32, a: f32) -> Self { + Self { + r: ((rgb >> 16) & 0xff) as f32, + g: ((rgb >> 8) & 0xff) as f32, + b: (rgb & 0xff) as f32, + a, + } + } + + pub fn alpha(self, a: f32) -> Self { + Self { a, ..self } + } + + pub fn to_color32(self) -> Color32 { + Color32::from_rgba_unmultiplied( + self.r.round().clamp(0.0, 255.0) as u8, + self.g.round().clamp(0.0, 255.0) as u8, + self.b.round().clamp(0.0, 255.0) as u8, + (self.a * 255.0).round().clamp(0.0, 255.0) as u8, + ) + } + + /// `color-mix(in srgb, self p, other)`: premultiplied interpolation, as + /// CSS Color 5 specifies it. + pub fn mix(self, p: f32, other: Rgba) -> Rgba { + let q = 1.0 - p; + let a = self.a * p + other.a * q; + if a <= 0.0 { + return Rgba::TRANSPARENT; + } + let ch = |x: f32, y: f32| (x * self.a * p + y * other.a * q) / a; + Rgba::new( + ch(self.r, other.r), + ch(self.g, other.g), + ch(self.b, other.b), + a, + ) + } + + /// Plain linear interpolation, for transitions between two states. + pub fn lerp(self, other: Rgba, t: f32) -> Rgba { + let l = |x: f32, y: f32| x + (y - x) * t; + Rgba::new( + l(self.r, other.r), + l(self.g, other.g), + l(self.b, other.b), + l(self.a, other.a), + ) + } +} + +pub struct Theme { + pub bg_solid: Rgba, + pub window_fill: Rgba, + pub card: Rgba, + pub card_hover: Rgba, + pub muted: Rgba, + pub elevated: Rgba, + pub fg: Rgba, + pub fg_muted: Rgba, + pub fg_subtle: Rgba, + pub border: Rgba, + pub danger: Rgba, +} + +pub const GLASS_DARK: Theme = Theme { + bg_solid: Rgba::hex(0x050508, 1.0), + window_fill: Rgba::hex(0x050508, 0.55), + card: Rgba::hex(0x131318, 0.65), + card_hover: Rgba::hex(0x1b1b22, 0.73), + muted: Rgba::hex(0x17171e, 0.69), + elevated: Rgba::hex(0x2a2a34, 0.75), + fg: Rgba::hex(0xf4f4f6, 1.0), + fg_muted: Rgba::hex(0xaeaeba, 1.0), + fg_subtle: Rgba::hex(0x7c7c88, 1.0), + border: Rgba::hex(0x2c2c36, 1.0), + danger: Rgba::hex(0xef4444, 1.0), +}; + +/// CSS `ease-out` (cubic-bezier(0, 0, 0.58, 1)), close enough for 180 ms. +pub fn ease_out(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + 1.0 - (1.0 - t) * (1.0 - t) +} + +/// The avatar fallback gradient of `avatarFallback.ts`: same hash, same hues. +pub fn fallback_gradient(seed: &str) -> (Rgba, Rgba) { + let normalized: String = seed + .trim() + .to_lowercase() + .split_whitespace() + .collect::>() + .join(" "); + let normalized = if normalized.is_empty() { + "?".to_string() + } else { + normalized + }; + let hash = |mult: u64, modulo: u64, seed: u64| { + let mut acc = seed % modulo; + for unit in normalized.encode_utf16() { + for b in 0..16u64 { + let bit = ((unit as u64) >> b) & 1; + acc = (acc * mult + bit + b) % modulo; + } + } + acc + }; + let base = hash(33, 997, 17); + let fade = hash(29, 991, 53); + let hue = (base * 47) % 360; + let hue_fade = (hue + ((fade * 61) % 181) + 37) % 360; + ( + hsl(hue as f32, 0.80, 0.56), + hsl(hue_fade as f32, 0.66, 0.38), + ) +} + +fn hsl(h: f32, s: f32, l: f32) -> Rgba { + let c = (1.0 - (2.0 * l - 1.0).abs()) * s; + let hp = h / 60.0; + let x = c * (1.0 - (hp % 2.0 - 1.0).abs()); + let (r, g, b) = match hp as u32 { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + let m = l - c / 2.0; + Rgba::new((r + m) * 255.0, (g + m) * 255.0, (b + m) * 255.0, 1.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mix_matches_css_premultiplied() { + // color-mix(in srgb, #f4f4f6 62%, transparent) keeps the colour and + // scales the alpha. + let ring = GLASS_DARK.fg.mix(0.62, Rgba::TRANSPARENT); + assert!((ring.a - 0.62).abs() < 1e-6); + assert!((ring.r - 244.0).abs() < 1e-3); + } +} From 74f207feb785598b26ed448d8a07462256650c16 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:11:43 +0200 Subject: [PATCH 002/106] perf(egui-spike): render on the CPU and cut startup to 57 ms --- crates/accshift-egui/Cargo.lock | 125 +- crates/accshift-egui/Cargo.toml | 66 +- crates/accshift-egui/README.md | 55 +- crates/accshift-egui/build.rs | 23 + crates/accshift-egui/src/app.rs | 16 +- crates/accshift-egui/src/lib.rs | 27 +- crates/accshift-egui/src/main.rs | 144 +- crates/accshift-egui/src/soft.rs | 407 ++++++ .../vendor/egui_software_backend/Cargo.toml | 32 + .../egui_software_backend/LICENSE-APACHE | 176 +++ .../vendor/egui_software_backend/LICENSE-MIT | 19 + .../egui_software_backend/src/color/avx2.rs | 321 +++++ .../egui_software_backend/src/color/mod.rs | 238 ++++ .../egui_software_backend/src/color/neon.rs | 349 +++++ .../egui_software_backend/src/color/sse41.rs | 346 +++++ .../egui_software_backend/src/egui_texture.rs | 119 ++ .../vendor/egui_software_backend/src/hash.rs | 39 + .../vendor/egui_software_backend/src/lib.rs | 1189 +++++++++++++++++ .../egui_software_backend/src/math/i64vec2.rs | 92 ++ .../egui_software_backend/src/math/mod.rs | 3 + .../egui_software_backend/src/math/vec4.rs | 601 +++++++++ .../egui_software_backend/src/raster/bary.rs | 259 ++++ .../egui_software_backend/src/raster/mod.rs | 4 + .../egui_software_backend/src/raster/rect.rs | 132 ++ .../egui_software_backend/src/raster/span.rs | 66 + .../egui_software_backend/src/raster/tri.rs | 142 ++ .../egui_software_backend/src/render.rs | 322 +++++ .../vendor/egui_software_backend/src/stats.rs | 258 ++++ 28 files changed, 5475 insertions(+), 95 deletions(-) create mode 100644 crates/accshift-egui/build.rs create mode 100644 crates/accshift-egui/src/soft.rs create mode 100644 crates/accshift-egui/vendor/egui_software_backend/Cargo.toml create mode 100644 crates/accshift-egui/vendor/egui_software_backend/LICENSE-APACHE create mode 100644 crates/accshift-egui/vendor/egui_software_backend/LICENSE-MIT create mode 100644 crates/accshift-egui/vendor/egui_software_backend/src/color/avx2.rs create mode 100644 crates/accshift-egui/vendor/egui_software_backend/src/color/mod.rs create mode 100644 crates/accshift-egui/vendor/egui_software_backend/src/color/neon.rs create mode 100644 crates/accshift-egui/vendor/egui_software_backend/src/color/sse41.rs create mode 100644 crates/accshift-egui/vendor/egui_software_backend/src/egui_texture.rs create mode 100644 crates/accshift-egui/vendor/egui_software_backend/src/hash.rs create mode 100644 crates/accshift-egui/vendor/egui_software_backend/src/lib.rs create mode 100644 crates/accshift-egui/vendor/egui_software_backend/src/math/i64vec2.rs create mode 100644 crates/accshift-egui/vendor/egui_software_backend/src/math/mod.rs create mode 100644 crates/accshift-egui/vendor/egui_software_backend/src/math/vec4.rs create mode 100644 crates/accshift-egui/vendor/egui_software_backend/src/raster/bary.rs create mode 100644 crates/accshift-egui/vendor/egui_software_backend/src/raster/mod.rs create mode 100644 crates/accshift-egui/vendor/egui_software_backend/src/raster/rect.rs create mode 100644 crates/accshift-egui/vendor/egui_software_backend/src/raster/span.rs create mode 100644 crates/accshift-egui/vendor/egui_software_backend/src/raster/tri.rs create mode 100644 crates/accshift-egui/vendor/egui_software_backend/src/render.rs create mode 100644 crates/accshift-egui/vendor/egui_software_backend/src/stats.rs diff --git a/crates/accshift-egui/Cargo.lock b/crates/accshift-egui/Cargo.lock index 7ac2b81..a3a1a29 100644 --- a/crates/accshift-egui/Cargo.lock +++ b/crates/accshift-egui/Cargo.lock @@ -144,9 +144,12 @@ dependencies = [ "accshift-core", "eframe", "egui", + "egui-winit", "egui_extras", + "egui_software_backend", "raw-window-handle", "resvg", + "softbuffer", "window-vibrancy", "windows-sys 0.61.2", ] @@ -724,6 +727,17 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "constify" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3efe35846d8965e78e0e246153416055964610dc2b67ba0e162624dc44fc719" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -801,6 +815,25 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.22" @@ -938,7 +971,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "libloading 0.7.4", + "libloading", ] [[package]] @@ -1049,6 +1082,7 @@ dependencies = [ "raw-window-handle", "smithay-clipboard", "web-time", + "webbrowser", "winit", ] @@ -1080,6 +1114,17 @@ dependencies = [ "profiling", ] +[[package]] +name = "egui_software_backend" +version = "0.0.3" +dependencies = [ + "ahash", + "constify", + "egui", + "rayon", + "strength_reduce", +] + [[package]] name = "either" version = "1.19.0" @@ -1536,7 +1581,7 @@ dependencies = [ "dispatch2", "glutin_egl_sys", "glutin_wgl_sys", - "libloading 0.8.9", + "libloading", "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-foundation", @@ -2113,16 +2158,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "libloading" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" -dependencies = [ - "cfg-if", - "winapi", -] - [[package]] name = "libloading" version = "0.8.9" @@ -3174,6 +3209,26 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "read-fonts" version = "0.41.0" @@ -3767,6 +3822,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "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", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + [[package]] name = "sqlite-wasm-rs" version = "0.5.5" @@ -3791,6 +3868,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + [[package]] name = "strict-num" version = "0.1.1" @@ -4518,6 +4601,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webbrowser" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +dependencies = [ + "jni", + "log", + "ndk-context", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -4966,7 +5065,7 @@ dependencies = [ "as-raw-xcb-connection", "gethostname", "libc", - "libloading 0.8.9", + "libloading", "once_cell", "rustix 1.1.4", "x11rb-protocol", diff --git a/crates/accshift-egui/Cargo.toml b/crates/accshift-egui/Cargo.toml index 15604a5..a8ffdc5 100644 --- a/crates/accshift-egui/Cargo.toml +++ b/crates/accshift-egui/Cargo.toml @@ -7,7 +7,7 @@ publish = false # Standalone on purpose: the root workspace runs `cargo clippy --workspace` and # `cargo test --workspace` in CI on three OSes, and a spike has no business -# adding eframe, glow and wgpu to that build. The root `Cargo.toml` excludes +# adding eframe, glow, winit and a vendored rasterizer to that build. The root `Cargo.toml` excludes # this folder. [workspace] @@ -16,37 +16,85 @@ publish = false # %TEMP%\accshift-egui-bench\.jsonl. The harness refuses any exe that # lacks the marker string, so a bench run can never pop a window. startup-bench = [] +# Renderer, pick one. `software`: egui meshes rasterized on the CPU and blitted +# with softbuffer (GDI on Windows), no GPU driver loaded. `glow`: eframe on +# OpenGL, the first version of the spike. +software = ["dep:egui-winit", "dep:softbuffer", "dep:egui_software_backend"] +glow = ["dep:eframe"] +default = ["software"] [dependencies] accshift-core = { path = "../accshift-core" } -# Same backend choice as spotifast: glow keeps the binary and the tree small, -# wgpu buys nothing for a 2D grid. -eframe = { version = "0.36", default-features = false, features = [ +# Same backend choice as spotifast for the `glow` path: glow keeps the binary +# and the tree small, wgpu buys nothing for a 2D grid. +eframe = { version = "0.36", optional = true, default-features = false, features = [ "accesskit", "glow", "default_fonts", ] } -egui = "0.36" +# The software path: the window and input layer eframe uses (egui-winit +# re-exports winit and accesskit_winit), a CPU rasterizer, a GDI blit. +egui-winit = { version = "0.36", optional = true, default-features = false, features = [ + "accesskit", + "clipboard", + "links", +] } +softbuffer = { version = "0.4", optional = true, default-features = false } +egui_software_backend = { path = "vendor/egui_software_backend", optional = true, features = [ + "rayon", +] } +# No bundled fonts on Windows: the app draws with Segoe UI read from the +# system, and the 1.2 MB of egui's defaults cost startup time (a bigger exe +# reaches `main` later, see build.rs). Other OSes keep them as the only fonts. +egui = { version = "0.36", default-features = false } egui_extras = { version = "0.36", default-features = false, features = ["svg"] } # The same resvg egui_extras rasterizes with, for its options type. resvg = { version = "0.45", default-features = false } raw-window-handle = "0.6" +[target.'cfg(not(windows))'.dependencies] +egui = { version = "0.36", features = ["default_fonts"] } + [target.'cfg(windows)'.dependencies] # The OS acrylic behind a transparent window: what Glass Dark uses in Tauri. window-vibrancy = "0.8" # DWM cloak for the bench build: the window exists and renders, but is never -# composited on screen. -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Dwm"] } +# composited on screen. UI Automation: warmed up off the UI thread at start. +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Graphics_Dwm", + "Win32_UI_Accessibility", +] } -# Mirrors the root workspace profile so binary sizes compare. +# The root workspace profile, except the size: a smaller exe reaches `main` +# sooner (build.rs), so everything is built for size but the per-frame hot +# path below. [profile.release] panic = "abort" codegen-units = 1 lto = "fat" -opt-level = 2 +opt-level = "s" strip = true +[profile.release.package.egui_software_backend] +opt-level = 3 + +[profile.release.package.epaint] +opt-level = 3 + +# Avatar SVG raster and blur, off the UI thread but on the way to `ready`. +[profile.release.package.accshift-egui] +opt-level = 3 + +[profile.release.package.resvg] +opt-level = 3 + +[profile.release.package.tiny-skia] +opt-level = 3 + +[profile.release.package.tiny-skia-path] +opt-level = 3 + # The `windows` bindings crate alone needs about 2 GB of RAM at opt-level 2 # with one codegen unit. Fat LTO optimizes its bitcode again at the final link, # so building it unoptimized changes the compile peak, not the binary. diff --git a/crates/accshift-egui/README.md b/crates/accshift-egui/README.md index a419f71..0d38e03 100644 --- a/crates/accshift-egui/README.md +++ b/crates/accshift-egui/README.md @@ -1,6 +1,6 @@ # accshift-egui (spike) -The accshift main screen redrawn in [egui](https://github.com/emilk/egui) 0.36 (eframe, glow backend), to weigh a move away from Tauri and WebView2 with numbers and real renders instead of guesses. It is not the app: there are no settings, no personas page, no real switch, and no CI coverage. The root workspace excludes this crate on purpose. +The accshift main screen redrawn in [egui](https://github.com/emilk/egui) 0.36, to weigh a move away from Tauri and WebView2 with numbers and real renders instead of guesses. It is not the app: there are no settings, no personas page, no real switch, and no CI coverage. The root workspace excludes this crate on purpose. ## What it reproduces @@ -10,37 +10,51 @@ The accshift main screen redrawn in [egui](https://github.com/emilk/egui) 0.36 ( - Glass Dark: OS acrylic behind a transparent window, the same tint, fills and text colours as `builtin/glass-dark.json`, and Segoe UI (the webview's fallback, since Inter is not installed). - `accshift-core` called directly, without IPC: a background thread reads the real Steam login list (read-only) and times it. +## Two renderers + +- `software` (default): winit through `egui-winit`, egui's meshes rasterized on the CPU by a vendored copy of [egui_software_backend](https://github.com/DGriffin91/egui_software_backend) 0.0.3 (MIT or Apache-2.0, ported to egui 0.36 in `vendor/`), and blitted with softbuffer, a GDI blit on Windows. No GPU driver is loaded. The runner is `src/soft.rs`. +- `glow`: eframe on OpenGL, the first version of the spike. Build it with `--no-default-features --features glow`. + +What the software path does to stay cheap: + +- Only the 64 px tiles that changed are rasterized, copied and presented (`present_with_damage`); a full copy happens on the first frame, a resize or an OS repaint. +- Frames are paced to the monitor's refresh rate instead of vsync, and rayon spreads the raster over the tiles. +- Startup: no bundled fonts on Windows (Segoe UI comes from the system), the exe is built for size except the per-frame hot path, the UI Automation, shell and COM DLLs are delay-loaded (`build.rs`), UI Automation warms up on another thread, and window drag and drop stays off so winit skips `OleInitialize`. + ## Run it ```powershell -cargo run --release # normal window +cargo run --release # normal window, software renderer cargo run --release -- --capture # scripted shots, hidden window cargo build --release --features startup-bench +cargo build --release --no-default-features --features glow ``` -- `--capture ` runs a fixed script (hover, arm, search, switch, tabs, folder, list) and writes one PAM file per shot, read back from the GL framebuffer of the real renderer at 860x440 points and 1.5 pixels per point, over the recorder's stand-in desktop (`src/backdrop.rs`). `ffmpeg -i shot.pam shot.png` converts them. -- `startup-bench` never shows the window: it is created inactive, outside the taskbar, at -32000,-32000, and DWM-cloaked before eframe makes it visible. Boot marks go to `%TEMP%\accshift-egui-bench\.jsonl` (`main`, `app_created`, `ui_done`, `first_frame`, `ready` once every avatar is on screen, `core_accounts`). The binary carries the string `accshift-egui-startup-bench-build`, and a harness refuses to launch any build without it. +- `--capture ` runs a fixed script (hover, arm, search, switch, tabs, folder, list) and writes one PAM file per shot, read back from the frame the renderer presented at 860x440 points and 1.5 pixels per point, over the recorder's stand-in desktop (`src/backdrop.rs`). `ACCSHIFT_EGUI_CAPTURE_SCALE` changes the scale. `ffmpeg -i shot.pam shot.png` converts them. +- `startup-bench` never shows the window: it is created inactive, outside the taskbar, at -32000,-32000, and DWM-cloaked before the runner makes it visible. Boot marks go to `%TEMP%\accshift-egui-bench\.jsonl` (`main`, `window`, `app_created`, `ui_done`, `first_frame`, `ready` once every avatar is on screen, `core_accounts`, and one `frame` line per paint with its timings in the software build). The binary carries the string `accshift-egui-startup-bench-build`, and a harness refuses to launch any build without it. - `--capture` uses the same hidden-window recipe in any build. -- Two environment knobs exist only for the boot breakdown: `ACCSHIFT_EGUI_NO_VSYNC` and `ACCSHIFT_EGUI_OPAQUE` (no transparency, no acrylic). +- `ACCSHIFT_EGUI_OPAQUE` (no transparency, no acrylic) and, for glow, `ACCSHIFT_EGUI_NO_VSYNC` exist only for the boot breakdown. -## Results (2026-10-10, Windows 11, NVIDIA desktop GPU) +## Results (Windows 11, NVIDIA desktop GPU) -Interleaved A/B against the Tauri `startup-bench` build of the same commit, 15 launches each, first launch dropped, fresh copies of both binaries. Tauri's boot is its `Boot completed` log line, written just before it shows the window; egui's is its first frame, already presented. +Interleaved A/B against the Tauri `startup-bench` build of the same commit, 15 launches each, first launch dropped, fresh copies of both binaries. Times run from process creation. Tauri's boot is its `Boot completed` log line, written just before it shows the window; egui's is its first frame, already presented. The glow column comes from the same harness one day earlier. -| | Tauri 2 + WebView2 | egui 0.36 + glow | -| ----------------------------- | --------------------------------------------------- | ---------------- | -| Boot, median (quiet machine) | 285 ms | 324 ms | -| Boot, median (loaded machine) | 435 ms | 468 ms | -| Working set, 3 s after boot | 502 MB | 103 MB | -| Private memory | 434 MB | 141 MB | -| Processes | 7 | 1 | -| Shipped files | exe 238 KB + DLL 11.9 MB, plus the WebView2 runtime | one 6.5 MB exe | +| | Tauri 2 + WebView2 | egui, software | egui, glow | +| --------------------------- | --------------------------------------------------- | -------------- | -------------- | +| Boot, median | 308 ms | 57 ms (min 46) | 324 ms | +| Every avatar on screen | | 66 ms | | +| Working set, 3 s after boot | 498 MB | 33 MB | 103 MB | +| Private memory | 431 MB | 21 MB | 141 MB | +| Processes | 7 | 1 | 1 | +| Shipped files | exe 238 KB + DLL 11.9 MB, plus the WebView2 runtime | one 4.4 MB exe | one 6.5 MB exe | -- Memory and process count drop by a factor of 4 to 7. Boot does not improve: egui spends about 180 ms creating the window and the WGL context and about 115 ms on its first paint and present, against 3 ms for the UI pass itself. Turning vsync off or making the window opaque without acrylic changes nothing measurable, so the cost sits in the OpenGL driver. -- The renders match the Tauri frames of `demo-switch` closely: metrics, colours, rings, the blurred armed avatar and the hint all line up. Text is the visible difference, because egui has no variable-font weights (the 500 weight of card names is drawn with the semibold face) and no subpixel positioning like Chromium. -- The spike covers one screen in about 2,200 lines of Rust. The Svelte front-end it would replace is about 15,600 lines of Svelte and 23,300 of TypeScript (settings, personas, dialogs, bulk edit, theming, i18n, the theme editor). +- The software build boots about 5 times faster than Tauri, with 15 times less working set and 20 times less private memory. About 31 ms of its boot is spent before `main`, mostly the loader mapping the exe and its DLLs; a bare Rust exe needs 12 ms there. From `main`: window at 12 ms, UI pass done at 15, first frame presented at 21, every avatar at 36. +- Glow does not boot faster than Tauri: about 180 ms go to the window and the WGL context and about 115 ms to its first present, against 3 ms for the UI pass. Vsync and transparency change nothing measurable there, so the cost sits in the OpenGL driver. +- Software frame cost during the capture script, measured on every paint: at 1290x660 pixels, median 0.40 ms, p95 1.6 ms, 8 % of one core; at 2580x1320, median 0.53 ms, p95 5.2 ms, 18 % of one core. The worst frame (73 ms at the bigger size) is the one where every avatar texture arrives. +- The software captures match the glow ones within rounding (PSNR about 51 dB), and every shot is byte-identical between a full redraw and the damaged-tiles path. Both match the Tauri frames of `demo-switch` closely: metrics, colours, rings, the blurred armed avatar and the hint line up. Text is the visible difference, because egui has no variable-font weights (the 500 weight of card names is drawn with the semibold face) and no subpixel positioning like Chromium. +- The spike covers one screen in about 2,700 lines of Rust plus the vendored rasterizer. The Svelte front-end it would replace is about 15,600 lines of Svelte and 23,300 of TypeScript (settings, personas, dialogs, bulk edit, theming, i18n, the theme editor). -Not measured: a wgpu backend (DX12 might create its device faster than the NVIDIA OpenGL driver, but a transparent DX12 window needs DirectComposition), other GPUs, real input in a visible window, accessibility with a screen reader. +Not verified: the software path in a visible window (acrylic through the GDI blit, drag, maximize, live resize), other GPUs and Windows versions, Linux and macOS, accessibility with a screen reader, a wgpu backend. ## Layout @@ -54,3 +68,6 @@ Not measured: a wgpu backend (DX12 might create its device faster than the NVIDI | `src/capture.rs` | The scripted capture run | | `src/bench.rs` | Boot marks | | `src/backdrop.rs` | The recorder's stand-in desktop, for captures only | +| `src/soft.rs` | The software runner: winit loop, damaged-tiles present, pacing | +| `vendor/` | egui_software_backend 0.0.3, ported to egui 0.36 | +| `build.rs` | Delay-loaded DLLs on Windows | diff --git a/crates/accshift-egui/build.rs b/crates/accshift-egui/build.rs new file mode 100644 index 0000000..b0a2b75 --- /dev/null +++ b/crates/accshift-egui/build.rs @@ -0,0 +1,23 @@ +// Windows startup: the loader maps every statically imported DLL before +// `main`. A bare Rust exe reaches `main` in about 13 ms here; this one took 31. +// The DLLs below are only needed for accessibility clients (UI Automation), +// links and COM, never for the first frame, so they load on first call. +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + let windows = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows"); + let msvc = std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc"); + if !(windows && msvc) { + return; + } + for dll in [ + "uiautomationcore.dll", + "shell32.dll", + "shlwapi.dll", + "ole32.dll", + "oleaut32.dll", + "combase.dll", + ] { + println!("cargo:rustc-link-arg-bins=/DELAYLOAD:{dll}"); + } + println!("cargo:rustc-link-arg-bins=delayimp.lib"); +} diff --git a/crates/accshift-egui/src/app.rs b/crates/accshift-egui/src/app.rs index 37b9bce..d33a7c2 100644 --- a/crates/accshift-egui/src/app.rs +++ b/crates/accshift-egui/src/app.rs @@ -1024,16 +1024,18 @@ fn install_fonts(ctx: &egui::Context) { let mut fonts = FontDefinitions::default(); let dir = std::env::var("WINDIR").unwrap_or_else(|_| r"C:\Windows".into()); let read = |name: &str| std::fs::read(format!(r"{dir}\Fonts\{name}")).ok(); - let base: Vec = fonts.families[&FontFamily::Proportional].clone(); if let Some(bytes) = read("segoeui.ttf") { fonts .font_data .insert("segoe".into(), Arc::new(FontData::from_owned(bytes))); - fonts - .families - .get_mut(&FontFamily::Proportional) - .expect("proportional family") - .insert(0, "segoe".into()); + // On Windows egui ships no fonts of its own (see Cargo.toml): Segoe + // UI is then the whole proportional family and the monospace one. + for family in [FontFamily::Proportional, FontFamily::Monospace] { + let list = fonts.families.entry(family.clone()).or_default(); + if family == FontFamily::Proportional || list.is_empty() { + list.insert(0, "segoe".into()); + } + } } let mut semibold_family = Vec::new(); if let Some(bytes) = read("seguisb.ttf") { @@ -1043,7 +1045,7 @@ fn install_fonts(ctx: &egui::Context) { ); semibold_family.push("segoe-semibold".to_string()); } - semibold_family.extend(base); + semibold_family.extend(fonts.families[&FontFamily::Proportional].iter().cloned()); fonts.families.insert(semibold(), semibold_family); ctx.set_fonts(fonts); } diff --git a/crates/accshift-egui/src/lib.rs b/crates/accshift-egui/src/lib.rs index 5b7b237..b2e5d30 100644 --- a/crates/accshift-egui/src/lib.rs +++ b/crates/accshift-egui/src/lib.rs @@ -7,30 +7,47 @@ pub mod bench; pub mod capture; pub mod data; pub mod icons; +#[cfg(feature = "software")] +pub mod soft; pub mod theme; pub use app::{AccshiftApp, Options}; -/// The eframe wrapper: transparent clear so the OS acrylic shows through the -/// 55 % window fill, exactly like the Tauri window. +#[cfg(all(feature = "software", feature = "glow"))] +compile_error!("pick one renderer: `software` (default) or `glow` with --no-default-features"); + +/// What both runners drive: the app plus the optional capture script. The +/// window is cleared to transparent so the OS acrylic shows through the 55 % +/// window fill, exactly like the Tauri window. pub struct Shell { pub app: AccshiftApp, pub capture: Option, } -impl eframe::App for Shell { - fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { +impl Shell { + pub fn input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { if let Some(capture) = &mut self.capture { capture.feed(ctx, raw_input, self.app.avatars_ready()); } } - fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + pub fn frame(&mut self, ui: &mut egui::Ui) { if let Some(capture) = &mut self.capture { capture.collect(ui.ctx()); } self.app.ui(ui); } +} + +#[cfg(feature = "glow")] +impl eframe::App for Shell { + fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { + self.input_hook(ctx, raw_input); + } + + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + self.frame(ui); + } fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] { [0.0, 0.0, 0.0, 0.0] diff --git a/crates/accshift-egui/src/main.rs b/crates/accshift-egui/src/main.rs index a7a7cf0..4a9a7d1 100644 --- a/crates/accshift-egui/src/main.rs +++ b/crates/accshift-egui/src/main.rs @@ -1,8 +1,9 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use accshift_egui::{bench, capture::Capture, AccshiftApp, Options, Shell}; +use raw_window_handle::HasWindowHandle; -fn main() -> eframe::Result { +fn main() { let bench_build = cfg!(feature = "startup-bench"); // `--capture `: scripted offscreen shots, see src/capture.rs. let capture_dir = { @@ -22,8 +23,13 @@ fn main() -> eframe::Result { bench::mark_with( "main", &format!( - "\"build\":\"{}\"", - if bench_build { bench::MARKER } else { "dev" } + "\"build\":\"{}\",\"renderer\":\"{}\"", + if bench_build { bench::MARKER } else { "dev" }, + if cfg!(feature = "glow") { + "glow" + } else { + "software" + }, ), ); if hidden { @@ -34,6 +40,25 @@ fn main() -> eframe::Result { }); } + // AccessKit initializes UI Automation on the UI thread before the window + // may show (about 4 ms here). Doing it first on another thread overlaps + // that with the event loop and window creation. + #[cfg(windows)] + std::thread::spawn(|| { + use windows_sys::Win32::UI::Accessibility::{ + AutomationIdentifierType_Property, ControlType_Property_GUID, UiaLookupId, + }; + // SAFETY: plain lookup of a built-in property id, the same call + // accesskit_windows makes to force UIA initialization. + unsafe { + UiaLookupId( + AutomationIdentifierType_Property, + &ControlType_Property_GUID, + ) + }; + bench::mark("uia_warm"); + }); + // The core is called directly, no IPC: read the real Steam login list // (read-only) off the UI thread, to prove the link and time it. std::thread::spawn(|| { @@ -51,62 +76,97 @@ fn main() -> eframe::Result { .with_min_inner_size([520.0, 320.0]) .with_decorations(false) .with_transparent(!opaque) + // No file drops in this app, and winit's drop target costs an + // `OleInitialize` on the UI thread before the window exists. + .with_drag_and_drop(false) .with_resizable(true); if hidden { // Never activated, never in the taskbar, off every monitor, and - // cloaked below: eframe shows the window after its first frame + // cloaked below: both runners show the window after its first frame // whatever the builder says, so this is what keeps it unseen. viewport = viewport .with_active(false) .with_taskbar(false) .with_position([-32000.0, -32000.0]); } - let options = eframe::NativeOptions { + + // Shared by both runners once the window exists. + let build = move |ctx: &egui::Context, window: &dyn HasWindowHandle, native_ppp: f32| { + #[cfg(windows)] + window_effects(window, hidden, !opaque); + #[cfg(not(windows))] + let _ = window; + bench::mark("app_created"); + let capture = capture_dir.map(|dir| { + std::fs::create_dir_all(&dir).ok(); + // The Tauri recorder's scale: 860x440 points at 1.5. Another + // scale prices the raster on a bigger window. + let scale = std::env::var("ACCSHIFT_EGUI_CAPTURE_SCALE") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(1.5); + ctx.set_zoom_factor(scale / native_ppp); + ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(860.0, 440.0))); + Capture::new(dir) + }); + let options = Options { + desktop_backdrop: capture.is_some(), + }; + Shell { + app: AccshiftApp::new(ctx, options), + capture, + } + }; + + #[cfg(feature = "software")] + let result = accshift_egui::soft::run( viewport, - persist_window: false, - // Experiment knob for the boot breakdown: does the first swap wait - // on the compositor? - glow_options: eframe::egui_glow::GlowConfiguration { - vsync: std::env::var_os("ACCSHIFT_EGUI_NO_VSYNC").is_none(), + Box::new( + move |ctx: &egui::Context, window: &egui_winit::winit::window::Window| { + build(ctx, window, window.scale_factor() as f32) + }, + ), + ) + .map_err(|err| err.to_string()); + + #[cfg(feature = "glow")] + let result = { + let options = eframe::NativeOptions { + viewport, + persist_window: false, + // Experiment knob for the boot breakdown: does the first swap + // wait on the compositor? + glow_options: eframe::egui_glow::GlowConfiguration { + vsync: std::env::var_os("ACCSHIFT_EGUI_NO_VSYNC").is_none(), + ..Default::default() + }, ..Default::default() - }, - ..Default::default() - }; - eframe::run_native( - "accshift-egui", - options, - Box::new(move |cc| { - #[cfg(windows)] - window_effects(cc, hidden, !opaque); - bench::mark("app_created"); - let capture = capture_dir.map(|dir| { - std::fs::create_dir_all(&dir).ok(); - // The Tauri recorder's scale: 860x440 points at 1.5. + }; + eframe::run_native( + "accshift-egui", + options, + Box::new(move |cc| { let native = cc.egui_ctx.native_pixels_per_point().unwrap_or(1.0); - cc.egui_ctx.set_zoom_factor(1.5 / native); - cc.egui_ctx - .send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(860.0, 440.0))); - Capture::new(dir) - }); - let options = Options { - desktop_backdrop: capture.is_some(), - }; - Ok(Box::new(Shell { - app: AccshiftApp::new(&cc.egui_ctx, options), - capture, - })) - }), - ) + Ok(Box::new(build(&cc.egui_ctx, cc, native))) + }), + ) + .map_err(|err| err.to_string()) + }; + + if let Err(err) = result { + eprintln!("{err}"); + std::process::exit(1); + } } #[cfg(windows)] -fn window_effects(cc: &eframe::CreationContext<'_>, cloak: bool, acrylic: bool) { - use raw_window_handle::{HasWindowHandle, RawWindowHandle}; +fn window_effects(window: &dyn HasWindowHandle, cloak: bool, acrylic: bool) { + use raw_window_handle::RawWindowHandle; if cloak { - if let Ok(handle) = cc.window_handle() { + if let Ok(handle) = window.window_handle() { if let RawWindowHandle::Win32(h) = handle.as_raw() { let on: i32 = 1; - // SAFETY: valid HWND owned by eframe, 4-byte BOOL attribute. + // SAFETY: valid HWND owned by the runner, 4-byte BOOL attribute. unsafe { windows_sys::Win32::Graphics::Dwm::DwmSetWindowAttribute( h.hwnd.get() as _, @@ -121,6 +181,6 @@ fn window_effects(cc: &eframe::CreationContext<'_>, cloak: bool, acrylic: bool) // Glass Dark's material in Tauri: acrylic with a heavy black tint // (`ACRYLIC_TINTS` in src/lib/theme/backdrop.ts). if acrylic { - let _ = window_vibrancy::apply_acrylic(cc, Some((0, 0, 0, 150))); + let _ = window_vibrancy::apply_acrylic(window, Some((0, 0, 0, 150))); } } diff --git a/crates/accshift-egui/src/soft.rs b/crates/accshift-egui/src/soft.rs new file mode 100644 index 0000000..a47639f --- /dev/null +++ b/crates/accshift-egui/src/soft.rs @@ -0,0 +1,407 @@ +//! The software runner: what eframe does for this spike, minus OpenGL. +//! +//! Window and input go through egui-winit exactly as in eframe. egui's meshes +//! are rasterized on the CPU by the vendored `egui_software_backend`, and the +//! frame is blitted with softbuffer (a 32-bit DIB and `BitBlt` on Windows). +//! No GPU driver is loaded: with glow, the NVIDIA OpenGL driver took about +//! 300 ms before the first frame and most of the 100 MB working set. + +use std::num::NonZeroU32; +use std::rc::Rc; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use egui::{ViewportEvent, ViewportId, ViewportInfo}; +use egui_software_backend::{BufferMutRef, ColorFieldOrder, EguiSoftwareRender}; +use egui_winit::accesskit_winit; +use egui_winit::winit; +use winit::application::ApplicationHandler; +use winit::event::WindowEvent; +use winit::event_loop::{ + ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy, OwnedDisplayHandle, +}; +use winit::window::{Window, WindowId}; + +use crate::{bench, Shell}; + +pub enum UserEvent { + /// From egui's repaint callback, possibly on another thread. + Repaint { + when: Instant, + pass: u64, + }, + AccessKit(accesskit_winit::Event), +} + +impl From for UserEvent { + fn from(event: accesskit_winit::Event) -> Self { + Self::AccessKit(event) + } +} + +/// Builds the app once the window exists, before the first frame. +pub type Init = Box Shell>; + +pub fn run( + viewport: egui::ViewportBuilder, + init: Init, +) -> Result<(), winit::error::EventLoopError> { + let event_loop = EventLoop::::with_user_event().build()?; + bench::mark("event_loop"); + let mut runner = Runner { + ctx: egui::Context::default(), + proxy: event_loop.create_proxy(), + viewport, + init: Some(init), + live: None, + next_repaint: None, + last_paint: None, + frame_interval: Duration::from_secs_f64(1.0 / 60.0), + }; + event_loop.run_app(&mut runner) +} + +struct Live { + window: Rc, + surface: softbuffer::Surface>, + state: egui_winit::State, + info: ViewportInfo, + shell: Shell, + render: EguiSoftwareRender, + /// Texture changes not rasterized yet (a minimized window has no pixels). + textures: egui::TexturesDelta, + actions: Vec, + full_present: bool, + shown: bool, +} + +struct Runner { + ctx: egui::Context, + proxy: EventLoopProxy, + viewport: egui::ViewportBuilder, + init: Option, + live: Option, + next_repaint: Option, + /// Frame pacing in place of vsync: a blit returns at once, so an egui + /// animation would otherwise repaint as fast as the CPU allows. + last_paint: Option, + frame_interval: Duration, +} + +impl Runner { + fn repaint_at(&mut self, when: Instant) { + let when = match self.last_paint { + Some(last) => when.max(last + self.frame_interval), + None => when, + }; + self.next_repaint = Some(self.next_repaint.map_or(when, |t| t.min(when))); + } + + fn paint(&mut self, event_loop: &ActiveEventLoop) { + let ctx = self.ctx.clone(); + let Some(live) = &mut self.live else { return }; + + egui_winit::update_viewport_info(&mut live.info, &ctx, &live.window, false); + let mut raw = live.state.take_egui_input(&live.window); + raw.viewports.insert(ViewportId::ROOT, live.info.clone()); + live.info.events.clear(); + live.shell.input_hook(&ctx, &mut raw); + + let started = Instant::now(); + self.last_paint = Some(started); + let shell = &mut live.shell; + let mut out = ctx.run_ui(raw, |ui| shell.frame(ui)); + let ui_done = Instant::now(); + live.state.handle_platform_output_with_event_loop( + &live.window, + event_loop, + out.platform_output, + ); + live.textures.append(out.textures_delta); + let commands = out + .viewport_output + .remove(&ViewportId::ROOT) + .map(|v| v.commands) + .unwrap_or_default(); + let prims = ctx.tessellate(out.shapes, out.pixels_per_point); + let tessellated = Instant::now(); + let rasterized; + let mut damaged_px = 0usize; + + let size = live.window.inner_size(); + let mut screenshot = None; + if let (Some(w), Some(h)) = (NonZeroU32::new(size.width), NonZeroU32::new(size.height)) { + live.surface.resize(w, h).expect("softbuffer resize"); + let mut buffer = live.surface.buffer_mut().expect("softbuffer buffer"); + // A buffer that still holds the last frame only needs the tiles + // that changed; a new one (first frame, resize) or a repaint the + // OS asked for gets everything. + let full = std::mem::take(&mut live.full_present) || buffer.age() == 0; + // SAFETY: [u8; 4] has the size of u32 and a weaker alignment. + let pixels = unsafe { + std::slice::from_raw_parts_mut(buffer.as_mut_ptr().cast::<[u8; 4]>(), buffer.len()) + }; + let mut target = BufferMutRef::new(pixels, w.get() as usize, h.get() as usize); + let textures = std::mem::take(&mut live.textures); + live.render.render_to_canvas( + w.get() as usize, + h.get() as usize, + &prims, + &textures, + out.pixels_per_point, + ); + // Premultiplied BGRA over transparent, and GDI keeps the alpha + // byte, so the acrylic shows through where egui paints nothing. + let damage: Vec = live + .render + .copy_canvas_to_buffer(&mut target, full) + .into_iter() + .filter_map(|[x, y, width, height]| { + Some(softbuffer::Rect { + x: x as u32, + y: y as u32, + width: NonZeroU32::new(width as u32)?, + height: NonZeroU32::new(height as u32)?, + }) + }) + .collect(); + rasterized = Instant::now(); + + egui_winit::process_viewport_commands( + &ctx, + &mut live.info, + commands, + &live.window, + &mut live.actions, + ); + if live + .actions + .iter() + .any(|a| matches!(a, egui_winit::ActionRequested::Screenshot(_))) + { + screenshot = Some(read_pixels(pixels, [w.get() as usize, h.get() as usize])); + } + damaged_px = damage + .iter() + .map(|r| r.width.get() as usize * r.height.get() as usize) + .sum(); + buffer + .present_with_damage(&damage) + .expect("softbuffer present"); + } else { + rasterized = Instant::now(); + egui_winit::process_viewport_commands( + &ctx, + &mut live.info, + commands, + &live.window, + &mut live.actions, + ); + } + + if bench::enabled() { + let ms = |a: Instant, b: Instant| (b - a).as_secs_f64() * 1000.0; + let done = Instant::now(); + bench::mark_with( + "frame", + &format!( + "\"px\":[{},{}],\"damaged\":{},\"ui\":{:.3},\"tessellate\":{:.3},\"raster\":{:.3},\"present\":{:.3}", + size.width, + size.height, + damaged_px, + ms(started, ui_done), + ms(ui_done, tessellated), + ms(tessellated, rasterized), + ms(rasterized, done), + ), + ); + } + + for action in live.actions.drain(..) { + let event = match action { + egui_winit::ActionRequested::Screenshot(user_data) => { + let Some(image) = screenshot.clone() else { + continue; + }; + egui::Event::Screenshot { + viewport_id: ViewportId::ROOT, + user_data, + image, + } + } + egui_winit::ActionRequested::Cut => egui::Event::Cut, + egui_winit::ActionRequested::Copy => egui::Event::Copy, + egui_winit::ActionRequested::Paste => match live.state.clipboard_text() { + Some(text) => egui::Event::Paste(text), + None => continue, + }, + }; + live.state.egui_input_mut().events.push(event); + ctx.request_repaint(); + } + + if !live.shown { + // Like eframe: created hidden, shown once the first frame is in. + live.shown = true; + live.window.set_visible(true); + } + if live + .info + .events + .iter() + .any(|e| matches!(e, ViewportEvent::Close)) + { + event_loop.exit(); + } + } +} + +/// The frame as egui's glow painter returns it: premultiplied RGBA. +fn read_pixels(bgra: &[[u8; 4]], size: [usize; 2]) -> Arc { + let pixels = bgra + .iter() + .map(|&[b, g, r, a]| egui::Color32::from_rgba_premultiplied(r, g, b, a)) + .collect(); + Arc::new(egui::ColorImage::new(size, pixels)) +} + +impl ApplicationHandler for Runner { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + let Some(init) = self.init.take() else { return }; + + let builder = self.viewport.clone().with_visible(false); + let window = match egui_winit::create_window(&self.ctx, event_loop, &builder) { + Ok(window) => Rc::new(window), + Err(err) => { + eprintln!("window creation failed: {err}"); + event_loop.exit(); + return; + } + }; + bench::mark("window"); + let context = softbuffer::Context::new(event_loop.owned_display_handle()) + .expect("softbuffer context"); + let surface = + softbuffer::Surface::new(&context, window.clone()).expect("softbuffer surface"); + + let mut state = egui_winit::State::new( + self.ctx.clone(), + ViewportId::ROOT, + event_loop, + Some(window.scale_factor() as f32), + window.theme(), + None, + ); + bench::mark("surface_input"); + // AccessKit wants its adapter before the window is first shown. + state.init_accesskit(event_loop, &window, self.proxy.clone()); + bench::mark("accesskit"); + + let proxy = Mutex::new(self.proxy.clone()); + self.ctx.set_request_repaint_callback(move |info| { + if let Ok(proxy) = proxy.lock() { + let _ = proxy.send_event(UserEvent::Repaint { + when: Instant::now() + info.delay, + pass: info.current_cumulative_pass_nr, + }); + } + }); + + // The monitor's refresh rate; the primary one while the window sits + // off screen. + if let Some(mhz) = window + .current_monitor() + .or_else(|| event_loop.primary_monitor()) + .and_then(|monitor| monitor.refresh_rate_millihertz()) + .filter(|&mhz| mhz > 0) + { + self.frame_interval = Duration::from_secs_f64(1000.0 / f64::from(mhz)); + } + + let shell = init(&self.ctx, &window); + let mut info = ViewportInfo::default(); + egui_winit::update_viewport_info(&mut info, &self.ctx, &window, true); + self.live = Some(Live { + window, + surface, + state, + info, + shell, + render: EguiSoftwareRender::new(ColorFieldOrder::Bgra), + textures: Default::default(), + actions: Vec::new(), + full_present: true, + shown: false, + }); + self.paint(event_loop); + } + + fn user_event(&mut self, _event_loop: &ActiveEventLoop, event: UserEvent) { + match event { + UserEvent::Repaint { when, pass } => { + // eframe's guard against requests from a pass already painted. + let current = self.ctx.cumulative_pass_nr_for(ViewportId::ROOT); + if current == pass || current == pass + 1 { + self.repaint_at(when); + } + } + UserEvent::AccessKit(event) => { + let Some(live) = &mut self.live else { return }; + match event.window_event { + accesskit_winit::WindowEvent::InitialTreeRequested => { + self.ctx.enable_accesskit(); + self.repaint_at(Instant::now()); + } + accesskit_winit::WindowEvent::ActionRequested(request) => { + live.state.on_accesskit_action_request(request); + self.repaint_at(Instant::now()); + } + accesskit_winit::WindowEvent::AccessibilityDeactivated => { + self.ctx.disable_accesskit(); + } + } + } + } + } + + fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { + match event { + WindowEvent::CloseRequested => { + event_loop.exit(); + return; + } + WindowEvent::RedrawRequested => { + // Only the OS asks for this (the runner paints directly), and + // it wants the whole client area back. + if let Some(live) = &mut self.live { + live.full_present = true; + } + self.next_repaint = None; + self.paint(event_loop); + return; + } + _ => {} + } + let Some(live) = &mut self.live else { return }; + let response = live.state.on_window_event(&live.window, &event); + if response.repaint { + self.repaint_at(Instant::now()); + } + } + + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + match self.next_repaint { + Some(when) if when <= Instant::now() => { + self.next_repaint = None; + self.paint(event_loop); + // The frame may have asked for another one right away. + match self.next_repaint { + Some(when) => event_loop.set_control_flow(ControlFlow::WaitUntil(when)), + None => event_loop.set_control_flow(ControlFlow::Wait), + } + } + Some(when) => event_loop.set_control_flow(ControlFlow::WaitUntil(when)), + None => event_loop.set_control_flow(ControlFlow::Wait), + } + } +} diff --git a/crates/accshift-egui/vendor/egui_software_backend/Cargo.toml b/crates/accshift-egui/vendor/egui_software_backend/Cargo.toml new file mode 100644 index 0000000..4615ddf --- /dev/null +++ b/crates/accshift-egui/vendor/egui_software_backend/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "egui_software_backend" +description = "Vendored rasterizer of egui_software_backend 0.0.3, ported to egui 0.36" +version = "0.0.3" +edition = "2024" +license = "MIT OR Apache-2.0" +publish = false + +# Copied from https://github.com/DGriffin91/egui_software_backend at 0.0.3 +# (crates.io), which targets egui 0.34. Only the rasterizer is kept: the winit +# runner and the test renderer are dropped, the spike has its own runner. +# egui 0.36 no longer re-exports ahash: it is a direct dependency, as on +# upstream main. + +[features] +default = ["std"] +std = ["ahash/std"] +raster_stats = ["std"] +# Rasterizes primitives and composites tile rows on the rayon pool. +rayon = ["std", "dep:rayon"] + +[dependencies] +ahash = { version = "0.8", default-features = false, features = ["no-rng"] } +constify = "0.0.1" +egui = { version = "0.36", default-features = false } +rayon = { version = "1.11", optional = true } +strength_reduce = "0.2.4" + +[lints.rust.unexpected_cfgs] +level = "warn" +# Upstream features whose dependencies are not vendored. +check-cfg = ['cfg(feature, values("log"))'] diff --git a/crates/accshift-egui/vendor/egui_software_backend/LICENSE-APACHE b/crates/accshift-egui/vendor/egui_software_backend/LICENSE-APACHE new file mode 100644 index 0000000..d9a10c0 --- /dev/null +++ b/crates/accshift-egui/vendor/egui_software_backend/LICENSE-APACHE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/crates/accshift-egui/vendor/egui_software_backend/LICENSE-MIT b/crates/accshift-egui/vendor/egui_software_backend/LICENSE-MIT new file mode 100644 index 0000000..9cf1062 --- /dev/null +++ b/crates/accshift-egui/vendor/egui_software_backend/LICENSE-MIT @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/accshift-egui/vendor/egui_software_backend/src/color/avx2.rs b/crates/accshift-egui/vendor/egui_software_backend/src/color/avx2.rs new file mode 100644 index 0000000..49262ef --- /dev/null +++ b/crates/accshift-egui/vendor/egui_software_backend/src/color/avx2.rs @@ -0,0 +1,321 @@ +#![allow(unsafe_code)] + +use core::ptr::write_unaligned; +use core::{arch::x86_64::*, ptr::read_unaligned}; + +use crate::color::sse41::Sse41Impl; +use crate::color::{GenericImpl, SelectedImpl}; + +type U8x4x4 = __m128i; +type U16x4x2 = __m128i; +type U16x4x4 = __m256i; + +#[derive(Clone, Copy)] +pub(crate) struct Avx2Impl { + sse41: Sse41Impl, +} + +impl Avx2Impl { + /// `std::arch::is_x86_feature_detected!("avx2")` MUST be true + /// `std::arch::is_x86_feature_detected!("sse4.1")` MUST be true + pub(crate) const unsafe fn new() -> Self { + Self { + sse41: unsafe { Sse41Impl::new() }, + } + } +} + +impl Avx2Impl { + #[inline] + #[target_feature(enable = "avx2")] + fn dispatch_avx2(self, f: impl FnOnce(Self) -> R) -> R { + f(self) + } + + // https://www.lgfae.com/posts/2025-09-01-AlphaBlendWithSIMD.html + /// dst[i] = blend(src[i], dst[i]) // As unorm + /// blend fn is (ONE, ONE_MINUS_SRC_ALPHA) + #[target_feature(enable = "avx2")] + fn egui_blend_u8_slice_avx2(self, src: &[[u8; 4]], dst: &mut [[u8; 4]]) { + assert_eq!(src.len(), dst.len()); + + let n = dst.len(); + if n == 0 { + return; + } + + let mut i = 0; + while i + 3 < n { + // Load 4 src pixels + let src_ptr = unsafe { src.as_ptr().add(i) }.cast::<__m128i>(); + let src8: U8x4x4 = unsafe { read_unaligned(src_ptr) }; + let src16: U16x4x4 = x8_zeroextend16(src8); + + // Load 4 dst pixels + let dst_ptr = unsafe { dst.as_mut_ptr().add(i) }.cast::<__m128i>(); + let dst8: U8x4x4 = unsafe { read_unaligned(dst_ptr) }; + let dst16: U16x4x4 = x8_zeroextend16(dst8); + + let dst8: U8x4x4 = egui_blend_4_u16x4(src8, src16, dst16); + + unsafe { write_unaligned(dst_ptr, dst8) }; + i += 4; + } + + while i < n { + dst[i] = self.egui_blend_u8(src[i], dst[i]); + i += 1; + } + } + + // https://www.lgfae.com/posts/2025-09-01-AlphaBlendWithSIMD.html + /// dst[i] = blend(src[i], dst[i]) // As unorm + /// blend fn is (ONE, ONE_MINUS_SRC_ALPHA) + #[target_feature(enable = "avx2")] + fn egui_blend_u8_slice_one_src_tinted_fn_avx2( + self, + src: [u8; 4], + mut tint_fn: impl FnMut() -> [u8; 4], + dst: &mut [[u8; 4]], + ) { + let n = dst.len(); + if n == 0 { + return; + } + + let src8 = _mm_set1_epi32(i32::from_le_bytes(src)); + let src16 = x8_zeroextend16(src8); + + let mut i = 0usize; + while i + 3 < n { + // Load 4 tint values + let tint_a = i32::from_le_bytes(tint_fn()); + let tint_b = i32::from_le_bytes(tint_fn()); + let tint_c = i32::from_le_bytes(tint_fn()); + let tint_d = i32::from_le_bytes(tint_fn()); + let tint8: U8x4x4 = _mm_set_epi32(tint_d, tint_c, tint_b, tint_a); + let tint16: U16x4x4 = x8_zeroextend16(tint8); + + // Load 4 dst pixels + let dst_ptr = unsafe { dst.as_mut_ptr().add(i) }.cast::<__m128i>(); + let dst8: U8x4x4 = unsafe { read_unaligned(dst_ptr) }; + let dst16 = x8_zeroextend16(dst8); + + // src_tinted = (src16 * vert16 + 128) * 257 >> 16 (rounded /255) + let src_tinted16 = x16_div_255_approx(a16_times_b16_plus_128(src16, tint16)); + let src_tinted8 = x16_pack8(src_tinted16); + + let dst8 = egui_blend_4_u16x4(src_tinted8, src_tinted16, dst16); + + unsafe { write_unaligned(dst_ptr, dst8) }; + i += 4; + } + + // Tail: handle the last pixels (if any) in scalar + while i < n { + dst[i] = self.egui_blend_u8(self.unorm_mult4x4(src, tint_fn()), dst[i]); + i += 1; + } + } + + #[target_feature(enable = "avx2")] + fn egui_blend_u8_slice_tinted_avx2(self, src: &[[u8; 4]], tint: [u8; 4], dst: &mut [[u8; 4]]) { + assert_eq!(src.len(), dst.len()); + let n = dst.len(); + if n == 0 { + return; + } + + let tint8: U8x4x4 = _mm_set1_epi32(i32::from_le_bytes(tint)); + let tint16: U16x4x4 = x8_zeroextend16(tint8); + + let mut i = 0usize; + while i + 3 < n { + // Load 4 src pixels + let src = unsafe { src.as_ptr().add(i) }.cast::<__m128i>(); + let src8 = unsafe { read_unaligned(src) }; + let src16 = x8_zeroextend16(src8); + + // Load 4 dst pixels + let dst = unsafe { dst.as_mut_ptr().add(i) }.cast::<__m128i>(); + let dst8 = unsafe { read_unaligned(dst) }; + let dst16 = x8_zeroextend16(dst8); + + // src_tinted = (src16 * vert16 + 128) * 257 >> 16 (rounded /255) + let src_tinted16 = x16_div_255_approx(a16_times_b16_plus_128(src16, tint16)); + let src_tinted8 = x16_pack8(src_tinted16); + + let dst8 = egui_blend_4_u16x4(src_tinted8, src_tinted16, dst16); + + unsafe { write_unaligned(dst, dst8) }; + i += 4; + } + + // Tail: handle the last pixels (if any) in scalar + while i < n { + dst[i] = self.egui_blend_u8(self.unorm_mult4x4(src[i], tint), dst[i]); + i += 1; + } + } + + #[target_feature(enable = "avx2")] + fn egui_blend_u8_slice_one_src_avx2(self, src: [u8; 4], dst: &mut [[u8; 4]]) { + let n = dst.len(); + if n == 0 { + return; + } + + let a = src[3]; + + let alpha = src[3] as i32; + let alpha_compl = 0xFF ^ alpha; + + // (255 - a) in all u16 lanes + let alpha_compl16 = _mm256_set1_epi16(alpha_compl as i16); + + let mut i = 0; + if a == 0 { + // Only need to saturating_add src to dst. We can do 8 at a time in this case. + let src8 = _mm256_set1_epi32(i32::from_le_bytes(src)); + while i + 7 < n { + let dst_ptr = unsafe { dst.as_mut_ptr().add(i) }.cast::<__m256i>(); + let dst8 = unsafe { read_unaligned(dst_ptr) }; + let dst8 = _mm256_adds_epu8(dst8, src8); + unsafe { write_unaligned(dst_ptr, dst8) }; + i += 8; + } + } else { + let src8 = _mm_set1_epi32(i32::from_le_bytes(src)); + while i + 3 < n { + // Load 4 dst pixels + let dst_ptr = unsafe { dst.as_mut_ptr().add(i) }.cast::<__m128i>(); + let dst8: U8x4x4 = unsafe { read_unaligned(dst_ptr) }; + let dst16 = x8_zeroextend16(dst8); + + // dst * alpha_compl + 0x0080008000800080 + let res16 = a16_times_b16_plus_128(dst16, alpha_compl16); + let res16 = x16_div_255_approx(res16); + let dst8 = x16_pack8(res16); + + // dst.saturating_add(src) + let dst8 = a8_saturatingadd_b8(dst8, src8); + + unsafe { write_unaligned(dst_ptr, dst8) }; + i += 4; + } + } + + while i < n { + dst[i] = self.egui_blend_u8(src, dst[i]); + i += 1; + } + } +} + +impl SelectedImpl for Avx2Impl { + #[inline] + fn dispatch(self, f: impl FnOnce(Self) -> R) -> R { + unsafe { self.dispatch_avx2(f) } + } + + #[inline] + fn egui_blend_u8_slice(self, src: &[[u8; 4]], dst: &mut [[u8; 4]]) { + unsafe { self.egui_blend_u8_slice_avx2(src, dst) } + } + + #[inline] + fn egui_blend_u8_slice_one_src_tinted_fn( + self, + src: [u8; 4], + tint_fn: impl FnMut() -> [u8; 4], + dst: &mut [[u8; 4]], + ) { + unsafe { self.egui_blend_u8_slice_one_src_tinted_fn_avx2(src, tint_fn, dst) } + } + + #[inline] + fn egui_blend_u8_slice_tinted(self, src: &[[u8; 4]], tint: [u8; 4], dst: &mut [[u8; 4]]) { + unsafe { self.egui_blend_u8_slice_tinted_avx2(src, tint, dst) } + } + + #[inline] + fn egui_blend_u8_slice_one_src(self, src: [u8; 4], dst: &mut [[u8; 4]]) { + unsafe { self.egui_blend_u8_slice_one_src_avx2(src, dst) } + } + + #[inline] + fn egui_blend_u8(self, src: [u8; 4], dst: [u8; 4]) -> [u8; 4] { + self.sse41.egui_blend_u8(src, dst) + } + + #[inline] + fn unorm_mult4x4(self, a: [u8; 4], b: [u8; 4]) -> [u8; 4] { + GenericImpl.unorm_mult4x4(a, b) + } +} + +/// src_u8x4x4 should have four 8 bit per channel rgba samples stored in the low bits +/// src_u16x4x4 should have four 16 bit per channel rgba samples +/// dst_u16x4x4 should have four 16 bit per channel rgba samples +#[inline] +#[target_feature(enable = "avx2")] +fn egui_blend_4_u16x4(src8: __m128i, src16: __m256i, dst16: __m256i) -> __m128i { + let ones_u16x4x4 = _mm256_set1_epi16(0x00FF); + + // Broadcast alpha within each pixel's 4 lanes + let a_broadcast_lo = _mm256_shufflelo_epi16(src16, 0b11111111); + let a_broadcast = _mm256_shufflehi_epi16(a_broadcast_lo, 0b11111111); + + // simd_alpha_compl = 255 - A for each lane, per pixel + let simd_alpha_compl = _mm256_xor_si256(ones_u16x4x4, a_broadcast); + + // dst * alpha_compl + 0x0080008000800080 + let res16 = a16_times_b16_plus_128(dst16, simd_alpha_compl); + let res16 = x16_div_255_approx(res16); + let dst8 = x16_pack8(res16); + + a8_saturatingadd_b8(dst8, src8) +} + +/// a.saturating_add(b) +#[inline] +#[target_feature(enable = "avx2")] +fn a8_saturatingadd_b8(a: U8x4x4, b: U8x4x4) -> U8x4x4 { + _mm_adds_epu8(a, b) +} + +/// x as u16 +#[inline] +#[target_feature(enable = "avx2")] +fn x8_zeroextend16(x: U8x4x4) -> U16x4x4 { + _mm256_cvtepu8_epi16(x) +} + +/// a + b + 128 +#[inline] +#[target_feature(enable = "avx2")] +fn a16_times_b16_plus_128(a: U16x4x4, b: U16x4x4) -> U16x4x4 { + let mul = _mm256_mullo_epi16(a, b); + _mm256_add_epi16(mul, _mm256_set1_epi16(128)) +} + +/// Fast approximation of x / 255 +/// ((x >> 8) + x) >> x +#[inline] +#[target_feature(enable = "avx2")] +fn x16_div_255_approx(x: U16x4x4) -> U16x4x4 { + // This mulhi is equivalent to the ((x >> 8) + x) >> 8 operation + // 1 256 1 257 + // ((x >> 8) + x) >> 8 = (x + x---)/256 = (x--- + x---)/256 = (x-----) = x*257 >> 16 + // 256 256 256 65536 + _mm256_mulhi_epu16(x, _mm256_set1_epi16(257)) +} + +// Converts packed 16-bit integers from `x` to packed 8-bit integers using unsigned saturation. +#[inline] +#[target_feature(enable = "avx2")] +fn x16_pack8(x: U16x4x4) -> U8x4x4 { + let hi: U16x4x2 = _mm256_extracti128_si256(x, 1); + let lo: U16x4x2 = _mm256_castsi256_si128(x); + _mm_packus_epi16(lo, hi) +} diff --git a/crates/accshift-egui/vendor/egui_software_backend/src/color/mod.rs b/crates/accshift-egui/vendor/egui_software_backend/src/color/mod.rs new file mode 100644 index 0000000..a5300e1 --- /dev/null +++ b/crates/accshift-egui/vendor/egui_software_backend/src/color/mod.rs @@ -0,0 +1,238 @@ +use crate::math::vec4::{Vec4, vec4}; + +#[cfg(all(target_arch = "x86_64", feature = "std"))] +pub(crate) mod avx2; +#[cfg(all(target_arch = "aarch64", feature = "std"))] +pub(crate) mod neon; +#[cfg(all(target_arch = "x86_64", feature = "std"))] +pub(crate) mod sse41; + +/// An available to run on this processor implementation +/// +/// This enum doesn't implement `SelectedImpl` by choice, thus forcing compiler monomorphisation +/// a the call tree up to the closest [`dispatch_simd_impl!`] +/// +/// This enum values can only be created from [`available_instrs()`], so there is no way to select +/// a non supported by the current processor implementation +#[derive(Clone, Copy)] +pub(crate) enum AvailableImpl { + Generic(GenericImpl), + #[cfg(all(target_arch = "x86_64", feature = "std"))] + Sse41(sse41::Sse41Impl), + #[cfg(all(target_arch = "x86_64", feature = "std"))] + Avx2(avx2::Avx2Impl), + #[cfg(all(target_arch = "aarch64", feature = "std"))] + Neon(neon::NeonImpl), +} + +/// Non empty, order from most performant to least one list of supported by the current processor +/// implementations +pub(crate) fn available_instrs() -> &'static [AvailableImpl] { + const GENERIC: GenericImpl = GenericImpl; + #[cfg(all(target_arch = "x86_64", feature = "std"))] + if std::arch::is_x86_feature_detected!("sse4.1") { + const SSE41: sse41::Sse41Impl = unsafe { sse41::Sse41Impl::new() }; + if std::arch::is_x86_feature_detected!("avx2") { + const AVX2: avx2::Avx2Impl = unsafe { avx2::Avx2Impl::new() }; + return &[ + AvailableImpl::Avx2(AVX2), + AvailableImpl::Sse41(SSE41), + AvailableImpl::Generic(GENERIC), + ]; + } else { + return &[AvailableImpl::Sse41(SSE41), AvailableImpl::Generic(GENERIC)]; + } + } + + #[cfg(all(target_arch = "aarch64", feature = "std"))] + if std::arch::is_aarch64_feature_detected!("neon") { + const NEON: neon::NeonImpl = unsafe { neon::NeonImpl::new() }; + return &[AvailableImpl::Neon(NEON), AvailableImpl::Generic(GENERIC)]; + } + + &[AvailableImpl::Generic(GENERIC)] +} + +impl Default for AvailableImpl { + /// Returns the most performant available implementation + fn default() -> Self { + *available_instrs().first().expect("at least one impl") + } +} + +impl core::fmt::Display for AvailableImpl { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Generic(_) => f.write_str("Generic"), + #[cfg(all(target_arch = "x86_64", feature = "std"))] + Self::Sse41(_) => f.write_str("Sse41"), + #[cfg(all(target_arch = "x86_64", feature = "std"))] + Self::Avx2(_) => f.write_str("Avx2"), + #[cfg(all(target_arch = "aarch64", feature = "std"))] + Self::Neon(_) => f.write_str("Neon"), + } + } +} + +impl core::fmt::Debug for AvailableImpl { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + core::fmt::Display::fmt(&self, f) + } +} + +#[doc(hidden)] +#[macro_export] +macro_rules! dispatch_simd_impl { + (|$simd_impl:ident| $body:expr) => { + $crate::dispatch_simd_impl!($crate::color::AvailableImpl::default(), |$simd_impl| $body) + }; + ($available_impl:expr, |$simd_impl:ident| $body:expr) => { + match $available_impl { + $crate::color::AvailableImpl::Generic(i) => { + i.dispatch(|$simd_impl: $crate::color::GenericImpl| $body) + } + #[cfg(all(target_arch = "x86_64", feature = "std"))] + $crate::color::AvailableImpl::Sse41(i) => { + i.dispatch(|$simd_impl: $crate::color::sse41::Sse41Impl| $body) + } + #[cfg(all(target_arch = "x86_64", feature = "std"))] + $crate::color::AvailableImpl::Avx2(i) => { + i.dispatch(|$simd_impl: $crate::color::avx2::Avx2Impl| $body) + } + #[cfg(all(target_arch = "aarch64", feature = "std"))] + $crate::color::AvailableImpl::Neon(i) => { + i.dispatch(|$simd_impl: $crate::color::neon::NeonImpl| $body) + } + } + }; +} + +/// This trait holds performance sensitive methods that often can be writing in more architecture +/// dependent specialized versions +/// +/// All methods of this trait contains a generic implementation +pub(crate) trait SelectedImpl: Copy + Sync + Send + 'static { + /// Trick the compiler into compiling `f` with the same target_feature as this impl. + #[inline] + fn dispatch(self, f: impl FnOnce(Self) -> R) -> R { + f(self) + } + + fn egui_blend_u8_slice(self, src: &[[u8; 4]], dst: &mut [[u8; 4]]) { + for (pixel, src) in dst.iter_mut().zip(src) { + *pixel = self.egui_blend_u8(*src, *pixel); + } + } + /// dst[i] = blend(src * tint_fn(), dst[i]) // As unorm + /// blend fn is (ONE, ONE_MINUS_SRC_ALPHA) + fn egui_blend_u8_slice_one_src_tinted_fn( + self, + src: [u8; 4], + mut tint_fn: impl FnMut() -> [u8; 4], + dst: &mut [[u8; 4]], + ) { + for pixel in dst.iter_mut() { + *pixel = self.egui_blend_u8(self.unorm_mult4x4(tint_fn(), src), *pixel); + } + } + + /// dst[i] = blend(src[i] * tint, dst[i]) // As unorm + /// blend fn is (ONE, ONE_MINUS_SRC_ALPHA) + fn egui_blend_u8_slice_tinted(self, src: &[[u8; 4]], tint: [u8; 4], dst: &mut [[u8; 4]]) { + for (pixel, tex_color) in dst.iter_mut().zip(src) { + *pixel = self.egui_blend_u8(self.unorm_mult4x4(tint, *tex_color), *pixel); + } + } + + /// dst[i] = blend(src, dst[i]) + /// blend fn is (ONE, ONE_MINUS_SRC_ALPHA) + fn egui_blend_u8_slice_one_src(self, src: [u8; 4], dst: &mut [[u8; 4]]) { + for pixel in dst { + *pixel = self.egui_blend_u8(src, *pixel); + } + } + + // https://www.lgfae.com/posts/2025-09-01-AlphaBlendWithSIMD.html + /// blend fn is (ONE, ONE_MINUS_SRC_ALPHA) + #[inline(always)] + fn egui_blend_u8(self, src: [u8; 4], mut dst: [u8; 4]) -> [u8; 4] { + let a = src[3]; + if a == 255 { + return src; + } + if a != 0 { + let alpha = a as u64; + let alpha_compl = 0xFF ^ alpha; + let dst64 = as_color16(u32::from_le_bytes(dst)); + + let res16 = dst64 * alpha_compl + 0x0080008000800080; + let res8 = res16 + ((res16 >> 8) & 0x00FF00FF00FF00FF); + + // transform the result back to 32 bytes + let res = (res8 >> 8) & 0x00FF00FF00FF00FF; + let res = (res | (res >> 8)) & 0x0000FFFF0000FFFF; + let res = res | (res >> 16); + dst = u32::to_le_bytes((res & 0x00000000FFFFFFFF) as u32); + } + + [ + dst[0].saturating_add(src[0]), + dst[1].saturating_add(src[1]), + dst[2].saturating_add(src[2]), + dst[3].saturating_add(src[3]), + ] + } + + #[inline(always)] + fn unorm_mult4x4(self, a: [u8; 4], b: [u8; 4]) -> [u8; 4] { + [ + unorm_mult(a[0] as u32, b[0] as u32) as u8, + unorm_mult(a[1] as u32, b[1] as u32) as u8, + unorm_mult(a[2] as u32, b[2] as u32) as u8, + unorm_mult(a[3] as u32, b[3] as u32) as u8, + ] + } +} +#[derive(Clone, Copy)] +pub(crate) struct GenericImpl; + +impl SelectedImpl for GenericImpl {} + +#[inline(always)] +pub fn vec4_to_u8x4(v: &Vec4) -> [u8; 4] { + let v = v.clamp(Vec4::ZERO, Vec4::ONE) * 255.0 + 0.5; + [v.x as u8, v.y as u8, v.z as u8, v.w as u8] +} + +#[inline(always)] +pub fn u8x4_to_vec4(v: &[u8; 4]) -> Vec4 { + vec4( + v[0] as f32 / 255.0, + v[1] as f32 / 255.0, + v[2] as f32 / 255.0, + v[3] as f32 / 255.0, + ) +} + +/// transforms 4 bytes RGBA into 8 bytes 0R0G0B0A +#[inline(always)] +pub fn as_color16(color: u32) -> u64 { + let x = color as u64; + let x = ((x & 0xFFFF0000) << 16) | (x & 0xFFFF); + ((x & 0x0000FF000000FF00) << 8) | (x & 0x000000FF000000FF) +} + +#[inline(always)] +// Jerry R. Van Aken - Alpha Blending with No Division Operations https://arxiv.org/pdf/2202.02864 +// Input should be 0..255, is multiplied as if it were 0..1f +pub fn unorm_mult(mut a: u32, b: u32) -> u32 { + a *= b; + a += 0x80; + a += a >> 8; + a >> 8 +} + +#[inline(always)] +pub fn swizzle_rgba_bgra(a: [u8; 4]) -> [u8; 4] { + [a[2], a[1], a[0], a[3]] +} diff --git a/crates/accshift-egui/vendor/egui_software_backend/src/color/neon.rs b/crates/accshift-egui/vendor/egui_software_backend/src/color/neon.rs new file mode 100644 index 0000000..bfd8ec5 --- /dev/null +++ b/crates/accshift-egui/vendor/egui_software_backend/src/color/neon.rs @@ -0,0 +1,349 @@ +#![allow(unsafe_code)] + +use core::arch::aarch64::*; + +use crate::color::SelectedImpl; + +#[derive(Clone, Copy)] +pub(crate) struct NeonImpl(()); + +impl NeonImpl { + /// `std::arch::is_aarch64_feature_detected!("neon")` MUST be true + pub(crate) const unsafe fn new() -> Self { + Self(()) + } + + #[inline] + #[target_feature(enable = "neon")] + fn dispatch_neon(self, f: impl FnOnce(Self) -> R) -> R { + f(self) + } +} + +impl SelectedImpl for NeonImpl { + #[inline] + fn dispatch(self, f: impl FnOnce(Self) -> R) -> R { + unsafe { self.dispatch_neon(f) } + } + + #[inline] + fn egui_blend_u8_slice(self, src: &[[u8; 4]], dst: &mut [[u8; 4]]) { + unsafe { egui_blend_u8_slice(src, dst) } + } + + #[inline] + fn egui_blend_u8_slice_one_src_tinted_fn( + self, + src: [u8; 4], + tint_fn: impl FnMut() -> [u8; 4], + dst: &mut [[u8; 4]], + ) { + unsafe { egui_blend_u8_slice_one_src_tinted_fn(src, tint_fn, dst) } + } + + #[inline] + fn egui_blend_u8_slice_tinted(self, src: &[[u8; 4]], tint: [u8; 4], dst: &mut [[u8; 4]]) { + unsafe { egui_blend_u8_slice_tinted(src, tint, dst) } + } + + #[inline] + fn egui_blend_u8_slice_one_src(self, src: [u8; 4], dst: &mut [[u8; 4]]) { + unsafe { egui_blend_u8_slice_one_src(src, dst) } + } + + #[inline] + fn egui_blend_u8(self, src: [u8; 4], dst: [u8; 4]) -> [u8; 4] { + unsafe { egui_blend_u8(src, dst) } + } + + #[inline] + fn unorm_mult4x4(self, a: [u8; 4], b: [u8; 4]) -> [u8; 4] { + unsafe { unorm_mult4x4(a, b) } + } +} + +/// blend fn is (ONE, ONE_MINUS_SRC_ALPHA) +#[inline] +#[target_feature(enable = "neon")] +fn egui_blend_u8(src: [u8; 4], dst: [u8; 4]) -> [u8; 4] { + let a = src[3]; + + let alpha_compl = vdupq_n_u16((0xFFu16) ^ (a as u16)); + let e1 = vdupq_n_u16(0x0080); + + let dst8 = vreinterpret_u8_u32(vdup_n_u32(u32::from_le_bytes(dst))); + let mut dst = vmovl_u8(dst8); + + // dst * alpha_compl + 0x0080 + dst = vaddq_u16(vmulq_u16(dst, alpha_compl), e1); + + // ((x >> 8) + x) >> 8 + dst = vaddq_u16(dst, vshrq_n_u16(dst, 8)); + dst = vshrq_n_u16(dst, 8); + + // Pack to back to u8 + let dst = vqmovn_u16(dst); + + let src32x2 = vdup_n_u32(u32::from_le_bytes(src)); + let src8 = vreinterpret_u8_u32(src32x2); + + // saturating add + let out8 = vqadd_u8(dst, src8); + + let out32 = vget_lane_u32(vreinterpret_u32_u8(out8), 0); + out32.to_le_bytes() +} + +// https://www.lgfae.com/posts/2025-09-01-AlphaBlendWithSIMD.html +/// blend fn is (ONE, ONE_MINUS_SRC_ALPHA) +#[target_feature(enable = "neon")] +pub fn egui_blend_u8_slice_one_src(src: [u8; 4], dst: &mut [[u8; 4]]) { + let n = dst.len(); + if n == 0 { + return; + } + + let a = src[3]; + + let src32 = u32::from_le_bytes(src); + + let alpha = src[3] as i32; + let alpha_compl = 0xFF ^ alpha; + + let e1 = vdupq_n_u16(0x0080); + + // (255 - a) in all u16 lanes + let simd_alpha_compl = vdupq_n_u16(alpha_compl as u16); + + let mut i = 0; + if a == 0 { + let src8_q = vreinterpretq_u8_u32(vdupq_n_u32(src32)); + // Only need to saturating_add src to dst. We can do 4 at a time in this case. + while i + 3 < n { + let p = unsafe { dst.as_mut_ptr().add(i) } as *mut u8; + let d128 = unsafe { vld1q_u8(p) }; + let out = vqaddq_u8(d128, src8_q); + unsafe { vst1q_u8(p, out) }; + i += 4; + } + } else { + let src8 = vreinterpret_u8_u32(vdup_n_u32(src32)); + while i + 1 < n { + let dst_p = unsafe { dst.as_mut_ptr().add(i) } as *mut u8; + // Load two dst pixels + let d8 = unsafe { vld1_u8(dst_p) }; + // [0,0,0,0,rg,ba,rg,ba] -> [r,g,b,a,r,g,b,a] + let dst16 = vmovl_u8(d8); + + // dst * alpha_compl + 0x0080008000800080 + let res16 = vaddq_u16(vmulq_u16(dst16, simd_alpha_compl), e1); + + // ((x >> 8) + x) >> 8 + let res16 = vshrq_n_u16(vaddq_u16(res16, vshrq_n_u16(res16, 8)), 8); + let mut dst8 = vqmovn_u16(res16); + + // dst.saturating_add(src) + dst8 = vqadd_u8(dst8, src8); + + unsafe { vst1_u8(dst_p, dst8) }; + i += 2; + } + } + + while i < n { + dst[i] = egui_blend_u8(src, dst[i]); + i += 1; + } +} + +// https://www.lgfae.com/posts/2025-09-01-AlphaBlendWithSIMD.html +/// dst[i] = blend(src[i], dst[i]) // As unorm +/// blend fn is (ONE, ONE_MINUS_SRC_ALPHA) +#[target_feature(enable = "neon")] +fn egui_blend_u8_slice(src: &[[u8; 4]], dst: &mut [[u8; 4]]) { + assert_eq!(src.len(), dst.len()); + + let n = dst.len(); + if n == 0 { + return; + } + + let mut i = 0; + while i + 1 < n { + let src_p = unsafe { src.as_ptr().add(i) } as *mut u8; + // Load two src pixels + let src8 = unsafe { vld1_u8(src_p) }; + // [0,0,0,0,rg,ba,rg,ba] -> [r,g,b,a,r,g,b,a] + let src16 = vmovl_u8(src8); + + // Load two dst pixels + let dst_p = unsafe { dst.as_mut_ptr().add(i) } as *mut u8; + // Load two dst pixels + let d8 = unsafe { vld1_u8(dst_p) }; + // [0,0,0,0,rg,ba,rg,ba] -> [r,g,b,a,r,g,b,a] + let dst16 = vmovl_u8(d8); + + let dst8 = egui_blend_two_u16x4(src8, src16, dst16); + + unsafe { vst1_u8(dst_p, dst8) }; + i += 2; + } + + if i < n { + dst[i] = egui_blend_u8(src[i], dst[i]); + } +} + +// https://www.lgfae.com/posts/2025-09-01-AlphaBlendWithSIMD.html +/// dst[i] = blend(src[i] * vert, dst[i]) // As unorm +/// blend fn is (ONE, ONE_MINUS_SRC_ALPHA) +#[target_feature(enable = "neon")] +fn egui_blend_u8_slice_tinted(src: &[[u8; 4]], tint: [u8; 4], dst: &mut [[u8; 4]]) { + assert_eq!(src.len(), dst.len()); + let n = dst.len(); + if n == 0 { + return; + } + + let e1 = vdupq_n_u16(0x0080); + + let tint32 = u32::from_le_bytes(tint); + let tint8 = vreinterpret_u8_u32(vdup_n_u32(tint32)); + let tint16 = vmovl_u8(tint8); + + let mut i = 0usize; + while i + 1 < n { + let src_p = unsafe { src.as_ptr().add(i) } as *mut u8; + // Load two src pixels + let src8 = unsafe { vld1_u8(src_p) }; + // [0,0,0,0,rg,ba,rg,ba] -> [r,g,b,a,r,g,b,a] + let src16 = vmovl_u8(src8); + + // Load two dst pixels + let dst_p = unsafe { dst.as_mut_ptr().add(i) } as *mut u8; + let d8 = unsafe { vld1_u8(dst_p) }; + // [0,0,0,0,rg,ba,rg,ba] -> [r,g,b,a,r,g,b,a] + let dst16 = vmovl_u8(d8); + + // src_tinted = (src16 * vert16 + 128) * 257 >> 16 (rounded /255) + let mut t = vaddq_u16(vmulq_u16(src16, tint16), e1); + t = vaddq_u16(t, vshrq_n_u16(t, 8)); + let src_tinted16 = vshrq_n_u16(t, 8); + let src_tinted8 = vqmovn_u16(src_tinted16); + + let dst8 = egui_blend_two_u16x4(src_tinted8, src_tinted16, dst16); + + unsafe { vst1_u8(dst_p, dst8) }; + i += 2; + } + + // Tail: handle the last pixel (if any) in scalar + if i < n { + dst[i] = egui_blend_u8(unorm_mult4x4(src[i], tint), dst[i]); + } +} + +// https://www.lgfae.com/posts/2025-09-01-AlphaBlendWithSIMD.html +/// dst[i] = blend(src * tint_fn(), dst[i]) // As unorm +/// blend fn is (ONE, ONE_MINUS_SRC_ALPHA) +#[target_feature(enable = "neon")] +fn egui_blend_u8_slice_one_src_tinted_fn( + src: [u8; 4], + mut tint_fn: impl FnMut() -> [u8; 4], + dst: &mut [[u8; 4]], +) { + let n = dst.len(); + if n == 0 { + return; + } + + let src32 = u32::from_le_bytes(src); + let src8 = vreinterpret_u8_u32(vdup_n_u32(src32)); + let src16 = vmovl_u8(src8); + + let e1 = vdupq_n_u16(0x0080); + + let mut i = 0usize; + while i + 1 < n { + // Load two tint values + + let mut t32x2 = vdup_n_u32(0); + t32x2 = vset_lane_u32(u32::from_le_bytes(tint_fn()), t32x2, 0); + t32x2 = vset_lane_u32(u32::from_le_bytes(tint_fn()), t32x2, 1); + let tint8 = vreinterpret_u8_u32(t32x2); + let tint16 = vmovl_u8(tint8); + + // Load two dst pixels + let dst_p = unsafe { dst.as_mut_ptr().add(i) } as *mut u8; + let d8 = unsafe { vld1_u8(dst_p) }; + // [0,0,0,0,rg,ba,rg,ba] -> [r,g,b,a,r,g,b,a] + let dst16 = vmovl_u8(d8); + + // src_tinted = (src16 * vert16 + 128) * 257 >> 16 (rounded /255) + let mut t = vaddq_u16(vmulq_u16(src16, tint16), e1); + t = vaddq_u16(t, vshrq_n_u16(t, 8)); + let src_tinted16 = vshrq_n_u16(t, 8); + let src_tinted8 = vqmovn_u16(src_tinted16); + + let dst8 = egui_blend_two_u16x4(src_tinted8, src_tinted16, dst16); + + unsafe { vst1_u8(dst_p, dst8) }; + i += 2; + } + + // Tail: handle the last pixel (if any) in scalar + if i < n { + dst[i] = egui_blend_u8(unorm_mult4x4(src, tint_fn()), dst[i]); + } +} + +#[inline] +#[target_feature(enable = "neon")] +fn unorm_mult4x4(a: [u8; 4], b: [u8; 4]) -> [u8; 4] { + let e1 = vdupq_n_u16(0x0080); + let a = vmovl_u8(vreinterpret_u8_u32(vdup_n_u32(u32::from_le_bytes(a)))); + let b = vmovl_u8(vreinterpret_u8_u32(vdup_n_u32(u32::from_le_bytes(b)))); + + // a * b + 0x0080 + let mut dst = vaddq_u16(vmulq_u16(a, b), e1); + + // ((a >> 8) + a) >> 8 + dst = vaddq_u16(dst, vshrq_n_u16(dst, 8)); + dst = vshrq_n_u16(dst, 8); + + // Pack to back to u8 + let dst = vqmovn_u16(dst); + + vget_lane_u32(vreinterpret_u32_u8(dst), 0).to_le_bytes() // Return first element of dst +} + +#[inline] +/// src8 should have two 8 bit per channel rgba samples stored in the low bits +/// src16 should have two 16 bit per channel rgba samples +/// dst16 should have two 16 bit per channel rgba samples +#[target_feature(enable = "neon")] +fn egui_blend_two_u16x4(src8: uint8x8_t, src16: uint16x8_t, dst16: uint16x8_t) -> uint8x8_t { + let ones = vdupq_n_u16(0x00FF); + let e1 = vdupq_n_u16(0x0080); + + // Broadcast alpha within each pixel's 4 lanes + let a_lo4 = vdup_n_u16(vgetq_lane_u16(src16, 3)); + let a_hi4 = vdup_n_u16(vgetq_lane_u16(src16, 7)); + let a_broadcast = vcombine_u16(a_lo4, a_hi4); + + // simd_alpha_compl = 255 - A for each lane, per pixel + let simd_alpha_compl = vsubq_u16(ones, a_broadcast); + + // dst * alpha_compl + 0x0080008000800080 + let dst_term = vmulq_u16(dst16, simd_alpha_compl); + let res16 = vaddq_u16(dst_term, e1); + + // ((x >> 8) + x) >> 8 + let res16 = vaddq_u16(res16, vshrq_n_u16(res16, 8)); + let res16 = vshrq_n_u16(res16, 8); + let dst8 = vqmovn_u16(res16); + // Pack back to u8 + + // dst.saturating_add(src) + vqadd_u8(dst8, src8) +} diff --git a/crates/accshift-egui/vendor/egui_software_backend/src/color/sse41.rs b/crates/accshift-egui/vendor/egui_software_backend/src/color/sse41.rs new file mode 100644 index 0000000..6ff4785 --- /dev/null +++ b/crates/accshift-egui/vendor/egui_software_backend/src/color/sse41.rs @@ -0,0 +1,346 @@ +#![allow(unsafe_code)] + +use core::{arch::x86_64::*, ptr::read_unaligned}; + +use crate::color::SelectedImpl; + +#[derive(Clone, Copy)] +pub struct Sse41Impl(()); + +impl Sse41Impl { + /// `std::arch::is_x86_feature_detected!("sse4.1")` MUST be true + pub const unsafe fn new() -> Self { + Self(()) + } + + #[inline] + #[target_feature(enable = "sse4.1")] + fn dispatch_sse41(self, f: impl FnOnce(Self) -> R) -> R { + f(self) + } +} + +impl SelectedImpl for Sse41Impl { + #[inline] + fn dispatch(self, f: impl FnOnce(Self) -> R) -> R { + unsafe { self.dispatch_sse41(f) } + } + + #[inline] + fn egui_blend_u8_slice(self, src: &[[u8; 4]], dst: &mut [[u8; 4]]) { + unsafe { egui_blend_u8_slice(src, dst) } + } + + #[inline] + fn egui_blend_u8_slice_one_src_tinted_fn( + self, + src: [u8; 4], + tint_fn: impl FnMut() -> [u8; 4], + dst: &mut [[u8; 4]], + ) { + unsafe { egui_blend_u8_slice_one_src_tinted_fn(src, tint_fn, dst) } + } + + #[inline] + fn egui_blend_u8_slice_tinted(self, src: &[[u8; 4]], tint: [u8; 4], dst: &mut [[u8; 4]]) { + unsafe { egui_blend_u8_slice_tinted(src, tint, dst) } + } + + #[inline] + fn egui_blend_u8_slice_one_src(self, src: [u8; 4], dst: &mut [[u8; 4]]) { + unsafe { egui_blend_u8_slice_one_src(src, dst) } + } + + #[inline] + fn egui_blend_u8(self, src: [u8; 4], dst: [u8; 4]) -> [u8; 4] { + unsafe { egui_blend_u8(src, dst) } + } + + #[inline] + fn unorm_mult4x4(self, a: [u8; 4], b: [u8; 4]) -> [u8; 4] { + unsafe { unorm_mult4x4(a, b) } + } +} + +/// blend fn is (ONE, ONE_MINUS_SRC_ALPHA) +#[inline] +#[target_feature(enable = "sse4.1")] +fn egui_blend_u8(src: [u8; 4], dst: [u8; 4]) -> [u8; 4] { + let alpha = src[3]; + + let alpha_compl = _mm_set1_epi16(0xFFi16 ^ (alpha as i16)); + let e1 = _mm_set1_epi16(0x0080); + let mut dst = _mm_cvtsi32_si128(i32::from_le_bytes(dst)); // Load dst into element a + dst = _mm_cvtepu8_epi16(dst); // [0,0,0,0,0,0,0,rgba] -> [0,0,0,0,r,g,b,a] + + // dst * alpha_compl + 0x0080 + dst = _mm_add_epi16(_mm_mullo_epi16(dst, alpha_compl), e1); + + // ((x >> 8) + x) >> 8 + dst = _mm_add_epi16(dst, _mm_srli_epi16(dst, 8)); + dst = _mm_srli_epi16(dst, 8); + + // Pack to back to u8 + let dst = _mm_packus_epi16(dst, _mm_setzero_si128()); + + let src = _mm_cvtsi32_si128(i32::from_le_bytes(src)); // Load src into element a + let dst = _mm_adds_epu8(dst, src); // dst.saturating_add(src) + + i32::to_le_bytes(_mm_cvtsi128_si32(dst)) // Return first element of dst +} + +// https://www.lgfae.com/posts/2025-09-01-AlphaBlendWithSIMD.html +/// blend fn is (ONE, ONE_MINUS_SRC_ALPHA) +#[target_feature(enable = "sse4.1")] +fn egui_blend_u8_slice_one_src(src: [u8; 4], dst: &mut [[u8; 4]]) { + let n = dst.len(); + if n == 0 { + return; + } + + let a = src[3]; + + let src32 = u32::from_le_bytes(src); + let src64 = (src32 as u64) | ((src32 as u64) << 32); + let src8 = _mm_set1_epi64x(src64 as i64); + + let alpha = src[3] as i32; + let alpha_compl = 0xFF ^ alpha; + + let e1 = _mm_set1_epi16(0x0080); + let e2 = _mm_set1_epi16(0x0101); + + // (255 - a) in all u16 lanes + let simd_alpha_compl = _mm_set1_epi16(alpha_compl as i16); + + let mut i = 0; + if a == 0 { + // Only need to saturating_add src to dst. We can do 4 at a time in this case. + while i + 3 < n { + let p = unsafe { dst.as_mut_ptr().add(i) } as *mut __m128i; + let d128 = unsafe { _mm_loadu_si128(p) }; + let out = _mm_adds_epu8(d128, src8); + unsafe { _mm_storeu_si128(p, out) }; + i += 4; + } + } else { + while i + 1 < n { + let dst = unsafe { dst.as_mut_ptr().add(i) }.cast::(); + // Load two dst pixels + let d8 = _mm_cvtsi64_si128(unsafe { read_unaligned(dst) } as i64); + // [0,0,0,0,rg,ba,rg,ba] -> [r,g,b,a,r,g,b,a] + let dst16 = _mm_cvtepu8_epi16(d8); + + // dst * alpha_compl + 0x0080008000800080 + let res16 = _mm_add_epi16(_mm_mullo_epi16(dst16, simd_alpha_compl), e1); + + // This mulhi is equivalent to the ((x >> 8) + x) >> 8 operation. (can you see why?) + let res16 = _mm_mulhi_epu16(res16, e2); + let mut dst8 = _mm_packus_epi16(res16, res16); // Pack back to u8 + + // dst.saturating_add(src) + dst8 = _mm_adds_epu8(dst8, src8); + + let lo64 = _mm_cvtsi128_si64(dst8) as u64; + unsafe { core::ptr::write_unaligned(dst, lo64) }; + i += 2; + } + } + + while i < n { + dst[i] = egui_blend_u8(src, dst[i]); + i += 1; + } +} + +// https://www.lgfae.com/posts/2025-09-01-AlphaBlendWithSIMD.html +/// dst[i] = blend(src[i], dst[i]) // As unorm +/// blend fn is (ONE, ONE_MINUS_SRC_ALPHA) +#[target_feature(enable = "sse4.1")] +fn egui_blend_u8_slice(src: &[[u8; 4]], dst: &mut [[u8; 4]]) { + assert_eq!(src.len(), dst.len()); + + let n = dst.len(); + if n == 0 { + return; + } + + let mut i = 0; + while i + 1 < n { + // Load two src pixels + let src = unsafe { src.as_ptr().add(i) }.cast::(); + let src8 = _mm_cvtsi64_si128(unsafe { read_unaligned(src) } as i64); + // [0,0,0,0,rg,ba,rg,ba] -> [r,g,b,a,r,g,b,a] + let src16 = _mm_cvtepu8_epi16(src8); + + // Load two dst pixels + let dst = unsafe { dst.as_mut_ptr().add(i) }.cast::(); + let d8 = _mm_cvtsi64_si128(unsafe { read_unaligned(dst) } as i64); + let dst16 = _mm_cvtepu8_epi16(d8); + + let dst8 = egui_blend_two_u16x4(src8, src16, dst16); + + let lo64 = _mm_cvtsi128_si64(dst8) as u64; + unsafe { core::ptr::write_unaligned(dst, lo64) }; + i += 2; + } + + if i < n { + dst[i] = egui_blend_u8(src[i], dst[i]); + } +} + +// https://www.lgfae.com/posts/2025-09-01-AlphaBlendWithSIMD.html +/// dst[i] = blend(src[i] * vert, dst[i]) // As unorm +/// blend fn is (ONE, ONE_MINUS_SRC_ALPHA) +#[target_feature(enable = "sse4.1")] +fn egui_blend_u8_slice_tinted(src: &[[u8; 4]], tint: [u8; 4], dst: &mut [[u8; 4]]) { + assert_eq!(src.len(), dst.len()); + let n = dst.len(); + if n == 0 { + return; + } + + let e1 = _mm_set1_epi16(0x0080); + let e2 = _mm_set1_epi16(0x0101); + + let t32 = _mm_set1_epi32(i32::from_le_bytes(tint)); + let tint16 = _mm_cvtepu8_epi16(t32); + + let mut i = 0usize; + while i + 1 < n { + // Load two src pixels + let src = unsafe { src.as_ptr().add(i) }.cast::(); + let src8 = _mm_cvtsi64_si128(unsafe { read_unaligned(src) } as i64); + // [0,0,0,0,rg,ba,rg,ba] -> [r,g,b,a,r,g,b,a] + let src16 = _mm_cvtepu8_epi16(src8); + + // Load two dst pixels + let dst = unsafe { dst.as_mut_ptr().add(i) }.cast::(); + let dst8 = _mm_cvtsi64_si128(unsafe { read_unaligned(dst) } as i64); + let dst16 = _mm_cvtepu8_epi16(dst8); + + // src_tinted = (src16 * vert16 + 128) * 257 >> 16 (rounded /255) + let tint_mul = _mm_mullo_epi16(src16, tint16); + let tint_rounded = _mm_add_epi16(tint_mul, e1); + let src_tinted16 = _mm_mulhi_epu16(tint_rounded, e2); + let src_tinted8 = _mm_packus_epi16(src_tinted16, src_tinted16); + + let dst8 = egui_blend_two_u16x4(src_tinted8, src_tinted16, dst16); + + let lo64 = _mm_cvtsi128_si64(dst8) as u64; + unsafe { core::ptr::write_unaligned(dst, lo64) }; + i += 2; + } + + // Tail: handle the last pixel (if any) in scalar + if i < n { + dst[i] = egui_blend_u8(unorm_mult4x4(src[i], tint), dst[i]); + } +} + +// https://www.lgfae.com/posts/2025-09-01-AlphaBlendWithSIMD.html +/// dst[i] = blend(src * tint_fn(), dst[i]) // As unorm +/// blend fn is (ONE, ONE_MINUS_SRC_ALPHA) +#[target_feature(enable = "sse4.1")] +fn egui_blend_u8_slice_one_src_tinted_fn( + src: [u8; 4], + mut tint_fn: impl FnMut() -> [u8; 4], + dst: &mut [[u8; 4]], +) { + let n = dst.len(); + if n == 0 { + return; + } + + let src32 = u32::from_le_bytes(src); + let src64 = (src32 as u64) | ((src32 as u64) << 32); + + let e1 = _mm_set1_epi16(0x0080); + let e2 = _mm_set1_epi16(0x0101); + + let mut i = 0usize; + while i + 1 < n { + // Load two tint values + let tint_a = u32::from_le_bytes(tint_fn()) as i64; + let tint_b = u32::from_le_bytes(tint_fn()) as i64; + let tint_simd = _mm_cvtsi64_si128((tint_b << 32) | tint_a); + let tint16 = _mm_cvtepu8_epi16(tint_simd); + + let src8 = _mm_cvtsi64_si128(src64 as i64); + // [0,0,0,0,rg,ba,rg,ba] -> [r,g,b,a,r,g,b,a] + let src16 = _mm_cvtepu8_epi16(src8); + + // Load two dst pixels + let dst = unsafe { dst.as_mut_ptr().add(i) }.cast::(); + let dst8 = _mm_cvtsi64_si128(unsafe { read_unaligned(dst) } as i64); + let dst16 = _mm_cvtepu8_epi16(dst8); + + // src_tinted = (src16 * vert16 + 128) * 257 >> 16 (rounded /255) + let tint_mul = _mm_mullo_epi16(src16, tint16); + let tint_rounded = _mm_add_epi16(tint_mul, e1); + let src_tinted16 = _mm_mulhi_epu16(tint_rounded, e2); + let src_tinted8 = _mm_packus_epi16(src_tinted16, src_tinted16); + + let dst8 = egui_blend_two_u16x4(src_tinted8, src_tinted16, dst16); + + let lo64 = _mm_cvtsi128_si64(dst8) as u64; + unsafe { core::ptr::write_unaligned(dst, lo64) }; + i += 2; + } + + // Tail: handle the last pixel (if any) in scalar + if i < n { + dst[i] = egui_blend_u8(unorm_mult4x4(src, tint_fn()), dst[i]); + } +} + +#[inline] +#[target_feature(enable = "sse4.1")] +fn unorm_mult4x4(a: [u8; 4], b: [u8; 4]) -> [u8; 4] { + let e1 = _mm_set1_epi16(0x0080); + let a = _mm_cvtepu8_epi16(_mm_cvtsi32_si128(i32::from_le_bytes(a))); + let b = _mm_cvtepu8_epi16(_mm_cvtsi32_si128(i32::from_le_bytes(b))); + + // a * b + 0x0080 + let mut dst = _mm_add_epi16(_mm_mullo_epi16(a, b), e1); + + // ((a >> 8) + a) >> 8 + dst = _mm_add_epi16(dst, _mm_srli_epi16(dst, 8)); + dst = _mm_srli_epi16(dst, 8); + + // Pack to back to u8 + let dst = _mm_packus_epi16(dst, _mm_setzero_si128()); + + i32::to_le_bytes(_mm_cvtsi128_si32(dst)) // Return first element of dst +} + +#[inline] +/// src8 should have two 8 bit per channel rgba samples stored in the low bits +/// src16 should have two 16 bit per channel rgba samples +/// dst16 should have two 16 bit per channel rgba samples +#[target_feature(enable = "sse4.1")] +fn egui_blend_two_u16x4(src8: __m128i, src16: __m128i, dst16: __m128i) -> __m128i { + let ones = _mm_set1_epi16(0x00FF); + let e1 = _mm_set1_epi16(0x0080); + let e2 = _mm_set1_epi16(0x0101); + + // Broadcast alpha within each pixel's 4 lanes + let a_broadcast_lo = _mm_shufflelo_epi16(src16, 0b11111111); + let a_broadcast = _mm_shufflehi_epi16(a_broadcast_lo, 0b11111111); + + // simd_alpha_compl = 255 - A for each lane, per pixel + let simd_alpha_compl = _mm_sub_epi16(ones, a_broadcast); + + // dst * alpha_compl + 0x0080008000800080 + let dst_term = _mm_mullo_epi16(dst16, simd_alpha_compl); + let res16 = _mm_add_epi16(dst_term, e1); + + // This mulhi is equivalent to the ((x >> 8) + x) >> 8 operation. (can you see why?) + let res16 = _mm_mulhi_epu16(res16, e2); + let dst8 = _mm_packus_epi16(res16, res16); + // Pack back to u8 + + // dst.saturating_add(src) + _mm_adds_epu8(dst8, src8) +} diff --git a/crates/accshift-egui/vendor/egui_software_backend/src/egui_texture.rs b/crates/accshift-egui/vendor/egui_software_backend/src/egui_texture.rs new file mode 100644 index 0000000..4bcc5f8 --- /dev/null +++ b/crates/accshift-egui/vendor/egui_software_backend/src/egui_texture.rs @@ -0,0 +1,119 @@ +use alloc::vec::Vec; +use egui::{Color32, TextureFilter, TextureOptions, Vec2, vec2}; + +use crate::{ + ColorFieldOrder, + color::{swizzle_rgba_bgra, u8x4_to_vec4, vec4_to_u8x4}, +}; + +pub struct EguiTexture { + pub data: Vec<[u8; 4]>, + // Common case: The default egui texture has the top-left corner pixel fully white. + // https://github.com/emilk/egui/blob/c97c065a575ec6e657bb42872890a00d0fb391c1/crates/epaint/src/lib.rs#L92 + pub uv_zero_val: [u8; 4], + /// width - 1 + pub width_extent: i32, + /// height - 1 + pub height_extent: i32, + pub width: usize, + #[allow(dead_code)] + pub height: usize, + pub fsize: Vec2, + pub options: TextureOptions, +} + +impl EguiTexture { + pub fn new( + field_order: ColorFieldOrder, + options: TextureOptions, + size: [usize; 2], + pixels: &[Color32], + ) -> EguiTexture { + let data = pixels + .iter() + .map(|p| match field_order { + ColorFieldOrder::Rgba => p.to_array(), + ColorFieldOrder::Bgra => swizzle_rgba_bgra(p.to_array()), + }) + .collect::>(); + let uv_zero_val = data[0]; + EguiTexture { + data, + width_extent: size[0] as i32 - 1, + height_extent: size[1] as i32 - 1, + width: size[0], + height: size[1], + fsize: vec2(size[0] as f32, size[1] as f32), + options, + uv_zero_val, + } + } + + #[allow(dead_code)] + pub fn sample_nearest(&self, uv: Vec2) -> [u8; 4] { + let ss_x = ((uv.x * self.fsize.x) as i32).max(0).min(self.width_extent); + let ss_y = ((uv.y * self.fsize.y) as i32) + .max(0) + .min(self.height_extent); + self.data[ss_x as usize + ss_y as usize * self.width] + } + + pub fn sample_bilinear(&self, uv: Vec2) -> [u8; 4] { + if uv == Vec2::ZERO { + return self.uv_zero_val; + } + + let w = self.fsize.x; + let h = self.fsize.y; + + #[inline(always)] + fn mirror(v: f32) -> f32 { + ((v * 0.5 + 0.5).fract() - 0.5) * 2.0 + } + + let uv = match self.options.wrap_mode { + egui::TextureWrapMode::ClampToEdge => uv, + egui::TextureWrapMode::Repeat => vec2(uv.x.fract(), uv.y.fract()), + egui::TextureWrapMode::MirroredRepeat => vec2(mirror(uv.x), mirror(uv.y)), + }; + + let sx = uv.x * w - 0.5; + let sy = uv.y * h - 0.5; + + let x0 = sx.floor() as i32; + let y0 = sy.floor() as i32; + let x1 = x0 + 1; + let y1 = y0 + 1; + + let fx = sx - x0 as f32; + let fy = sy - y0 as f32; + + let x0c = x0.max(0).min(self.width_extent); + let y0c = y0.max(0).min(self.height_extent); + let x1c = x1.max(0).min(self.width_extent); + let y1c = y1.max(0).min(self.height_extent); + + let c00 = self.data[(x0c as usize) + (y0c as usize) * self.width]; + + if self.options.magnification == TextureFilter::Nearest || (fx == 0.0 && fy == 0.0) { + // if these are 0 the px at 0,0 will have full influence. Equivalent to nearest sampling. + return c00; + } + + let c10 = self.data[(x1c as usize) + (y0c as usize) * self.width]; + let c01 = self.data[(x0c as usize) + (y1c as usize) * self.width]; + let c11 = self.data[(x1c as usize) + (y1c as usize) * self.width]; + + let v00 = u8x4_to_vec4(&c00); + let v10 = u8x4_to_vec4(&c10); + let v01 = u8x4_to_vec4(&c01); + let v11 = u8x4_to_vec4(&c11); + + let w00 = (1.0 - fx) * (1.0 - fy); + let w10 = fx * (1.0 - fy); + let w01 = (1.0 - fx) * fy; + let w11 = fx * fy; + + vec4_to_u8x4(&(v00 * w00 + v01 * w01 + v10 * w10 + v11 * w11)) + } +} diff --git a/crates/accshift-egui/vendor/egui_software_backend/src/hash.rs b/crates/accshift-egui/vendor/egui_software_backend/src/hash.rs new file mode 100644 index 0000000..b62d681 --- /dev/null +++ b/crates/accshift-egui/vendor/egui_software_backend/src/hash.rs @@ -0,0 +1,39 @@ +pub struct Hash32(pub u32); + +impl Hash32 { + #[inline(always)] + pub fn new_fnv() -> Self { + Hash32(0x811c9dc5) // FNV offset basis + } + + #[inline(always)] + pub fn hash_wrap(&mut self, v: u32) { + self.hash(v); + self.fnv_wrap(); + } + + #[inline(always)] + pub fn hash(&mut self, v: u32) { + self.0 ^= hash32(v); + } + + #[inline(always)] + pub fn fnv_wrap(&mut self) { + self.0 = self.0.wrapping_mul(0x01000193); // FNV prime + } + + pub fn finalize(&self) -> u32 { + self.0 + } +} + +#[inline(always)] +fn hash32(x: u32) -> u32 { + // from https://nullprogram.com/blog/2018/07/31/ + let mut x = x ^ (x >> 16); + x = x.overflowing_mul(0x7feb352d).0; + x = x ^ (x >> 15); + x = x.overflowing_mul(0x846ca68b).0; + x = x ^ (x >> 16); + x +} diff --git a/crates/accshift-egui/vendor/egui_software_backend/src/lib.rs b/crates/accshift-egui/vendor/egui_software_backend/src/lib.rs new file mode 100644 index 0000000..6cb0981 --- /dev/null +++ b/crates/accshift-egui/vendor/egui_software_backend/src/lib.rs @@ -0,0 +1,1189 @@ +//! CPU software render backend for egui +//! +//! Performance will be very poor without compiler optimizations. Run in release or use (Cargo.toml): +//!```toml +//!# Enable high optimizations for dependencies +//![profile.dev.package."*"] +//!opt-level = 3 +//!``` +//! + +#![allow(clippy::needless_doctest_main)] +#![no_std] +extern crate alloc; + +#[cfg(feature = "std")] +extern crate std; + +use core::ops::Range; + +use alloc::{borrow::Cow, vec, vec::Vec}; + +use ahash::HashMap; +use egui::{Color32, Mesh, Pos2, Vec2, vec2}; + +#[cfg(feature = "raster_stats")] +use crate::stats::RasterStats; +use crate::{ + color::{AvailableImpl, SelectedImpl, swizzle_rgba_bgra}, + egui_texture::EguiTexture, + hash::Hash32, + render::{draw_egui_mesh, egui_orient2df}, +}; + +pub(crate) mod color; +pub(crate) mod egui_texture; +pub(crate) mod hash; +pub(crate) mod math; +pub(crate) mod raster; +pub(crate) mod render; +#[cfg(feature = "raster_stats")] +pub mod stats; + +#[inline(always)] +#[allow(dead_code)] +pub(crate) fn sse41() -> bool { + #[cfg(all(target_arch = "x86_64", feature = "std"))] + return std::arch::is_x86_feature_detected!("sse4.1"); + #[cfg(any(not(target_arch = "x86_64"), not(feature = "std")))] + return false; +} + +#[inline(always)] +#[allow(dead_code)] +pub(crate) fn neon() -> bool { + #[cfg(all(target_arch = "aarch64", feature = "std"))] + // This should always be true on aarch64 + return std::arch::is_aarch64_feature_detected!("neon"); + #[cfg(any(not(target_arch = "aarch64"), not(feature = "std")))] + return false; +} + +const TILE_SIZE: usize = 64; + +/// Used to define the color swizzle order. Some backends require Rgba and others require Bgra. The renderer swizzles +/// textures as they are loaded so they can later be rasterized directly onto the frame buffer. +#[derive(Copy, Clone, Default)] +pub enum ColorFieldOrder { + #[default] + Rgba, + Bgra, +} + +/// Software render backend for egui. +pub struct EguiSoftwareRender { + textures: HashMap, + cached_primitives: HashMap, + tiles_dim: [usize; 2], + dirty_tiles: Vec, + target_size: Vec2, + prims_updated_this_frame: usize, + output_field_order: ColorFieldOrder, + canvas: Canvas, + redraw_everything_this_frame: bool, + convert_tris_to_rects: bool, + allow_raster_opt: bool, + cacheing_enabled: bool, + simd_impl: AvailableImpl, + #[cfg(feature = "raster_stats")] + pub stats: RasterStats, +} + +impl EguiSoftwareRender { + /// # Arguments + /// * `output_field_order` - egui textures and vertex colors will be swizzled before rendering to match the desired + /// output buffer order. + pub fn new(output_field_order: ColorFieldOrder) -> Self { + EguiSoftwareRender { + textures: Default::default(), + cached_primitives: Default::default(), + tiles_dim: Default::default(), + dirty_tiles: Default::default(), + target_size: Default::default(), + prims_updated_this_frame: Default::default(), + output_field_order, + canvas: Default::default(), + redraw_everything_this_frame: Default::default(), + convert_tris_to_rects: true, + allow_raster_opt: true, + cacheing_enabled: true, + simd_impl: Default::default(), + #[cfg(feature = "raster_stats")] + stats: Default::default(), + } + } + + /// If true: attempts to optimize by converting suitable triangle pairs into rectangles for faster rendering. + /// Things *should* look the same with this set to `true` while rendering faster. + pub fn with_convert_tris_to_rects(mut self, set: bool) -> Self { + self.convert_tris_to_rects = set; + self + } + + /// If false: Rasterize everything with triangles, always calculate vertex colors, uvs, use bilinear + /// everywhere, etc... Things *should* look the same with this set to `true` while rendering faster. + pub fn with_allow_raster_opt(mut self, set: bool) -> Self { + self.allow_raster_opt = set; + self + } + + /// If true: rasterized ClippedPrimitives are cached and rendered to an intermediate tiled canvas. That canvas is + /// then rendered over the frame buffer. If false ClippedPrimitives are rendered directly to the frame buffer. + /// Rendering without caching is much slower and primarily intended for testing. + pub fn with_caching(mut self, set: bool) -> Self { + self.cacheing_enabled = set; + self + } + + /// Renders the given paint jobs to buffer_ref. Alternatively, when using caching + /// EguiSoftwareRender::render_to_canvas() and subsequently EguiSoftwareRender::blit_canvas_to_buffer() can be run + /// separately so that the primary rendering in render_to_canvas() can happen without a lock on the frame buffer. + /// + /// + /// # Arguments + /// * `paint_jobs` - List of `egui::ClippedPrimitive` from egui to be rendered. + /// * `textures_delta` - The change in egui textures since last frame + /// * `pixels_per_point` - The number of physical pixels for each logical point. + pub fn render( + &mut self, + buffer_ref: &mut BufferMutRef, + paint_jobs: &[egui::ClippedPrimitive], + textures_delta: &egui::TexturesDelta, + pixels_per_point: f32, + ) { + if self.cacheing_enabled { + self.render_to_canvas( + buffer_ref.width, + buffer_ref.height, + paint_jobs, + textures_delta, + pixels_per_point, + ); + self.blit_canvas_to_buffer(buffer_ref); + } else { + self.render_direct(buffer_ref, paint_jobs, textures_delta, pixels_per_point); + } + } + + /// Renders the given paint jobs to an intermediate canvas. + /// + /// # Arguments + /// * `width` - The width of the output in pixels. Must match final output buffer dimensions. + /// * `height` - The height of the output in pixels. Must match final output buffer dimensions. + /// * `paint_jobs` - List of `egui::ClippedPrimitive` from egui to be rendered. + /// * `textures_delta` - The change in egui textures since last frame + /// * `pixels_per_point` - The number of physical pixels for each logical point. + pub fn render_to_canvas( + &mut self, + width: usize, + height: usize, + paint_jobs: &[egui::ClippedPrimitive], + textures_delta: &egui::TexturesDelta, + pixels_per_point: f32, + ) { + // TODO: need to deal with user textures. Either make the fields of EguiUserTextures pub or need to come up with a replacement. + + #[cfg(feature = "raster_stats")] + self.stats.clear(); + + assert!(width > 0); + assert!(height > 0); + assert!(pixels_per_point > 0.0); + + self.redraw_everything_this_frame = self.canvas.resize(width, height); + // Upstream never resets this, so every frame after the first + // recomposited its dirty tiles anyway; the reset plus the dirty-tile + // test below keep that, and say it. + self.prims_updated_this_frame = 0; + + if self.redraw_everything_this_frame { + self.canvas.clear(); + self.cached_primitives.clear(); + } + + for prim in self.cached_primitives.values_mut() { + prim.seen_this_frame = false; + } + + self.target_size = vec2(width as f32, height as f32); + self.tiles_dim = [width.div_ceil(TILE_SIZE), height.div_ceil(TILE_SIZE)]; + + self.set_textures(textures_delta); + + self.render_prims_to_cache(paint_jobs, pixels_per_point); + + self.update_dirty_tiles(); + self.clear_unused_cached_prims(); + + let mut reinit_canvas = self.redraw_everything_this_frame; + + if self.prims_updated_this_frame > 0 + || self + .dirty_tiles + .iter() + .any(|mask| mask & Self::DIRTY_TILE_MASK != 0) + { + // Only the dirty tiles are recomposited. + reinit_canvas = true; + } + + if reinit_canvas { + self.update_canvas_from_cached(); + } + + self.free_textures(textures_delta); + } + + /// Draw canvas alpha over given buffer. + /// Only run after EguiSoftwareRender::render_to_canvas(), or use EguiSoftwareRender::render() to run both. + /// Only writes tile regions that contain pixels that are not fully transparent. + pub fn blit_canvas_to_buffer(&mut self, buffer: &mut BufferMutRef) { + #[cfg(feature = "raster_stats")] + let start = std::time::Instant::now(); + + // Simple tile-less version + // buffer.data.iter_mut().zip(self.canvas.iter()).for_each(|(pixel, src)| { + // *pixel = egui_blend_u8(*src, *pixel); + // }); + + if self.canvas.data.is_empty() { + #[cfg(feature = "log")] + log::error!( + "Canvas not initialized, call EguiSoftwareRender::blit_canvas_to_buffer() only after EguiSoftwareRender::render_to_canvas()" + ); + return; + } + + let width = self.canvas.width; + let height = self.canvas.height; + assert_eq!(self.canvas.data.len(), width * height); + assert_eq!(buffer.data.len(), width * height); + + let tiles_x = self.tiles_dim[0]; + + #[cfg(feature = "rayon")] + { + use rayon::{ + iter::{IndexedParallelIterator, ParallelIterator}, + slice::ParallelSliceMut, + }; + // blit rows of tiles in parallel + + let width = buffer.width; + let px_per_row_of_tiles = width * TILE_SIZE; + + buffer + .data + .par_chunks_mut(px_per_row_of_tiles) + .enumerate() + .for_each(|(tile_row, tile_height_row)| { + let height = tile_height_row.len() / width; // Might be less than TILE_SIZE + let buffer_tile_row = &mut BufferMutRef::new(tile_height_row, width, height); + + for (tile_idx, &mask) in self.dirty_tiles.iter().enumerate() { + if mask & Self::OCCUPIED_TILE_MASK == 0 { + continue; + } + + let tile_y = tile_idx / tiles_x; + if tile_y != tile_row { + continue; + } + + let tile_x = tile_idx % tiles_x; + + let x_start = tile_x * TILE_SIZE; + let y_start = 0; + let x_end = (x_start + TILE_SIZE).min(width); + let y_end = TILE_SIZE.min(height); + + let canvas_row_offset = tile_row * TILE_SIZE; + + dispatch_simd_impl!(self.simd_impl, |simd_impl| self.blit_tile( + simd_impl, + buffer_tile_row, + x_start, + y_start, + x_end, + y_end, + canvas_row_offset, + )); + } + }); + } + #[cfg(not(feature = "rayon"))] + { + for (tile_idx, &mask) in self.dirty_tiles.iter().enumerate() { + if mask & Self::OCCUPIED_TILE_MASK == 0 { + continue; + } + + let tile_x = tile_idx % tiles_x; + let tile_y = tile_idx / tiles_x; + + let x_start = tile_x * TILE_SIZE; + let y_start = tile_y * TILE_SIZE; + let x_end = (x_start + TILE_SIZE).min(width); + let y_end = (y_start + TILE_SIZE).min(height); + + dispatch_simd_impl!(self.simd_impl, |simd_impl| self + .blit_tile(simd_impl, buffer, x_start, y_start, x_end, y_end, 0)); + } + } + + #[cfg(feature = "raster_stats")] + { + self.stats.blit_canvas_to_buffer = start.elapsed().as_secs_f32(); + } + } + + /// Copies the canvas into `buffer`, which must still hold the previous + /// frame unless `full` is set: only the tiles recomposited by the last + /// `render_to_canvas` are written. Returns the written regions as + /// `[x, y, width, height]`, one per run of adjacent dirty tiles in a tile + /// row, for a damage-aware present. + /// + /// Added for the accshift spike. Unlike `blit_canvas_to_buffer` this + /// overwrites instead of blending, so the buffer needs no clear: the + /// canvas already holds the final premultiplied pixels over transparent. + pub fn copy_canvas_to_buffer(&self, buffer: &mut BufferMutRef, full: bool) -> Vec<[usize; 4]> { + let width = self.canvas.width; + let height = self.canvas.height; + assert_eq!(buffer.data.len(), width * height); + assert_eq!(self.canvas.data.len(), width * height); + if full { + buffer.data.copy_from_slice(&self.canvas.data); + return vec![[0, 0, width, height]]; + } + let [tiles_x, tiles_y] = self.tiles_dim; + let dirty = |tx: usize, ty: usize| { + self.dirty_tiles + .get(tx + ty * tiles_x) + .is_some_and(|mask| mask & Self::DIRTY_TILE_MASK != 0) + }; + let mut rects = Vec::new(); + for ty in 0..tiles_y { + let mut tx = 0; + while tx < tiles_x { + if !dirty(tx, ty) { + tx += 1; + continue; + } + let run_start = tx; + while tx < tiles_x && dirty(tx, ty) { + tx += 1; + } + let x0 = run_start * TILE_SIZE; + let x1 = (tx * TILE_SIZE).min(width); + let y0 = ty * TILE_SIZE; + let y1 = (y0 + TILE_SIZE).min(height); + for y in y0..y1 { + let row = y * width + x0..y * width + x1; + buffer.data[row.clone()].copy_from_slice(&self.canvas.data[row]); + } + rects.push([x0, y0, x1 - x0, y1 - y0]); + } + } + rects + } + + #[allow(clippy::too_many_arguments)] + fn blit_tile( + &self, + simd_impl: impl SelectedImpl, + buffer: &mut BufferMutRef, + x_start: usize, + y_start: usize, + x_end: usize, + y_end: usize, + canvas_row_offset: usize, + ) { + for y in y_start..y_end { + let src_row = self.canvas.get_span(x_start, x_end, y + canvas_row_offset); + let dst_row = &mut buffer.get_mut_span(x_start, x_end, y); + simd_impl.egui_blend_u8_slice(src_row, dst_row); + } + } + + /// Render directly into buffer without cache. This is much slower and mainly intended for testing. + fn render_direct( + &mut self, + direct_draw_buffer: &mut BufferMutRef, + paint_jobs: &[egui::ClippedPrimitive], + textures_delta: &egui::TexturesDelta, + pixels_per_point: f32, + ) { + #[cfg(feature = "raster_stats")] + self.stats.clear(); + + self.set_textures(textures_delta); + + self.target_size = vec2( + direct_draw_buffer.width as f32, + direct_draw_buffer.height as f32, + ); + + #[cfg(feature = "raster_stats")] + let start = std::time::Instant::now(); + + for egui::ClippedPrimitive { + clip_rect, + primitive, + } in paint_jobs.iter() + { + let input_mesh = match primitive { + egui::epaint::Primitive::Mesh(input_mesh) => input_mesh, + egui::epaint::Primitive::Callback(_) => { + #[cfg(feature = "log")] + log::error!("egui::epaint::Primitive::Callback(PaintCallback) not supported"); + continue; + } + }; + + if input_mesh.vertices.is_empty() || input_mesh.indices.is_empty() { + continue; + } + + let clip_rect = egui::Rect { + min: clip_rect.min * pixels_per_point, + max: clip_rect.max * pixels_per_point, + }; + + let mut mesh_min = egui::Vec2::splat(f32::MAX); + let mut mesh_max = egui::Vec2::splat(-f32::MAX); + + let px_mesh = + self.prepare_px_mesh(pixels_per_point, input_mesh, &mut mesh_min, &mut mesh_max); + + let mesh_size = mesh_max - mesh_min; + if mesh_size.x > 8192.0 || mesh_size.y > 8192.0 { + // TODO it occasionally tries to make giant buffers in the first couple frames initially for some reason. + continue; + } + + let render_in_low_precision = mesh_size.x > 4096.0 || mesh_size.y > 4096.0; + if render_in_low_precision { + draw_egui_mesh::<2>( + self.simd_impl, + &self.textures, + direct_draw_buffer, + &clip_rect, + &px_mesh, + Vec2::ZERO, + self.allow_raster_opt, + self.convert_tris_to_rects, + #[cfg(all(feature = "raster_stats", not(feature = "rayon")))] + &mut self.stats, + ); + } else { + draw_egui_mesh::<8>( + self.simd_impl, + &self.textures, + direct_draw_buffer, + &clip_rect, + &px_mesh, + Vec2::ZERO, + self.allow_raster_opt, + self.convert_tris_to_rects, + #[cfg(all(feature = "raster_stats", not(feature = "rayon")))] + &mut self.stats, + ); + } + } + + #[cfg(feature = "raster_stats")] + { + self.stats.render_direct = start.elapsed().as_secs_f32(); + } + self.free_textures(textures_delta); + } + + fn prepare_px_mesh( + &self, + pixels_per_point: f32, + mesh: &egui::Mesh, + mesh_min: &mut Vec2, + mesh_max: &mut Vec2, + ) -> Mesh { + let mut px_mesh = mesh.clone(); + + for v in px_mesh.vertices.iter_mut() { + v.pos *= pixels_per_point; + + match self.output_field_order { + ColorFieldOrder::Rgba => (), // egui uses rgba + ColorFieldOrder::Bgra => { + let d = swizzle_rgba_bgra(v.color.to_array()); + v.color = Color32::from_rgba_premultiplied(d[0], d[1], d[2], d[3]); + } + } + + *mesh_min = mesh_min.min(v.pos.to_vec2()); + *mesh_max = mesh_max.max(v.pos.to_vec2()); + } + + // Make all the tris face forward (ccw) to simplify rasterization. + // TODO perf: could store the area so it's not recomputed later. + for i in (0..px_mesh.indices.len()).step_by(3) { + let i0 = px_mesh.indices[i] as usize; + let i1 = px_mesh.indices[i + 1] as usize; + let i2 = px_mesh.indices[i + 2] as usize; + let v0 = px_mesh.vertices[i0]; + let v1 = px_mesh.vertices[i1]; + let v2 = px_mesh.vertices[i2]; + let area = egui_orient2df(&v0.pos, &v1.pos, &v2.pos); + if area < 0.0 { + px_mesh.indices.swap(i + 1, i + 2); + } + } + px_mesh + } + + fn render_prims_to_cache( + &mut self, + paint_jobs: &[egui::ClippedPrimitive], + pixels_per_point: f32, + ) { + #[cfg(feature = "raster_stats")] + let start = std::time::Instant::now(); + + struct CacheReuse { + seen_this_frame: bool, + z_order: usize, + min_x: usize, + min_y: usize, + rendered_this_frame: bool, + hash: u32, + } + + enum CacheUpdate { + CacheReuse(CacheReuse), + New(u32, CachedPrimitive), + None, + } + + // Render paint jobs in parallel + #[cfg(feature = "rayon")] + use rayon::iter::{IndexedParallelIterator, IntoParallelRefIterator, ParallelIterator}; + #[cfg(feature = "rayon")] + let iter = paint_jobs.par_iter().enumerate(); + + #[cfg(not(feature = "rayon"))] + let iter = paint_jobs.iter().enumerate(); + + let updates: Vec = iter + .map( + |( + prim_idx, + egui::ClippedPrimitive { + clip_rect, + primitive, + }, + )| { + let input_mesh = match primitive { + egui::epaint::Primitive::Mesh(input_mesh) => input_mesh, + egui::epaint::Primitive::Callback(_) => { + #[cfg(feature = "log")] + log::error!( + "egui::epaint::Primitive::Callback(PaintCallback) not supported" + ); + return CacheUpdate::None; + } + }; + + if input_mesh.vertices.is_empty() || input_mesh.indices.is_empty() { + return CacheUpdate::None; + } + + let clip_rect = egui::Rect { + min: clip_rect.min * pixels_per_point, + max: clip_rect.max * pixels_per_point, + }; + + let mut mesh_min = egui::Vec2::splat(f32::MAX); + let mut mesh_max = egui::Vec2::splat(-f32::MAX); + + let px_mesh = self.prepare_px_mesh( + pixels_per_point, + input_mesh, + &mut mesh_min, + &mut mesh_max, + ); + + let cropped_min = mesh_min.max(clip_rect.min.to_vec2()); + let cropped_max = mesh_max.min(clip_rect.max.to_vec2()); + let clip_rect = egui::Rect { + min: Pos2::ZERO, + max: (cropped_max - cropped_min).to_pos2() + egui::Vec2::splat(0.5), + }; + + let hash = { + let mut hasher = Hash32::new_fnv(); + + hasher.hash_wrap(clip_rect.min.x.to_bits()); + hasher.hash_wrap(clip_rect.min.y.to_bits()); + hasher.hash_wrap(clip_rect.max.x.to_bits()); + hasher.hash_wrap(clip_rect.max.y.to_bits()); + hasher.hash_wrap(match px_mesh.texture_id { + egui::TextureId::Managed(id) => id as u32, + egui::TextureId::User(id) => id as u32 + 9358476, + }); + for ind in &px_mesh.indices { + let v = px_mesh.vertices[*ind as usize]; + + // Tried to do this to avoid full redraws when moving a window but it was resulting in some + // meshes to be matches incorrectly in the ui gradient portion of the egui color test: + //let pos = v.pos - cropped_min; + + // It's much faster to not wrap for every field. General ordering should be sufficiently preserved. + hasher.hash(v.pos.x.to_bits()); + hasher.hash(v.pos.y.to_bits()); + hasher.hash(v.uv.x.to_bits()); + hasher.hash(v.uv.y.to_bits()); + hasher.hash(u32::from_le_bytes(v.color.to_array())); + hasher.fnv_wrap(); + } + hasher.hash_wrap(px_mesh.indices.len() as u32); + hasher.finalize() + }; + + if self.cached_primitives.contains_key(&hash) { + CacheUpdate::CacheReuse(CacheReuse { + hash, + seen_this_frame: true, + z_order: prim_idx, + min_x: cropped_min.x as usize, + min_y: cropped_min.y as usize, + rendered_this_frame: false, + }) + } else { + let min_x = cropped_min.x as usize; + let min_y = cropped_min.y as usize; + let width = (cropped_max.x.ceil() as usize) - min_x; + let height = (cropped_max.y.ceil() as usize) - min_y; + + if width > 8192 || height > 8192 { + // TODO it occasionally tries to make giant buffers in the first couple frames initially for some reason. + return CacheUpdate::None; + } + + if width == 0 || height == 0 { + return CacheUpdate::None; + } + + let render_in_low_precision = width > 4096 || height > 4096; + + let mut prim = CachedPrimitive::new(min_x, min_y, width, height, prim_idx); + let mut buffer_ref = BufferMutRef { + data: &mut prim.buffer, + width, + height, + width_extent: width - 1, + height_extent: height - 1, + }; + + let offset = -vec2(cropped_min.x.floor(), cropped_min.y.floor()); + + if render_in_low_precision { + // Seems to not be an issue in direct draw? Seems like a bug. + draw_egui_mesh::<2>( + self.simd_impl, + &self.textures, + &mut buffer_ref, + &clip_rect, + &px_mesh, + offset, + self.allow_raster_opt, + self.convert_tris_to_rects, + #[cfg(all(feature = "raster_stats", not(feature = "rayon")))] + &mut self.stats, + ); + } else { + draw_egui_mesh::<8>( + self.simd_impl, + &self.textures, + &mut buffer_ref, + &clip_rect, + &px_mesh, + offset, + self.allow_raster_opt, + self.convert_tris_to_rects, + #[cfg(all(feature = "raster_stats", not(feature = "rayon")))] + &mut self.stats, + ); + } + prim.update_occupied_tiles(self.tiles_dim[0], self.tiles_dim[1]); + CacheUpdate::New(hash, prim) + } + }, + ) + .collect::>(); + + updates.into_iter().for_each(|update| match update { + CacheUpdate::CacheReuse(cache_reuse) => { + if let Some(cached_primitive) = self.cached_primitives.get_mut(&cache_reuse.hash) { + cached_primitive.seen_this_frame = cache_reuse.seen_this_frame; + cached_primitive.z_order = cache_reuse.z_order; + cached_primitive.min_x = cache_reuse.min_x; + cached_primitive.min_y = cache_reuse.min_y; + cached_primitive.rendered_this_frame = cache_reuse.rendered_this_frame; + } + } + CacheUpdate::New(hash, prim) => { + self.prims_updated_this_frame += 1; + self.cached_primitives.insert(hash, prim); + } + CacheUpdate::None => (), + }); + + #[cfg(feature = "raster_stats")] + { + self.stats.render_prims_to_cache = start.elapsed().as_secs_f32(); + } + } + + fn update_canvas_from_cached(&mut self) { + let simd_impl = self.simd_impl; + #[cfg(feature = "raster_stats")] + let start = std::time::Instant::now(); + + let mut sorted_prim_cache = self.cached_primitives.values().collect::>(); + sorted_prim_cache.sort_unstable_by_key(|prim| prim.z_order); + + #[allow(unused_mut)] + let mut canvas = + BufferMutRef::new(&mut self.canvas.data, self.canvas.width, self.canvas.height); + + #[cfg(feature = "rayon")] + { + use rayon::{ + iter::{IndexedParallelIterator, ParallelIterator}, + slice::ParallelSliceMut, + }; + // composite rows of tiles in parallel + + let full_height = self.canvas.height; + + let width = canvas.width; + let px_per_row_of_tiles = width * TILE_SIZE; + + canvas + .data + .par_chunks_mut(px_per_row_of_tiles) + .enumerate() + .for_each(|(tile_row, tile_height_row)| { + let height = tile_height_row.len() / width; // Might be less than TILE_SIZE + let canvas_tile_row = &mut BufferMutRef::new(tile_height_row, width, height); + + let dirty_tile_row_start = tile_row * self.tiles_dim[0]; + let dirty_tile_row_end = dirty_tile_row_start + self.tiles_dim[0]; + + self.dirty_tiles + .iter() + .enumerate() + .skip(dirty_tile_row_start) + .take(dirty_tile_row_end) + .filter(|(_, mask)| **mask & Self::DIRTY_TILE_MASK != 0) + .map(|(idx, _)| idx) + .for_each(|tile_idx| { + let tile_y = tile_idx / self.tiles_dim[0]; + + if tile_y != tile_row { + return; + } + let canvas_row_offset = tile_row * TILE_SIZE; + + let tile_x = tile_idx % self.tiles_dim[0]; + + update_canvas_tile( + simd_impl, + &sorted_prim_cache, + canvas_tile_row, + tile_x, + tile_y, + full_height, + canvas_row_offset, + ); + }); + }); + } + + #[cfg(not(feature = "rayon"))] + { + for tile_idx in self + .dirty_tiles + .iter() + .enumerate() + .filter(|(_, mask)| **mask & Self::DIRTY_TILE_MASK != 0) + .map(|(idx, _)| idx) + { + let tile_x = tile_idx % self.tiles_dim[0]; + let tile_y = tile_idx / self.tiles_dim[0]; + let full_height = canvas.height; + update_canvas_tile( + simd_impl, + &sorted_prim_cache, + &mut canvas, + tile_x, + tile_y, + full_height, + 0, + ); + } + } + #[cfg(feature = "raster_stats")] + { + self.stats.update_canvas_from_cached = start.elapsed().as_secs_f32(); + } + } + + fn clear_unused_cached_prims(&mut self) { + self.cached_primitives + .retain(|_hash, prim| prim.seen_this_frame); + } + + const DIRTY_TILE_MASK: u8 = 0b00000001; + const OCCUPIED_TILE_MASK: u8 = 0b000000010; + fn update_dirty_tiles(&mut self) { + #[cfg(feature = "raster_stats")] + let start = std::time::Instant::now(); + self.dirty_tiles + .resize(self.tiles_dim[0] * self.tiles_dim[1], 0); + self.dirty_tiles.fill(0); + for prim in self.cached_primitives.values() { + for tile in &prim.occupied_tiles { + let mask = + &mut self.dirty_tiles[tile[0] as usize + tile[1] as usize * self.tiles_dim[0]]; + if !prim.seen_this_frame || prim.rendered_this_frame { + *mask |= Self::DIRTY_TILE_MASK; + } + *mask |= Self::OCCUPIED_TILE_MASK; + } + } + #[cfg(feature = "raster_stats")] + { + self.stats.update_dirty_tiles = start.elapsed().as_secs_f32(); + } + } + + fn set_textures(&mut self, textures_delta: &egui::TexturesDelta) { + #[cfg(feature = "raster_stats")] + let start = std::time::Instant::now(); + // egui 0.36 queues several deltas per texture, applied in order. + for (id, delta) in textures_delta + .set + .iter() + .flat_map(|(id, deltas)| deltas.iter().map(move |delta| (id, delta))) + { + if delta.options.magnification != delta.options.minification { + // Would need helper lanes to impl? + #[cfg(feature = "log")] + log::warn!( + "TextureOptions magnification and minification not matching is unsupported." + ); + } + let pixels = match &delta.image { + egui::ImageData::Color(image) => { + assert_eq!(image.width() * image.height(), image.pixels.len()); + Cow::Borrowed(&image.pixels) + } + }; + let size = delta.image.size(); + if let Some(pos) = delta.pos { + if let Some(texture) = self.textures.get_mut(id) { + for y in 0..size[1] { + for x in 0..size[0] { + let src_pos = x + y * size[0]; + let dest_pos = (x + pos[0]) + (y + pos[1]) * texture.width; + texture.data[dest_pos] = match self.output_field_order { + ColorFieldOrder::Rgba => pixels[src_pos].to_array(), + ColorFieldOrder::Bgra => { + swizzle_rgba_bgra(pixels[src_pos].to_array()) + } + }; + } + } + } + } else { + let new_texture = + EguiTexture::new(self.output_field_order, delta.options, size, &pixels); + + self.textures.insert(*id, new_texture); + } + } + #[cfg(feature = "raster_stats")] + { + self.stats.set_textures = start.elapsed().as_secs_f32(); + } + } + + fn free_textures(&mut self, textures_delta: &egui::TexturesDelta) { + for free in &textures_delta.free { + self.textures.remove(free); + } + } +} + +fn update_canvas_tile( + simd_impl: AvailableImpl, + sorted_prim_cache: &[&CachedPrimitive], + canvas: &mut BufferMutRef, + tile_x: usize, + tile_y: usize, + full_height: usize, + canvas_row_offset: usize, +) { + let tile_x_start = tile_x * TILE_SIZE; + let tile_y_start = tile_y * TILE_SIZE; + let tile_x_end = (tile_x_start + TILE_SIZE).min(canvas.width); + let tile_y_end = (tile_y_start + TILE_SIZE).min(full_height); + + // clear tile + for y in (tile_y_start - canvas_row_offset)..(tile_y_end - canvas_row_offset) { + let row_start = y * canvas.width; + let start = row_start + tile_x_start; + let end = row_start + tile_x_end; + canvas.data[start..end].fill([0; 4]); + } + + let tile_n = [tile_x as u16, tile_y as u16]; + // redraw cached prims on tile + for prim in sorted_prim_cache { + if !prim.occupied_tiles.contains(&tile_n) { + continue; + } + + let mut min_x = prim.min_x; + let mut min_y = prim.min_y; + let mut max_x = min_x + prim.width; + let mut max_y = min_y + prim.height; + + min_x = min_x.max(tile_x_start).min(canvas.width); + min_y = min_y + .max(tile_y_start) + .min(canvas.height + canvas_row_offset); + max_x = max_x.min(tile_x_end).min(canvas.width); + max_y = max_y.min(tile_y_end).min(canvas.height + canvas_row_offset); + + let prim_buf = prim.get_buffer_ref(); + + if max_x <= min_x || max_y <= min_y { + continue; + } + let prim_x_min = (min_x - prim.min_x).min(prim_buf.width); + let prim_x_max = (max_x - prim.min_x).min(prim_buf.width); + + let get_ranges = |y: usize| -> (Range, Range) { + let canvas_row_start = (y - canvas_row_offset).min(canvas.height) * canvas.width; + let canvas_start = canvas_row_start + min_x; + let canvas_end = canvas_row_start + max_x; + + let prim_y = (y - prim.min_y).min(prim_buf.height); + let prim_row_start = prim_y * prim_buf.width; + let prim_start = prim_row_start + prim_x_min; + let prim_end = prim_row_start + prim_x_max; + + (canvas_start..canvas_end, prim_start..prim_end) + }; + + dispatch_simd_impl!(simd_impl, |simd_impl| { + for y in min_y..max_y { + let (canvas_slice, prim_slice) = get_ranges(y); + let src_row = &prim_buf.data[prim_slice]; + let dst_row = &mut canvas.data[canvas_slice]; + simd_impl.egui_blend_u8_slice(src_row, dst_row); + } + }); + } +} + +#[derive(Default)] +struct Canvas { + data: Vec<[u8; 4]>, + width: usize, + height: usize, + width_extent: usize, + height_extent: usize, +} + +impl Canvas { + fn clear(&mut self) { + self.data.iter_mut().for_each(|p| *p = [0; 4]); + } + + /// returns true if wasn't already the given size + fn resize(&mut self, width: usize, height: usize) -> bool { + if width != self.width || height != self.height { + self.data.resize(width * height, [0; 4]); + self.width = width; + self.height = height; + self.width_extent = width - 1; + self.height_extent = height - 1; + true + } else { + false + } + } + + #[inline(always)] + pub fn get_range(&self, start: usize, end: usize, y: usize) -> Range { + let row_start = y * self.width; + let start = row_start + start; + let end = row_start + end; + start..end + } + + #[inline(always)] + pub fn get_span(&self, start: usize, end: usize, y: usize) -> &[[u8; 4]] { + let range = self.get_range(start, end, y); + &self.data[range] + } +} + +/// A region of cached rendered image data that corresponds to a ClippedPrimitive. +pub struct CachedPrimitive { + buffer: Vec<[u8; 4]>, + min_x: usize, + min_y: usize, + width: usize, + height: usize, + z_order: usize, + seen_this_frame: bool, + rendered_this_frame: bool, + occupied_tiles: Vec<[u16; 2]>, +} + +impl CachedPrimitive { + fn get_buffer_ref(&self) -> BufferRef<'_> { + BufferRef { + data: &self.buffer, + width: self.width, + height: self.height, + width_extent: self.width - 1, + height_extent: self.height - 1, + } + } + + fn new(min_x: usize, min_y: usize, width: usize, height: usize, z_order: usize) -> Self { + CachedPrimitive { + buffer: vec![[0; 4]; width * height], + min_x, + min_y, + width, + height, + z_order, + seen_this_frame: true, + rendered_this_frame: true, + occupied_tiles: Vec::with_capacity(64), + } + } + + fn update_occupied_tiles(&mut self, tiles_wide: usize, tiles_tall: usize) { + // list which tiles contain a pixel with that isn't fully transparent (also containing not color info) + self.occupied_tiles.clear(); + let max_x = self.min_x + self.width; + let max_y = self.min_y + self.height; + let first_tile_x = (self.min_x / TILE_SIZE).min(tiles_wide); + let first_tile_y = (self.min_y / TILE_SIZE).min(tiles_tall); + let last_tile_x = max_x.div_ceil(TILE_SIZE).min(tiles_wide); + let last_tile_y = max_y.div_ceil(TILE_SIZE).min(tiles_tall); + + for tile_y in first_tile_y..last_tile_y { + let mut px_start_y = (tile_y * TILE_SIZE).max(self.min_y); + let mut px_end_y = (px_start_y + TILE_SIZE).min(max_y); + px_start_y -= self.min_y; + px_end_y -= self.min_y; + for tile_x in first_tile_x..last_tile_x { + let mut px_start_x = (tile_x * TILE_SIZE).max(self.min_x); + let mut px_end_x = (px_start_x + TILE_SIZE).min(max_x); + px_start_x -= self.min_x; + px_end_x -= self.min_x; + + 'px_outer: for y in px_start_y..px_end_y { + for x in px_start_x..px_end_x { + // Purposefully panicing when out of bounds. If it's out of bounds then the math is wrong and + // the tile is not being calculated correctly. + if u32::from_le_bytes(self.buffer[x + y * self.width]) > 0 { + self.occupied_tiles.push([tile_x as u16, tile_y as u16]); + break 'px_outer; + } + } + } + } + } + } +} + +/// A mutable reference to a slice of image buffer data and corresponding image extents. +#[derive(Debug)] +pub struct BufferMutRef<'a> { + pub data: &'a mut [[u8; 4]], + pub width: usize, + pub height: usize, + pub width_extent: usize, + pub height_extent: usize, +} + +impl<'a> BufferMutRef<'a> { + pub fn new(data: &'a mut [[u8; 4]], width: usize, height: usize) -> Self { + assert!(width > 0); + assert!(height > 0); + BufferMutRef { + data, + width, + height, + width_extent: width - 1, + height_extent: height - 1, + } + } + + #[inline(always)] + pub fn get_range(&self, start: usize, end: usize, y: usize) -> Range { + let row_start = y * self.width; + let start = row_start + start; + let end = row_start + end; + start..end + } + + #[inline(always)] + pub fn get_mut_span(&mut self, start: usize, end: usize, y: usize) -> &mut [[u8; 4]] { + let range = self.get_range(start, end, y); + &mut self.data[range] + } + + #[inline(always)] + pub fn get_mut_clamped(&mut self, x: usize, y: usize) -> &mut [u8; 4] { + let x = x.min(self.width_extent); + let y = y.min(self.height_extent); + &mut self.data[x + y * self.width] + } + + #[inline(always)] + pub fn get_mut(&mut self, x: usize, y: usize) -> &mut [u8; 4] { + &mut self.data[x + y * self.width] + } +} + +/// A reference to a slice of image buffer data and corresponding image extents. +#[derive(Debug)] +pub struct BufferRef<'a> { + pub data: &'a [[u8; 4]], + pub width: usize, + pub height: usize, + pub width_extent: usize, + pub height_extent: usize, +} + +impl<'a> BufferRef<'a> { + #[inline(always)] + pub fn get_ref_clamped(&self, x: usize, y: usize) -> &[u8; 4] { + let x = x.min(self.width_extent); + let y = y.min(self.height_extent); + &self.data[x + y * self.width] + } + + #[inline(always)] + pub fn get_ref(&self, x: usize, y: usize) -> &[u8; 4] { + &self.data[x + y * self.width] + } +} diff --git a/crates/accshift-egui/vendor/egui_software_backend/src/math/i64vec2.rs b/crates/accshift-egui/vendor/egui_software_backend/src/math/i64vec2.rs new file mode 100644 index 0000000..5f17a1e --- /dev/null +++ b/crates/accshift-egui/vendor/egui_software_backend/src/math/i64vec2.rs @@ -0,0 +1,92 @@ +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +#[repr(C, align(16))] +pub struct I64Vec2 { + pub x: i64, + pub y: i64, +} + +impl I64Vec2 { + #[inline(always)] + pub fn from_vec2(v: egui::Vec2) -> Self { + i64vec2(v.x as i64, v.y as i64) + } + + #[inline(always)] + pub fn min(self, v: Self) -> Self { + i64vec2(self.x.min(v.x), self.y.min(v.y)) + } + + #[inline(always)] + pub fn max(self, v: Self) -> Self { + i64vec2(self.x.max(v.x), self.y.max(v.y)) + } +} + +impl core::ops::Mul for I64Vec2 { + type Output = Self; + #[inline(always)] + fn mul(self, rhs: Self) -> Self { + i64vec2(self.x * rhs.x, self.y * rhs.y) + } +} + +impl core::ops::Add for I64Vec2 { + type Output = Self; + #[inline(always)] + fn add(self, rhs: Self) -> Self { + i64vec2(self.x + rhs.x, self.y + rhs.y) + } +} + +impl core::ops::Sub for I64Vec2 { + type Output = Self; + #[inline(always)] + fn sub(self, rhs: Self) -> Self { + i64vec2(self.x - rhs.x, self.y - rhs.y) + } +} + +impl core::ops::Mul for I64Vec2 { + type Output = Self; + #[inline(always)] + fn mul(self, rhs: i64) -> Self { + i64vec2(self.x * rhs, self.y * rhs) + } +} + +impl core::ops::Add for I64Vec2 { + type Output = Self; + #[inline(always)] + fn add(self, rhs: i64) -> Self { + i64vec2(self.x + rhs, self.y + rhs) + } +} + +impl core::ops::Sub for I64Vec2 { + type Output = Self; + #[inline(always)] + fn sub(self, rhs: i64) -> Self { + i64vec2(self.x - rhs, self.y - rhs) + } +} + +impl core::ops::Shr for I64Vec2 { + type Output = Self; + #[inline(always)] + fn shr(self, rhs: u32) -> Self { + i64vec2(self.x >> rhs, self.y >> rhs) + } +} + +impl core::ops::Shl for I64Vec2 { + type Output = Self; + #[inline(always)] + fn shl(self, rhs: u32) -> Self { + i64vec2(self.x << rhs, self.y << rhs) + } +} + +#[inline(always)] +pub fn i64vec2(x: i64, y: i64) -> I64Vec2 { + I64Vec2 { x, y } +} diff --git a/crates/accshift-egui/vendor/egui_software_backend/src/math/mod.rs b/crates/accshift-egui/vendor/egui_software_backend/src/math/mod.rs new file mode 100644 index 0000000..7926b83 --- /dev/null +++ b/crates/accshift-egui/vendor/egui_software_backend/src/math/mod.rs @@ -0,0 +1,3 @@ +// These exist just to avoid bringing in glam or similar since egui doesn't. +pub(crate) mod i64vec2; +pub(crate) mod vec4; diff --git a/crates/accshift-egui/vendor/egui_software_backend/src/math/vec4.rs b/crates/accshift-egui/vendor/egui_software_backend/src/math/vec4.rs new file mode 100644 index 0000000..7acbbf4 --- /dev/null +++ b/crates/accshift-egui/vendor/egui_software_backend/src/math/vec4.rs @@ -0,0 +1,601 @@ +// This exists just to avoid bringing in glam or similar since egui doesn't. +// Might be able to use ecolor::rgba::Rgba with some additions. +// Based on emath Vec2 + +use core::fmt; +use core::ops::{Add, AddAssign, Div, DivAssign, Mul, MulAssign, Neg, Sub, SubAssign}; + +#[repr(C, align(16))] +#[derive(Clone, Copy, Default, PartialEq)] +pub struct Vec4 { + pub x: f32, + pub y: f32, + pub z: f32, + pub w: f32, +} + +/// `vec4(x, y, z, w) == Vec4::new(x, y, z, w)` +#[inline(always)] +pub const fn vec4(x: f32, y: f32, z: f32, w: f32) -> Vec4 { + Vec4 { x, y, z, w } +} + +// ---------------------------------------------------------------------------- +// Compatibility and convenience conversions to and from [f32; 4]: + +impl From<[f32; 4]> for Vec4 { + #[inline(always)] + fn from(v: [f32; 4]) -> Self { + Self { + x: v[0], + y: v[1], + z: v[2], + w: v[3], + } + } +} + +impl From<&[f32; 4]> for Vec4 { + #[inline(always)] + fn from(v: &[f32; 4]) -> Self { + Self { + x: v[0], + y: v[1], + z: v[2], + w: v[3], + } + } +} + +impl From for [f32; 4] { + #[inline(always)] + fn from(v: Vec4) -> Self { + [v.x, v.y, v.z, v.w] + } +} + +impl From<&Vec4> for [f32; 4] { + #[inline(always)] + fn from(v: &Vec4) -> Self { + [v.x, v.y, v.z, v.w] + } +} + +// ---------------------------------------------------------------------------- +// Compatibility and convenience conversions to and from (f32, f32, f32, f32): + +impl From<(f32, f32, f32, f32)> for Vec4 { + #[inline(always)] + fn from(v: (f32, f32, f32, f32)) -> Self { + Self { + x: v.0, + y: v.1, + z: v.2, + w: v.3, + } + } +} + +impl From<&(f32, f32, f32, f32)> for Vec4 { + #[inline(always)] + fn from(v: &(f32, f32, f32, f32)) -> Self { + Self { + x: v.0, + y: v.1, + z: v.2, + w: v.3, + } + } +} + +impl From for (f32, f32, f32, f32) { + #[inline(always)] + fn from(v: Vec4) -> Self { + (v.x, v.y, v.z, v.w) + } +} + +impl From<&Vec4> for (f32, f32, f32, f32) { + #[inline(always)] + fn from(v: &Vec4) -> Self { + (v.x, v.y, v.z, v.w) + } +} + +// ---------------------------------------------------------------------------- + +impl Vec4 { + pub const ZERO: Self = Self { + x: 0.0, + y: 0.0, + z: 0.0, + w: 0.0, + }; + pub const ONE: Self = Self { + x: 1.0, + y: 1.0, + z: 1.0, + w: 1.0, + }; + pub const INFINITY: Self = Self::splat(f32::INFINITY); + pub const NAN: Self = Self::splat(f32::NAN); + + #[inline(always)] + pub const fn new(x: f32, y: f32, z: f32, w: f32) -> Self { + Self { x, y, z, w } + } + + /// Set `x`, `y`, `z`, `w` to the same value. + #[inline(always)] + pub const fn splat(v: f32) -> Self { + Self { + x: v, + y: v, + z: v, + w: v, + } + } + + /// Safe normalize: returns zero if input is zero. + #[must_use] + #[inline(always)] + pub fn normalized(self) -> Self { + let len = self.length(); + if len <= 0.0 { self } else { self / len } + } + + /// Checks if `self` has length `1.0` up to a precision of `1e-6`. + #[inline(always)] + pub fn is_normalized(self) -> bool { + (self.length_sq() - 1.0).abs() < 2e-6 + } + + #[inline(always)] + pub fn length(self) -> f32 { + self.length_sq().sqrt() + } + + #[inline(always)] + pub fn length_sq(self) -> f32 { + self.x * self.x + self.y * self.y + self.z * self.z + self.w * self.w + } + + #[must_use] + #[inline(always)] + pub fn floor(self) -> Self { + vec4( + self.x.floor(), + self.y.floor(), + self.z.floor(), + self.w.floor(), + ) + } + + #[must_use] + #[inline(always)] + pub fn round(self) -> Self { + vec4( + self.x.round(), + self.y.round(), + self.z.round(), + self.w.round(), + ) + } + + #[must_use] + #[inline(always)] + pub fn ceil(self) -> Self { + vec4(self.x.ceil(), self.y.ceil(), self.z.ceil(), self.w.ceil()) + } + + #[must_use] + #[inline] + pub fn abs(self) -> Self { + vec4(self.x.abs(), self.y.abs(), self.z.abs(), self.w.abs()) + } + + /// True if all members are also finite. + #[inline(always)] + pub fn is_finite(self) -> bool { + self.x.is_finite() && self.y.is_finite() && self.z.is_finite() && self.w.is_finite() + } + + /// True if any member is NaN. + #[inline(always)] + pub fn any_nan(self) -> bool { + self.x.is_nan() || self.y.is_nan() || self.z.is_nan() || self.w.is_nan() + } + + #[must_use] + #[inline] + pub fn min(self, other: Self) -> Self { + vec4( + self.x.min(other.x), + self.y.min(other.y), + self.z.min(other.z), + self.w.min(other.w), + ) + } + + #[must_use] + #[inline] + pub fn max(self, other: Self) -> Self { + vec4( + self.x.max(other.x), + self.y.max(other.y), + self.z.max(other.z), + self.w.max(other.w), + ) + } + + /// The dot-product of two vectors. + #[inline] + pub fn dot(self, other: Self) -> f32 { + self.x * other.x + self.y * other.y + self.z * other.z + self.w * other.w + } + + /// Returns the minimum of all elements. + #[must_use] + #[inline(always)] + pub fn min_elem(self) -> f32 { + self.x.min(self.y).min(self.z).min(self.w) + } + + /// Returns the maximum of all elements. + #[inline(always)] + #[must_use] + pub fn max_elem(self) -> f32 { + self.x.max(self.y).max(self.z).max(self.w) + } + + #[must_use] + #[inline] + pub fn clamp(self, min: Self, max: Self) -> Self { + Self { + x: self.x.clamp(min.x, max.x), + y: self.y.clamp(min.y, max.y), + z: self.z.clamp(min.z, max.z), + w: self.w.clamp(min.w, max.w), + } + } +} + +impl core::ops::Index for Vec4 { + type Output = f32; + + #[inline(always)] + fn index(&self, index: usize) -> &f32 { + match index { + 0 => &self.x, + 1 => &self.y, + 2 => &self.z, + 3 => &self.w, + _ => panic!("Vec4 index out of bounds: {index}"), + } + } +} + +impl core::ops::IndexMut for Vec4 { + #[inline(always)] + fn index_mut(&mut self, index: usize) -> &mut f32 { + match index { + 0 => &mut self.x, + 1 => &mut self.y, + 2 => &mut self.z, + 3 => &mut self.w, + _ => panic!("Vec4 index out of bounds: {index}"), + } + } +} + +impl Eq for Vec4 {} + +impl Neg for Vec4 { + type Output = Self; + + #[inline(always)] + fn neg(self) -> Self { + vec4(-self.x, -self.y, -self.z, -self.w) + } +} + +impl AddAssign for Vec4 { + #[inline(always)] + fn add_assign(&mut self, rhs: Self) { + *self = Self { + x: self.x + rhs.x, + y: self.y + rhs.y, + z: self.z + rhs.z, + w: self.w + rhs.w, + }; + } +} + +impl SubAssign for Vec4 { + #[inline(always)] + fn sub_assign(&mut self, rhs: Self) { + *self = Self { + x: self.x - rhs.x, + y: self.y - rhs.y, + z: self.z - rhs.z, + w: self.w - rhs.w, + }; + } +} + +impl Add for Vec4 { + type Output = Self; + + #[inline(always)] + fn add(self, rhs: Self) -> Self { + Self { + x: self.x + rhs.x, + y: self.y + rhs.y, + z: self.z + rhs.z, + w: self.w + rhs.w, + } + } +} + +impl Add for &Vec4 { + type Output = Vec4; + + #[inline(always)] + fn add(self, rhs: Vec4) -> Vec4 { + Vec4 { + x: self.x + rhs.x, + y: self.y + rhs.y, + z: self.z + rhs.z, + w: self.w + rhs.w, + } + } +} + +impl Add<&Vec4> for Vec4 { + type Output = Vec4; + + #[inline(always)] + fn add(self, rhs: &Vec4) -> Vec4 { + Vec4 { + x: self.x + rhs.x, + y: self.y + rhs.y, + z: self.z + rhs.z, + w: self.w + rhs.w, + } + } +} + +impl Sub for Vec4 { + type Output = Self; + + #[inline(always)] + fn sub(self, rhs: Self) -> Self { + Self { + x: self.x - rhs.x, + y: self.y - rhs.y, + z: self.z - rhs.z, + w: self.w - rhs.w, + } + } +} + +/// Element-wise multiplication +impl Mul for Vec4 { + type Output = Self; + + #[inline(always)] + fn mul(self, vec: Self) -> Self { + Self { + x: self.x * vec.x, + y: self.y * vec.y, + z: self.z * vec.z, + w: self.w * vec.w, + } + } +} + +/// Element-wise division +impl Div for Vec4 { + type Output = Self; + + #[inline(always)] + fn div(self, rhs: Self) -> Self { + Self { + x: self.x / rhs.x, + y: self.y / rhs.y, + z: self.z / rhs.z, + w: self.w / rhs.w, + } + } +} + +impl MulAssign for Vec4 { + #[inline(always)] + fn mul_assign(&mut self, rhs: f32) { + self.x *= rhs; + self.y *= rhs; + self.z *= rhs; + self.w *= rhs; + } +} + +impl DivAssign for Vec4 { + #[inline(always)] + fn div_assign(&mut self, rhs: f32) { + self.x /= rhs; + self.y /= rhs; + self.z /= rhs; + self.w /= rhs; + } +} + +impl Mul for Vec4 { + type Output = Self; + + #[inline(always)] + fn mul(self, factor: f32) -> Self { + Self { + x: self.x * factor, + y: self.y * factor, + z: self.z * factor, + w: self.w * factor, + } + } +} + +impl Mul for &Vec4 { + type Output = Vec4; + + #[inline(always)] + fn mul(self, factor: f32) -> Vec4 { + Vec4 { + x: self.x * factor, + y: self.y * factor, + z: self.z * factor, + w: self.w * factor, + } + } +} + +impl Add for Vec4 { + type Output = Self; + + #[inline(always)] + fn add(self, factor: f32) -> Self { + Self { + x: self.x + factor, + y: self.y + factor, + z: self.z + factor, + w: self.w + factor, + } + } +} + +impl Add for &Vec4 { + type Output = Vec4; + + #[inline(always)] + fn add(self, factor: f32) -> Vec4 { + Vec4 { + x: self.x + factor, + y: self.y + factor, + z: self.z + factor, + w: self.w + factor, + } + } +} + +impl Mul for f32 { + type Output = Vec4; + + #[inline(always)] + fn mul(self, vec: Vec4) -> Vec4 { + Vec4 { + x: self * vec.x, + y: self * vec.y, + z: self * vec.z, + w: self * vec.w, + } + } +} + +impl Div for Vec4 { + type Output = Self; + + #[inline(always)] + fn div(self, factor: f32) -> Self { + Self { + x: self.x / factor, + y: self.y / factor, + z: self.z / factor, + w: self.w / factor, + } + } +} + +impl fmt::Debug for Vec4 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(precision) = f.precision() { + write!( + f, + "[{1:.0$} {2:.0$} {3:.0$} {4:.0$}]", + precision, self.x, self.y, self.z, self.w + ) + } else { + write!( + f, + "[{:.1} {:.1} {:.1} {:.1}]", + self.x, self.y, self.z, self.w + ) + } + } +} + +impl fmt::Display for Vec4 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("[")?; + self.x.fmt(f)?; + f.write_str(" ")?; + self.y.fmt(f)?; + f.write_str(" ")?; + self.z.fmt(f)?; + f.write_str(" ")?; + self.w.fmt(f)?; + f.write_str("]")?; + Ok(()) + } +} + +#[cfg(test)] +mod test { + use super::*; + + macro_rules! almost_eq { + ($left: expr, $right: expr) => { + let left = $left; + let right = $right; + assert!((left - right).abs() < 1e-6, "{} != {}", left, right); + }; + } + + #[test] + fn test_vec4() { + let mut assignment = vec4(1.0, 2.0, 3.0, 4.0); + assignment += vec4(3.0, 4.0, 5.0, 6.0); + assert_eq!(assignment, vec4(4.0, 6.0, 8.0, 10.0)); + + let mut assignment = vec4(4.0, 6.0, 8.0, 10.0); + assignment -= vec4(1.0, 2.0, 3.0, 4.0); + assert_eq!(assignment, vec4(3.0, 4.0, 5.0, 6.0)); + + let mut assignment = vec4(1.0, 2.0, 3.0, 4.0); + assignment *= 2.0; + assert_eq!(assignment, vec4(2.0, 4.0, 6.0, 8.0)); + + let mut assignment = vec4(2.0, 4.0, 6.0, 8.0); + assignment /= 2.0; + assert_eq!(assignment, vec4(1.0, 2.0, 3.0, 4.0)); + } + + #[test] + fn test_vec4_normalized() { + fn generate_spiral(n: usize, start: Vec4, end: Vec4) -> impl Iterator { + let angle_step = 2.0 * core::f32::consts::PI / n as f32; + let radius_step = (end.length() - start.length()) / n as f32; + + (0..n).map(move |i| { + let angle = i as f32 * angle_step; + let radius = start.length() + i as f32 * radius_step; + let x = radius * angle.cos(); + let y = radius * angle.sin(); + vec4(x, y, x, y) + }) + } + + for v in generate_spiral(40, Vec4::splat(0.1), Vec4::splat(2.0)) { + let vn = v.normalized(); + almost_eq!(vn.length(), 1.0); + assert!(vn.is_normalized()); + } + } +} diff --git a/crates/accshift-egui/vendor/egui_software_backend/src/raster/bary.rs b/crates/accshift-egui/vendor/egui_software_backend/src/raster/bary.rs new file mode 100644 index 0000000..c584168 --- /dev/null +++ b/crates/accshift-egui/vendor/egui_software_backend/src/raster/bary.rs @@ -0,0 +1,259 @@ +use core::ops::{Add, AddAssign, Mul, Sub}; + +use egui::Vec2; + +use crate::math::i64vec2::{I64Vec2, i64vec2}; + +// https://fgiesen.wordpress.com/2013/02/17/optimizing-sw-occlusion-culling-index/ +// https://jtsorlinis.github.io/rendering-tutorial/ +// https://www.cs.cornell.edu/courses/cs4620/2011fa/lectures/16rasterizationWeb.pdf + +/// ss for screen space (unit is screen pixel) +/// sp for subpixel space (unit fraction of screen pixel) +/// Here for reference for raster without using span. +#[allow(unused)] +pub fn raster_tri( + ss_bounds: [I64Vec2; 2], + ss_tri: &[Vec2; 3], + // ss_x, ss_y + mut raster: impl FnMut(i64, i64), +) { + let Some((ss_min, ss_max, _sp_inv_area, mut stepper)) = + SingleStepper::from_ss_tri_backface_cull::(ss_bounds, ss_tri) + else { + return; + }; + + for ss_y in ss_min.y..ss_max.y { + stepper.row_start(); + for ss_x in ss_min.x..ss_max.x { + if stepper.inside_tri_pos_area() { + raster(ss_x, ss_y); + } + stepper.col_step(); + } + stepper.row_step(); + } +} + +#[inline(always)] +pub fn is_top_left(a: &I64Vec2, b: &I64Vec2) -> bool { + let dy = b.y - a.y; + (dy > 0) || (dy == 0 && (b.x - a.x) < 0) +} + +pub struct SingleStepper { + /// Edge subpixel barycentric steps. (edges are: 12, 20, 01) + pub step: [SingleStep; 3], + /// Edge subpixel space barycentric weights. Divide by subpixel area for barycentric factors. + pub sp_weight: [i64; 3], + /// Edge subpixel bias for watertightness. These are applied on SingleStepper::row_start(). If you're using + /// something else you'll need to apply them to sp_weight. + pub bias: [i8; 3], +} + +impl SingleStepper { + /// For the given subpixel resolution, calculate the screen space bounds, subpixel inverse area, and subpixel Stepper. + /// returns: ss_min, ss_max, sp_inv_area, stepper + pub fn from_ss_tri_backface_cull( + ss_bounds: [I64Vec2; 2], + ss_tri: &[Vec2; 3], + ) -> Option<(I64Vec2, I64Vec2, f32, SingleStepper)> { + let subpix_bits = SUBPIX_BITS as u32; + let subpix: i64 = 1 << subpix_bits; + let subpix_half: i64 = subpix >> 1; + let fsubpix = subpix as f32; + + let sp0 = I64Vec2::from_vec2(ss_tri[0] * fsubpix); + let sp1 = I64Vec2::from_vec2(ss_tri[1] * fsubpix); + let sp2 = I64Vec2::from_vec2(ss_tri[2] * fsubpix); + + let sp_area = orient2d(&sp0, &sp1, &sp2); + + if sp_area <= 0 { + return None; + } + + let sp_min = sp0.min(sp1).min(sp2); + let sp_max = sp0.max(sp1).max(sp2); + + let ss_min = ((sp_min - subpix_half) >> subpix_bits) + .max(ss_bounds[0]) + .min(ss_bounds[1]); + let ss_max = ((sp_max + subpix_half) >> subpix_bits) + .max(ss_bounds[0]) + .min(ss_bounds[1]); + + let sp_min_p = ss_min * subpix + subpix_half; + let ss_size = ss_max - ss_min; + + if ss_size.x <= 0 || ss_size.y <= 0 { + return None; + } + + let sp_inv_area = 1.0 / (sp_area as f32); + + let stepper = SingleStepper::new(&sp0, &sp1, &sp2, &sp_min_p, subpix); + + Some((ss_min, ss_max, sp_inv_area, stepper)) + } + + pub fn new( + sp0: &I64Vec2, + sp1: &I64Vec2, + sp2: &I64Vec2, + sp_min_p: &I64Vec2, + subpix: i64, + ) -> Self { + SingleStepper { + step: [ + SingleStep::new(sp1, sp2, sp_min_p, subpix), + SingleStep::new(sp2, sp0, sp_min_p, subpix), + SingleStep::new(sp0, sp1, sp_min_p, subpix), + ], + sp_weight: [0; 3], + bias: [ + if is_top_left(sp1, sp2) { 0 } else { -1 }, + if is_top_left(sp2, sp0) { 0 } else { -1 }, + if is_top_left(sp0, sp1) { 0 } else { -1 }, + ], + } + } + + #[inline(always)] + /// Check if the current step of the stepper is inside the triangle. + pub fn inside_tri_pos_area(&self) -> bool { + // None w are negative + let m = + (self.sp_weight[0] as u64) | (self.sp_weight[1] as u64) | (self.sp_weight[2] as u64); + (m & 0x8000_0000_0000_0000) == 0 + } + + #[inline(always)] + /// Take one step along y to the next row. + pub fn row_step(&mut self) { + self.step[0].row += self.step[0].step.y; + self.step[1].row += self.step[1].step.y; + self.step[2].row += self.step[2].step.y; + } + + #[inline(always)] + /// Take one step along x to the next column. + pub fn col_step(&mut self) { + self.sp_weight[0] += self.step[0].step.x; + self.sp_weight[1] += self.step[1].step.x; + self.sp_weight[2] += self.step[2].step.x; + } + + #[inline(always)] + /// Initialize weights to the start of the current row and apply the bias. + pub fn row_start(&mut self) { + self.sp_weight[0] = self.step[0].row + self.bias[0] as i64; + self.sp_weight[1] = self.step[1].row + self.bias[1] as i64; + self.sp_weight[2] = self.step[2].row + self.bias[2] as i64; + } + + /// Generate stepper for float attribute (like vertex UVs or vertex colors) + /// Depends on SingleStepper's initial state. Make sure to run before using SingleStepper::row_step() or + /// SingleStepper::col_step() + pub fn attr(&self, attr: &[T; 3], sp_inv_area: f32) -> AttributeStepper + where + T: Copy + Add + Sub + AddAssign + Mul, + { + // Get attribute value of top left + let w0 = self.step[0].row; + let w1 = self.step[1].row; + let (b0, b1, b2) = subpixel_bary_to_factor(w0, w1, sp_inv_area); + let attr_tl = attr[0] * b0 + attr[1] * b1 + attr[2] * b2; + + // Get attribute value of one x step right from top left + let w0sx = w0 + self.step[0].step.x; + let w1sx = w1 + self.step[1].step.x; + let (b0, b1, b2) = subpixel_bary_to_factor(w0sx, w1sx, sp_inv_area); + let attr_1x = attr[0] * b0 + attr[1] * b1 + attr[2] * b2; + + // Get attribute value of one y step down from top left + let w0sy = w0 + self.step[0].step.y; + let w1sy = w1 + self.step[1].step.y; + let (b0, b1, b2) = subpixel_bary_to_factor(w0sy, w1sy, sp_inv_area); + let attr_1y = attr[0] * b0 + attr[1] * b1 + attr[2] * b2; + + // Compute deltas + let step_x = attr_1x - attr_tl; + let step_y = attr_1y - attr_tl; + + let row = attr_tl; + + AttributeStepper { + step_x, + step_y, + row, + attr: attr_tl, + } + } +} + +pub struct SingleStep { + pub step: I64Vec2, + pub row: i64, +} + +impl SingleStep { + #[inline(always)] + pub fn new(sp0: &I64Vec2, sp1: &I64Vec2, sp_min_p: &I64Vec2, subpix: i64) -> Self { + let a = sp0.y - sp1.y; + let b = sp1.x - sp0.x; + let c = (sp0.x) * (sp1.y) - (sp0.y) * (sp1.x); + + Self { + step: i64vec2(a * subpix, b * subpix), + row: a * sp_min_p.x + b * sp_min_p.y + c, + } + } +} + +#[inline(always)] +/// Returns twice the signed area of triangle abc +pub fn orient2d(a: &I64Vec2, b: &I64Vec2, c: &I64Vec2) -> i64 { + (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x) +} + +#[inline(always)] +/// Convert subpixel space barycentric weights to barycentric factors (0..1) +pub fn subpixel_bary_to_factor(sp_w0: i64, sp_w1: i64, inv_area: f32) -> (f32, f32, f32) { + let b0 = (sp_w0 as f32) * inv_area; + let b1 = (sp_w1 as f32) * inv_area; + let b2 = 1.0 - b0 - b1; + (b0, b1, b2) +} + +#[derive(Default)] +pub struct AttributeStepper +where + T: Copy + Add + Sub + AddAssign + Mul, +{ + pub step_x: T, + pub step_y: T, + pub row: T, + pub attr: T, +} + +impl AttributeStepper +where + T: Copy + Add + Sub + AddAssign + Mul, +{ + #[inline(always)] + pub fn row_step(&mut self) { + self.row += self.step_y; + } + + #[inline(always)] + pub fn col_step(&mut self) { + self.attr += self.step_x; + } + + #[inline(always)] + pub fn row_start(&mut self) { + self.attr = self.row; + } +} diff --git a/crates/accshift-egui/vendor/egui_software_backend/src/raster/mod.rs b/crates/accshift-egui/vendor/egui_software_backend/src/raster/mod.rs new file mode 100644 index 0000000..38a6855 --- /dev/null +++ b/crates/accshift-egui/vendor/egui_software_backend/src/raster/mod.rs @@ -0,0 +1,4 @@ +pub(crate) mod bary; +pub(crate) mod rect; +pub(crate) mod span; +pub(crate) mod tri; diff --git a/crates/accshift-egui/vendor/egui_software_backend/src/raster/rect.rs b/crates/accshift-egui/vendor/egui_software_backend/src/raster/rect.rs new file mode 100644 index 0000000..e34d207 --- /dev/null +++ b/crates/accshift-egui/vendor/egui_software_backend/src/raster/rect.rs @@ -0,0 +1,132 @@ +use constify::constify; +use egui::{Vec2, vec2}; + +use crate::{BufferMutRef, color::SelectedImpl, egui_texture::EguiTexture, render::DrawInfo}; + +#[constify] +pub fn draw_rect( + simd_impl: impl SelectedImpl, + buffer: &mut BufferMutRef, + texture: &EguiTexture, + draw: &DrawInfo, + #[constify] vert_col_vary: bool, + #[constify] vert_uvs_vary: bool, + #[constify] alpha_blend: bool, +) { + simd_impl.dispatch(|simd_impl| { + draw_rect_impl::( + simd_impl, buffer, texture, draw, + ) + }) +} + +#[inline] +#[allow(non_upper_case_globals)] +fn draw_rect_impl( + simd_impl: impl SelectedImpl, + buffer: &mut BufferMutRef, + texture: &EguiTexture, + draw: &DrawInfo, +) { + let const_tri_color_u8x4 = draw.const_tri_color_u8x4; + let clip_bounds = &draw.clip_bounds; + let tri_min = draw.tri_min; + let tri_max = draw.tri_max; + let min_x = ((tri_min.x + 0.5) as i64).max(clip_bounds[0].x); + let min_y = ((tri_min.y + 0.5) as i64).max(clip_bounds[0].y); + let max_x = ((tri_max.x + 0.5) as i64).min(clip_bounds[1].x); + let max_y = ((tri_max.y + 0.5) as i64).min(clip_bounds[1].y); + + let sizex = max_x - min_x; + let sizey = max_y - min_y; + + if sizex <= 0 || sizey <= 0 { + return; + } + + let min_x = min_x as usize; + let min_y = min_y as usize; + let max_x = max_x as usize; + let max_y = max_y as usize; + + if !vert_uvs_vary && !vert_col_vary { + for y in min_y..max_y { + if alpha_blend { + simd_impl.egui_blend_u8_slice_one_src( + const_tri_color_u8x4, + buffer.get_mut_span(min_x, max_x, y), + ); + } else { + buffer + .get_mut_span(min_x, max_x, y) + .fill(const_tri_color_u8x4); + } + } + } else { + // TODO could another level of constify make this cleaner (const use_nearest_sampling?) + let mut min_uv = vec2( + draw.uv[0].x.min(draw.uv[1].x).min(draw.uv[2].x), + draw.uv[0].y.min(draw.uv[1].y).min(draw.uv[2].y), + ); + let max_uv = vec2( + draw.uv[0].x.max(draw.uv[1].x).max(draw.uv[2].x), + draw.uv[0].y.max(draw.uv[1].y).max(draw.uv[2].y), + ); + + let uv_step = (max_uv - min_uv) / (tri_max - tri_min); + min_uv += uv_step * (vec2(min_x as f32, min_y as f32) - tri_min).max(Vec2::ZERO); // Offset to account for clip + min_uv += uv_step * 0.5; // Raster at pixel centers + + let ts_min = min_uv * texture.fsize; + let ts_max = max_uv * texture.fsize; + + let use_nearest_sampling = { + let ss_step = uv_step * texture.fsize; + let dist_from_px_center = (ts_min - ts_min.floor() - vec2(0.5, 0.5)).abs(); + let steps_off_from_1px = (ss_step - Vec2::ONE).abs(); + let eps = 0.01; + let steps_are_1px = steps_off_from_1px.x < eps && steps_off_from_1px.y < eps; + let start_on_texture_px_center = + dist_from_px_center.x < eps && dist_from_px_center.y < eps; + + steps_are_1px && start_on_texture_px_center + }; + + let no_texture_wrap_or_overflow = + (ts_max.x as usize) < texture.width && (ts_max.y as usize) < texture.height; + + if use_nearest_sampling && no_texture_wrap_or_overflow { + // Can just directly blend the texture over the dst buffer, no need to sample with uv + let min_uv = [ts_min.x as i32, ts_min.y as i32]; + for (tex_row, y) in (min_uv[1]..).zip(min_y..max_y) { + let tex_row_start = tex_row as usize * texture.width; + let tex_start = tex_row_start + min_uv[0] as usize; + let tex_end = tex_start + max_x - min_x; + + let dst = &mut buffer.get_mut_span(min_x, max_x, y); + let src = &texture.data[tex_start..tex_end]; + + simd_impl.egui_blend_u8_slice_tinted(src, draw.const_vert_color_u8x4, dst); + } + } else { + // There's overflow or can't use nearest. So we need to do full sample. + // TODO perf: there could be a situation where we could nearest sample but with wrapping/clipping. + // We don't have a fast path for that and are falling back to the more general solution below. + // This would be (use_nearest_sampling && !no_texture_wrap_or_overflow) which at least never occurs in the + // demo as far as I can tell + let mut uv = min_uv; + for y in min_y..max_y { + uv.x = min_uv.x; + let buf_y = y * buffer.width; + for x in min_x..max_x { + let tex_color = texture.sample_bilinear(uv); + let pixel = &mut buffer.data[x + buf_y]; + let src = simd_impl.unorm_mult4x4(draw.const_vert_color_u8x4, tex_color); + *pixel = simd_impl.egui_blend_u8(src, *pixel); + uv.x += uv_step.x; + } + uv.y += uv_step.y; + } + } + }; +} diff --git a/crates/accshift-egui/vendor/egui_software_backend/src/raster/span.rs b/crates/accshift-egui/vendor/egui_software_backend/src/raster/span.rs new file mode 100644 index 0000000..f5b669e --- /dev/null +++ b/crates/accshift-egui/vendor/egui_software_backend/src/raster/span.rs @@ -0,0 +1,66 @@ +use crate::raster::bary::SingleStepper; +use strength_reduce::StrengthReducedU64; + +/// Returns Some((start, end)) for the current row in the triangle. The end points are defined within the aabb of the +/// triangle so add ss_min.x to each to get the screen space coordinate. Returns None if there is no span intersecting +/// this row. +pub fn calc_row_span( + stepper: &SingleStepper, + max_cols: i64, + step_rcp: &[StrengthReducedU64; 3], +) -> Option<(i64, i64)> { + let mut start = 0; + let mut end = u64::MAX; + let w = &stepper.sp_weight; + let sx = [ + stepper.step[0].step.x, + stepper.step[1].step.x, + stepper.step[2].step.x, + ]; + + for i in 0..3 { + let w = w[i]; + let sx = sx[i]; + let step_rcp = step_rcp[i]; + if w < 0 { + if sx > 0 { + // unoptimized: start = start.max(div_ceil(-w, sx)); + start = start.max(div_ceil_sr(-w as u64, step_rcp)); + } else { + return None; + } + } else if sx < 0 { + // unoptimized: end = end.min(div_floor(w, -sx) + 1); + // sx < 0 and stepper.step_x_rcp is already abs so no need to negate + // w >= 0 due to if condition above so cast to u64 is fine + end = end.min((w as u64 / step_rcp) + 1); + } + } + + end = end.clamp(0, max_cols as u64); + + if start >= end { + return None; + } + Some((start as i64, end as i64)) +} + +// based on https://github.com/rust-lang/rust/blob/e4b521903b3b1a671e26a70b9475bcff385767e5/library/core/src/num/int_macros.rs#L3238 +#[inline(always)] +pub fn div_ceil_sr(lhs: u64, rhs: StrengthReducedU64) -> u64 { + let (d, r) = StrengthReducedU64::div_rem(lhs, rhs); + + // When remainder is non-zero we have a.div_ceil(b) == 1 + a.div_floor(b), + // so we can re-use the algorithm from div_floor, just adding 1. + let correction = 1 + ((lhs ^ rhs.get()) >> (u64::BITS - 1)); + if r != 0 { d + correction } else { d } +} + +pub fn step_rcp(stepper: &SingleStepper) -> [StrengthReducedU64; 3] { + // max(1) is fine here since the element will already be skipped if step.x == 0 + [ + StrengthReducedU64::new(stepper.step[0].step.x.abs().max(1) as u64), + StrengthReducedU64::new(stepper.step[1].step.x.abs().max(1) as u64), + StrengthReducedU64::new(stepper.step[2].step.x.abs().max(1) as u64), + ] +} diff --git a/crates/accshift-egui/vendor/egui_software_backend/src/raster/tri.rs b/crates/accshift-egui/vendor/egui_software_backend/src/raster/tri.rs new file mode 100644 index 0000000..4e3e73d --- /dev/null +++ b/crates/accshift-egui/vendor/egui_software_backend/src/raster/tri.rs @@ -0,0 +1,142 @@ +use constify::constify; + +use crate::{ + BufferMutRef, + color::{SelectedImpl, vec4_to_u8x4}, + egui_texture::EguiTexture, + raster::{ + bary::SingleStepper, + span::{calc_row_span, step_rcp}, + }, + render::DrawInfo, +}; + +#[constify] +pub fn draw_tri( + simd_impl: impl SelectedImpl, + buffer: &mut BufferMutRef, + texture: &EguiTexture, + draw: &DrawInfo, + #[constify] vert_col_vary: bool, + #[constify] vert_uvs_vary: bool, + #[constify] alpha_blend: bool, +) { + simd_impl.dispatch(|simd_impl| { + draw_tri_impl::( + simd_impl, buffer, texture, draw, + ) + }) +} + +#[inline] +#[allow(non_upper_case_globals)] +fn draw_tri_impl< + const SUBPIX_BITS: i32, + const vert_col_vary: bool, + const vert_uvs_vary: bool, + const alpha_blend: bool, +>( + simd_impl: impl SelectedImpl, + buffer: &mut BufferMutRef, + texture: &EguiTexture, + draw: &DrawInfo, +) { + let Some((ss_min, ss_max, sp_inv_area, mut stepper)) = + SingleStepper::from_ss_tri_backface_cull::(draw.clip_bounds, &draw.ss_tri) + else { + return; + }; + + let step_rcp = step_rcp(&stepper); + + let mut vert_col_stepper = if vert_col_vary { + stepper.attr(&draw.colors, sp_inv_area) + } else { + Default::default() + }; + + let mut vert_uv_stepper = if vert_uvs_vary { + stepper.attr(&draw.uv, sp_inv_area) + } else { + Default::default() + }; + + let max_cols = ss_max.x - ss_min.x; + + for ss_y in ss_min.y..ss_max.y { + stepper.row_start(); + if vert_col_vary { + vert_col_stepper.row_start(); + } + if vert_uvs_vary { + vert_uv_stepper.row_start(); + } + + if let Some((start, end)) = calc_row_span(&stepper, max_cols, &step_rcp) { + if vert_col_vary { + vert_col_stepper.attr += vert_col_stepper.step_x * start as f32; + } + if vert_uvs_vary { + vert_uv_stepper.attr += vert_uv_stepper.step_x * start as f32; + } + let ss_start = (ss_min.x + start) as usize; + let ss_end = (ss_min.x + end) as usize; + + if alpha_blend && !vert_uvs_vary { + let dst = buffer.get_mut_span(ss_start, ss_end, ss_y as usize); + if vert_col_vary { + simd_impl.egui_blend_u8_slice_one_src_tinted_fn( + draw.const_tex_color_u8x4, + || { + let v = vec4_to_u8x4(&vert_col_stepper.attr); + vert_col_stepper.col_step(); + v + }, + dst, + ) + } else { + simd_impl.egui_blend_u8_slice_one_src(draw.const_tri_color_u8x4, dst) + } + } else { + for ss_x in ss_start..ss_end { + let src = if vert_uvs_vary || vert_col_vary { + let tex_color = if vert_uvs_vary { + texture.sample_bilinear(vert_uv_stepper.attr) + } else { + draw.const_tex_color_u8x4 + }; + let vert_color = if vert_col_vary { + vec4_to_u8x4(&vert_col_stepper.attr) + } else { + draw.const_vert_color_u8x4 + }; + crate::color::GenericImpl.unorm_mult4x4(vert_color, tex_color) + //^ `simd_impl.unorm_mult4x4` is slower at least in avx2 and sse4.1 + } else { + draw.const_tri_color_u8x4 + }; + let pixel = buffer.get_mut(ss_x, ss_y as usize); + *pixel = if alpha_blend { + simd_impl.egui_blend_u8(src, *pixel) + } else { + src + }; + if vert_col_vary { + vert_col_stepper.col_step(); + } + if vert_uvs_vary { + vert_uv_stepper.col_step(); + } + } + } + }; + + stepper.row_step(); + if vert_col_vary { + vert_col_stepper.row_step(); + } + if vert_uvs_vary { + vert_uv_stepper.row_step(); + } + } +} diff --git a/crates/accshift-egui/vendor/egui_software_backend/src/render.rs b/crates/accshift-egui/vendor/egui_software_backend/src/render.rs new file mode 100644 index 0000000..825b417 --- /dev/null +++ b/crates/accshift-egui/vendor/egui_software_backend/src/render.rs @@ -0,0 +1,322 @@ +#![allow(unsafe_code)] + +use crate::{ + BufferMutRef, EguiTexture, + color::{AvailableImpl, SelectedImpl, u8x4_to_vec4, vec4_to_u8x4}, + math::{ + i64vec2::{I64Vec2, i64vec2}, + vec4::Vec4, + }, + raster::{rect::draw_rect, tri::draw_tri}, +}; +use ahash::HashMap; +use egui::{Pos2, Vec2, epaint::Vertex, vec2}; + +#[allow(clippy::too_many_arguments)] +pub fn draw_egui_mesh( + simd_impl: AvailableImpl, + textures: &HashMap, + buffer: &mut BufferMutRef, + clip_rect: &egui::Rect, + mesh: &egui::Mesh, + vert_offset: Vec2, + allow_raster_opt: bool, + convert_tris_to_rects: bool, + #[cfg(all(feature = "raster_stats", not(feature = "rayon")))] + stats: &mut crate::stats::RasterStats, +) { + crate::dispatch_simd_impl!(simd_impl, |simd_impl| draw_egui_mesh_impl::( + simd_impl, + textures, + buffer, + clip_rect, + mesh, + vert_offset, + allow_raster_opt, + convert_tris_to_rects, + #[cfg(all(feature = "raster_stats", not(feature = "rayon")))] + stats, + )) +} + +#[allow(clippy::too_many_arguments)] +fn draw_egui_mesh_impl( + simd_impl: impl SelectedImpl, + textures: &HashMap, + buffer: &mut BufferMutRef, + clip_rect: &egui::Rect, + mesh: &egui::Mesh, + vert_offset: Vec2, + allow_raster_opt: bool, + convert_tris_to_rects: bool, + #[cfg(all(feature = "raster_stats", not(feature = "rayon")))] + stats: &mut crate::stats::RasterStats, +) { + if mesh.vertices.is_empty() || mesh.indices.is_empty() { + return; + } + + let Some(texture) = textures.get(&mesh.texture_id) else { + return; + }; + + let indices = &mesh.indices; + let vertices = &mesh.vertices; + + let clip_bounds = [ + i64vec2( + (clip_rect.min.x as i64).clamp(0, buffer.width as i64), + (clip_rect.min.y as i64).clamp(0, buffer.height as i64), + ), + i64vec2( + (clip_rect.max.x.ceil() as i64).clamp(0, buffer.width as i64), + (clip_rect.max.y.ceil() as i64).clamp(0, buffer.height as i64), + ), + ]; + + if clip_bounds[1].x - clip_bounds[0].x <= 0 || clip_bounds[1].y - clip_bounds[0].y <= 0 { + return; + } + + let mut i = 0; + // Get texture + while i < indices.len() { + let mut tri = [ + vertices[indices[i] as usize], + vertices[indices[i + 1] as usize], + vertices[indices[i + 2] as usize], + ]; + tri[0].pos += vert_offset; + tri[1].pos += vert_offset; + tri[2].pos += vert_offset; + + let tri_min = vec2( + tri[0].pos.x.min(tri[1].pos.x).min(tri[2].pos.x), + tri[0].pos.y.min(tri[1].pos.y).min(tri[2].pos.y), + ); + let tri_max = vec2( + tri[0].pos.x.max(tri[1].pos.x).max(tri[2].pos.x), + tri[0].pos.y.max(tri[1].pos.y).max(tri[2].pos.y), + ); + + let fsize = tri_max - tri_min; + if fsize.x <= 0.0 || fsize.y <= 0.0 { + i += 3; + continue; + } + + let color0_u8x4 = tri[0].color.to_array(); + let color1_u8x4 = tri[1].color.to_array(); + let color2_u8x4 = tri[2].color.to_array(); + + let mut draw = DrawInfo::new( + clip_bounds, + [ + u8x4_to_vec4(&color0_u8x4), + u8x4_to_vec4(&color1_u8x4), + u8x4_to_vec4(&color2_u8x4), + ], + [ + tri[0].pos.to_vec2(), + tri[1].pos.to_vec2(), + tri[2].pos.to_vec2(), + ], + [ + vec2(tri[0].uv.x, tri[0].uv.y), + vec2(tri[1].uv.x, tri[1].uv.y), + vec2(tri[2].uv.x, tri[2].uv.y), + ], + tri_min, + tri_max, + ); + + if !allow_raster_opt { + draw_tri::(simd_impl, buffer, texture, &draw, true, true, true); + i += 3; + continue; + } + + let mut vert_uvs_vary = !(draw.uv[0] == draw.uv[1] && draw.uv[0] == draw.uv[2]); + let mut vert_col_vary = !(color0_u8x4 == color1_u8x4 && color0_u8x4 == color2_u8x4); + let mut alpha_blend = true; + + if !vert_uvs_vary { + draw.const_tex_color_u8x4 = texture.sample_bilinear(draw.uv[0]); + draw.const_tex_color = u8x4_to_vec4(&draw.const_tex_color_u8x4); + } + + if !vert_col_vary { + draw.const_vert_color = draw.colors[0]; + draw.const_vert_color_u8x4 = color0_u8x4; + } + + if !vert_uvs_vary && !vert_col_vary { + let const_tri_color = draw.const_vert_color * draw.const_tex_color; + draw.const_tri_color_u8x4 = vec4_to_u8x4(&const_tri_color); + if draw.const_tri_color_u8x4[3] == 255 { + alpha_blend = false; + } + } + + if !vert_uvs_vary + && vert_col_vary + && draw.const_tex_color_u8x4[3] == 255 + && color0_u8x4[3] == 255 + && color1_u8x4[3] == 255 + && color2_u8x4[3] == 255 + { + alpha_blend = false; + } + + let find_rects = convert_tris_to_rects && !vert_col_vary && i + 6 < indices.len(); + let mut found_rect = false; + + if find_rects { + let mut tri2 = [ + vertices[indices[i + 3] as usize], + vertices[indices[i + 4] as usize], + vertices[indices[i + 5] as usize], + ]; + tri2[0].pos += vert_offset; + tri2[1].pos += vert_offset; + tri2[2].pos += vert_offset; + + found_rect = tri_verts_match_corners(tri_min, tri_max, tri, tri2); + + if found_rect { + let tri_area = egui_orient2df(&tri[0].pos, &tri[1].pos, &tri[2].pos).abs(); + let rect_area = (tri_max.x - tri_min.x) * (tri_max.y - tri_min.y); + let areas_match = (tri_area - rect_area).abs() < 0.5; + + if areas_match { + if rect_area.abs() < 0.25 { + i += 6; // Skip both tris + continue; // early out of rects smaller than quarter px + } + + if !vert_uvs_vary { + let tri2_uvs_match = tri[0].uv == tri2[0].uv + && tri[0].uv == tri2[1].uv + && tri[0].uv == tri2[2].uv; + vert_uvs_vary = vert_uvs_vary && tri2_uvs_match; + } + + if !vert_col_vary { + let tri2_colors_match = tri[0].color == tri2[0].color + && tri[0].color == tri2[1].color + && tri[0].color == tri2[2].color; + vert_col_vary = vert_col_vary && tri2_colors_match; + } + } else { + found_rect = false; + } + } + } + + let rect = found_rect && !vert_col_vary; // vert_col_vary not supported by rect render + + #[cfg(all(feature = "raster_stats", not(feature = "rayon")))] + stats.start_raster(); + if rect { + draw_rect( + simd_impl, + buffer, + texture, + &draw, + vert_col_vary, + vert_uvs_vary, + alpha_blend, + ); + + #[cfg(all(feature = "raster_stats", not(feature = "rayon")))] + stats.finish_rect(fsize, vert_uvs_vary, vert_col_vary, alpha_blend); + i += 6; + } else { + draw_tri::( + simd_impl, + buffer, + texture, + &draw, + vert_col_vary, + vert_uvs_vary, + alpha_blend, + ); + + #[cfg(all(feature = "raster_stats", not(feature = "rayon")))] + stats.finish_tri(fsize, vert_uvs_vary, vert_col_vary, alpha_blend); + i += 3; + } + } +} + +pub struct DrawInfo { + pub clip_bounds: [I64Vec2; 2], + pub colors: [Vec4; 3], + pub ss_tri: [Vec2; 3], + pub uv: [Vec2; 3], + pub tri_min: Vec2, + pub tri_max: Vec2, + pub const_tex_color: Vec4, + pub const_tex_color_u8x4: [u8; 4], + pub const_vert_color: Vec4, + pub const_vert_color_u8x4: [u8; 4], + pub const_tri_color_u8x4: [u8; 4], +} + +impl DrawInfo { + fn new( + clip_bounds: [I64Vec2; 2], + colors: [Vec4; 3], + ss_tri: [Vec2; 3], + uv: [Vec2; 3], + tri_min: Vec2, + tri_max: Vec2, + ) -> Self { + Self { + clip_bounds, + colors, + ss_tri, + uv, + tri_min, + tri_max, + const_tex_color: Vec4::ONE, + const_tex_color_u8x4: [255; 4], + const_vert_color: Vec4::ONE, + const_vert_color_u8x4: [255; 4], + const_tri_color_u8x4: [255; 4], + } + } +} + +#[inline(always)] +/// Returns twice the signed area of triangle abc +pub fn egui_orient2df(a: &Pos2, b: &Pos2, c: &Pos2) -> f32 { + (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x) +} + +fn tri_verts_match_corners( + tri_min: Vec2, + tri_max: Vec2, + tri: [Vertex; 3], + tri2: [Vertex; 3], +) -> bool { + #[inline(always)] + fn close(a: f32, b: f32) -> bool { + //(a - b).abs() <= 0.1 + a == b + } + + // https://github.com/emilk/imgui_software_renderer/blob/b5ae63a9e42eccf7db3bf64696761a53424c53dd/src/imgui_sw.cpp#L577 + (close(tri[0].pos.x, tri_min.x) || close(tri[0].pos.x, tri_max.x)) + && (close(tri[0].pos.y, tri_min.y) || close(tri[0].pos.y, tri_max.y)) + && (close(tri[1].pos.x, tri_min.x) || close(tri[1].pos.x, tri_max.x)) + && (close(tri[1].pos.y, tri_min.y) || close(tri[1].pos.y, tri_max.y)) + && (close(tri[2].pos.x, tri_min.x) || close(tri[2].pos.x, tri_max.x)) + && (close(tri[2].pos.y, tri_min.y) || close(tri[2].pos.y, tri_max.y)) + && (close(tri2[0].pos.x, tri_min.x) || close(tri2[0].pos.x, tri_max.x)) + && (close(tri2[0].pos.y, tri_min.y) || close(tri2[0].pos.y, tri_max.y)) + && (close(tri2[1].pos.x, tri_min.x) || close(tri2[1].pos.x, tri_max.x)) + && (close(tri2[1].pos.y, tri_min.y) || close(tri2[1].pos.y, tri_max.y)) + && (close(tri2[2].pos.x, tri_min.x) || close(tri2[2].pos.x, tri_max.x)) + && (close(tri2[2].pos.y, tri_min.y) || close(tri2[2].pos.y, tri_max.y)) +} diff --git a/crates/accshift-egui/vendor/egui_software_backend/src/stats.rs b/crates/accshift-egui/vendor/egui_software_backend/src/stats.rs new file mode 100644 index 0000000..ae1d9a5 --- /dev/null +++ b/crates/accshift-egui/vendor/egui_software_backend/src/stats.rs @@ -0,0 +1,258 @@ +use crate::alloc::string::ToString; +use ahash::HashMap; +use alloc::format; +use alloc::vec::Vec; +use std::time::Instant; + +#[allow(unused_imports)] +use egui::{Ui, Vec2, Vec2b}; + +#[derive(Clone, Copy)] +pub struct Stat { + pub count: u32, + pub time: f32, + pub sum_area: f32, +} + +pub struct RasterStats { + pub tri_width_buckets: HashMap, // Key is tri width + pub tri_height_buckets: HashMap, // Key is tri height + pub rect_width_buckets: HashMap, // Key is rect width + pub rect_height_buckets: HashMap, // Key is rect height + pub tri_vert_col_vary: u32, // Count of tris where the vertex colors varied + pub tri_vert_uvs_vary: u32, // Count of tris where the vertex uvs varied + pub tri_alpha_blend: u32, // Count of tris that required alpha blending + pub rect_vert_col_vary: u32, // Count of rects where the vertex colors varied + pub rect_vert_uvs_vary: u32, // Count of rects where the vertex uvs varied + pub rect_alpha_blend: u32, // Count of rects that required alpha blending + pub tris: u32, // Total tris drawn + pub rects: u32, // Total rects drawn + pub start: Instant, // Time just before latest rasterization + pub set_textures: f32, + pub update_dirty_tiles: f32, + pub update_canvas_from_cached: f32, + pub render_prims_to_cache: f32, + pub render_direct: f32, + pub blit_canvas_to_buffer: f32, +} + +impl Default for RasterStats { + fn default() -> Self { + Self { + tri_width_buckets: Default::default(), + tri_height_buckets: Default::default(), + tri_vert_col_vary: Default::default(), + tri_vert_uvs_vary: Default::default(), + tri_alpha_blend: Default::default(), + rect_width_buckets: Default::default(), + rect_height_buckets: Default::default(), + rect_vert_col_vary: Default::default(), + rect_vert_uvs_vary: Default::default(), + rect_alpha_blend: Default::default(), + rects: Default::default(), + tris: Default::default(), + set_textures: Default::default(), + update_dirty_tiles: Default::default(), + update_canvas_from_cached: Default::default(), + render_prims_to_cache: Default::default(), + render_direct: Default::default(), + blit_canvas_to_buffer: Default::default(), + start: Instant::now(), + } + } +} + +#[cfg(not(feature = "rayon"))] +fn insert_or_increment(long_side_size: u32, elapsed: f32, area: f32, map: &mut HashMap) { + if let Some(stat) = map.get_mut(&long_side_size) { + stat.count += 1; + stat.time += elapsed; + stat.sum_area += area; + } else { + map.insert( + long_side_size, + Stat { + count: 1, + time: elapsed, + sum_area: area, + }, + ); + } +} + +impl RasterStats { + pub(crate) fn clear(&mut self) { + *self = RasterStats::default(); + } + + #[cfg(not(feature = "rayon"))] + pub(crate) fn start_raster(&mut self) { + self.start = Instant::now(); + } + + #[cfg(not(feature = "rayon"))] + pub(crate) fn finish_rect( + &mut self, + fsize: Vec2, + vert_uvs_vary: bool, + vert_col_vary: bool, + alpha_blend: bool, + ) { + let elapsed = self.start.elapsed().as_secs_f32(); + self.rects += 1; + let tri_area = (fsize.x * fsize.y) * 0.5; + insert_or_increment( + (fsize.x as u32).max(1), + elapsed, + tri_area, + &mut self.rect_width_buckets, + ); + insert_or_increment( + (fsize.y as u32).max(1), + elapsed, + tri_area, + &mut self.rect_height_buckets, + ); + self.rect_vert_col_vary += vert_col_vary as u32; + self.rect_vert_uvs_vary += vert_uvs_vary as u32; + self.rect_alpha_blend += alpha_blend as u32; + } + + #[cfg(not(feature = "rayon"))] + pub(crate) fn finish_tri( + &mut self, + fsize: Vec2, + vert_uvs_vary: bool, + vert_col_vary: bool, + alpha_blend: bool, + ) { + let elapsed = self.start.elapsed().as_secs_f32(); + self.tris += 1; + let rect_area = fsize.x * fsize.y; + insert_or_increment( + (fsize.x as u32).max(1), + elapsed, + rect_area, + &mut self.tri_width_buckets, + ); + insert_or_increment( + (fsize.y as u32).max(1), + elapsed, + rect_area, + &mut self.tri_height_buckets, + ); + self.tri_vert_col_vary += vert_col_vary as u32; + self.tri_vert_uvs_vary += vert_uvs_vary as u32; + self.tri_alpha_blend += alpha_blend as u32; + } + + pub fn render(&self, ui: &mut Ui) { + egui::ScrollArea::both() + .auto_shrink(Vec2b::new(false, false)) + .min_scrolled_width(900.0) + .show(ui, |ui| { + egui::Grid::new("stats_grid").striped(true).show(ui, |ui| { + let mut stat = |label: &str, val: f32| { + ui.label(label); + ui.label(format!("{:.2}ms", val * 1000.0)); + ui.end_row(); + }; + stat("set_textures", self.set_textures); + stat("render_prims_to_cache", self.render_prims_to_cache); + stat("update_dirty_tiles", self.update_dirty_tiles); + stat("update_canvas_from_cached", self.update_canvas_from_cached); + stat("blit_canvas_to_buffer", self.blit_canvas_to_buffer); + stat("render_direct", self.render_direct); + + ui.heading(""); + ui.heading("Tri"); + ui.heading("Rect"); + ui.end_row(); + let mut stat = |label: &str, val: u32, val2: u32| { + ui.label(label); + ui.label(val.to_string()); + ui.label(val2.to_string()); + ui.end_row(); + }; + stat( + "Vertex colors vary", + self.tri_vert_col_vary, + self.rect_vert_col_vary, + ); + stat( + "Vertex uvs vary", + self.tri_vert_uvs_vary, + self.rect_vert_uvs_vary, + ); + stat( + "Requires alpha blend", + self.tri_alpha_blend, + self.rect_alpha_blend, + ); + }); + + ui.label(""); + ui.end_row(); + + fn collect_and_sort(map: &HashMap) -> Vec<(u32, Stat)> { + let mut v: Vec<_> = map.iter().map(|(&k, &s)| (k, s)).collect(); + v.sort_by_key(|&(_, s)| std::cmp::Reverse((s.time * 1000000.0) as u32)); // Seconds to microseconds + v + } + + let tri_width_bucket = collect_and_sort(&self.tri_width_buckets); + let tri_height_bucket = collect_and_sort(&self.tri_height_buckets); + let rect_width_bucket = collect_and_sort(&self.rect_width_buckets); + let rect_height_bucket = collect_and_sort(&self.rect_height_buckets); + + let max_rows = tri_width_bucket + .len() + .max(tri_height_bucket.len()) + .max(rect_width_bucket.len()) + .max(rect_height_bucket.len()); + + egui::Grid::new("stats_grid2").striped(true).show(ui, |ui| { + ui.heading("Tris"); + ui.heading(format!("{}", self.tris)); + (0..=5).for_each(|_| _ = ui.heading("")); + ui.heading(" "); + ui.heading("Rects"); + ui.heading(format!("{}", self.rects)); + (0..=5).for_each(|_| _ = ui.heading("")); + ui.end_row(); + + let headers = ["W", "Qty", "μs", "area"]; + + headers.iter().for_each(|s| _ = ui.heading(*s)); + headers.iter().for_each(|s| _ = ui.heading(*s)); + ui.heading(" "); + headers.iter().for_each(|s| _ = ui.heading(*s)); + headers.iter().for_each(|s| _ = ui.heading(*s)); + ui.end_row(); + + fn row(ui: &mut Ui, i: usize, v: &[(u32, Stat)]) { + if let Some((size, stat)) = v.get(i) { + ui.label(format!("{size}")); + ui.label(format!("{}", stat.count)); + ui.label(format!("{:.0}", stat.time * 1000000.0)); // Seconds to microseconds + ui.label(format!("{}", stat.sum_area as u32)); + } else { + ui.label(""); + ui.label(""); + ui.label(""); + ui.label(""); + } + } + + for i in 0..max_rows { + row(ui, i, &tri_width_bucket); + row(ui, i, &tri_height_bucket); + ui.label(" "); + row(ui, i, &rect_width_bucket); + row(ui, i, &rect_height_bucket); + ui.end_row(); + } + }); + }); + } +} From 92dd54f31a1f1caea9dd3b30fe4e21ed6c2d9d15 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:29:04 +0200 Subject: [PATCH 003/106] fix(egui-spike): present the whole window when it shows --- crates/accshift-egui/Cargo.toml | 1 + crates/accshift-egui/src/soft.rs | 42 ++++++++++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/accshift-egui/Cargo.toml b/crates/accshift-egui/Cargo.toml index a8ffdc5..424d6fe 100644 --- a/crates/accshift-egui/Cargo.toml +++ b/crates/accshift-egui/Cargo.toml @@ -63,6 +63,7 @@ window-vibrancy = "0.8" windows-sys = { version = "0.61", features = [ "Win32_Foundation", "Win32_Graphics_Dwm", + "Win32_Graphics_Gdi", "Win32_UI_Accessibility", ] } diff --git a/crates/accshift-egui/src/soft.rs b/crates/accshift-egui/src/soft.rs index a47639f..a4c5aa3 100644 --- a/crates/accshift-egui/src/soft.rs +++ b/crates/accshift-egui/src/soft.rs @@ -136,7 +136,9 @@ impl Runner { // A buffer that still holds the last frame only needs the tiles // that changed; a new one (first frame, resize) or a repaint the // OS asked for gets everything. - let full = std::mem::take(&mut live.full_present) || buffer.age() == 0; + let full = std::mem::take(&mut live.full_present) + || buffer.age() == 0 + || os_wants_full_paint(&live.window); // SAFETY: [u8; 4] has the size of u32 and a weaker alignment. let pixels = unsafe { std::slice::from_raw_parts_mut(buffer.as_mut_ptr().cast::<[u8; 4]>(), buffer.len()) @@ -185,6 +187,15 @@ impl Runner { .iter() .map(|r| r.width.get() as usize * r.height.get() as usize) .sum(); + if !live.shown { + // Like eframe, the window waits for its first frame, but it + // shows right before that frame's present: a blit into a + // hidden window is lost, and the repaint the OS asks for when + // it shows would be swallowed by the next damaged-tiles + // present, since softbuffer validates the whole window. + live.shown = true; + live.window.set_visible(true); + } buffer .present_with_damage(&damage) .expect("softbuffer present"); @@ -241,7 +252,7 @@ impl Runner { } if !live.shown { - // Like eframe: created hidden, shown once the first frame is in. + // A zero-sized first frame presented nothing; show anyway. live.shown = true; live.window.set_visible(true); } @@ -256,6 +267,33 @@ impl Runner { } } +/// Whether Windows has part of the client area marked for repaint: it does +/// when it shows, restores or otherwise invalidates the window, and then it +/// expects all of it back. softbuffer validates the whole window on every +/// present, so a damaged-tiles present would swallow that request and leave +/// stale or blank areas until the next full present. +#[cfg(windows)] +fn os_wants_full_paint(window: &Window) -> bool { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + let Ok(handle) = window.window_handle() else { + return false; + }; + let RawWindowHandle::Win32(h) = handle.as_raw() else { + return false; + }; + // SAFETY: live HWND owned by this runner; a null rect only asks whether + // the update region is empty. + unsafe { + windows_sys::Win32::Graphics::Gdi::GetUpdateRect(h.hwnd.get() as _, std::ptr::null_mut(), 0) + != 0 + } +} + +#[cfg(not(windows))] +fn os_wants_full_paint(_window: &Window) -> bool { + false +} + /// The frame as egui's glow painter returns it: premultiplied RGBA. fn read_pixels(bgra: &[[u8; 4]], size: [usize; 2]) -> Arc { let pixels = bgra From 9ae44a00fbd477661ce28f7f51a7d7223aea18d2 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:37:24 +0200 Subject: [PATCH 004/106] docs(egui-spike): record the visible-window check --- crates/accshift-egui/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/accshift-egui/README.md b/crates/accshift-egui/README.md index 0d38e03..233b931 100644 --- a/crates/accshift-egui/README.md +++ b/crates/accshift-egui/README.md @@ -17,7 +17,7 @@ The accshift main screen redrawn in [egui](https://github.com/emilk/egui) 0.36, What the software path does to stay cheap: -- Only the 64 px tiles that changed are rasterized, copied and presented (`present_with_damage`); a full copy happens on the first frame, a resize or an OS repaint. +- Only the 64 px tiles that changed are rasterized, copied and presented (`present_with_damage`); a full copy happens on the first frame, a resize or whenever Windows has part of the window marked for repaint (softbuffer validates the whole window on each present, so that request would otherwise be lost). - Frames are paced to the monitor's refresh rate instead of vsync, and rayon spreads the raster over the tiles. - Startup: no bundled fonts on Windows (Segoe UI comes from the system), the exe is built for size except the per-frame hot path, the UI Automation, shell and COM DLLs are delay-loaded (`build.rs`), UI Automation warms up on another thread, and window drag and drop stays off so winit skips `OleInitialize`. @@ -53,8 +53,9 @@ Interleaved A/B against the Tauri `startup-bench` build of the same commit, 15 l - Software frame cost during the capture script, measured on every paint: at 1290x660 pixels, median 0.40 ms, p95 1.6 ms, 8 % of one core; at 2580x1320, median 0.53 ms, p95 5.2 ms, 18 % of one core. The worst frame (73 ms at the bigger size) is the one where every avatar texture arrives. - The software captures match the glow ones within rounding (PSNR about 51 dB), and every shot is byte-identical between a full redraw and the damaged-tiles path. Both match the Tauri frames of `demo-switch` closely: metrics, colours, rings, the blurred armed avatar and the hint line up. Text is the visible difference, because egui has no variable-font weights (the 500 weight of card names is drawn with the semibold face) and no subpixel positioning like Chromium. - The spike covers one screen in about 2,700 lines of Rust plus the vendored rasterizer. The Svelte front-end it would replace is about 15,600 lines of Svelte and 23,300 of TypeScript (settings, personas, dialogs, bulk edit, theming, i18n, the theme editor). +- In a visible window on Windows 10 22H2, driven with real mouse and keyboard input: the acrylic shows through the GDI blit, and hover, arm and switch, search, the tabs, the folder, the list view, maximize and restore, the title-bar drag, an OS-side resize and the close button all work. The window holds 31 MB of working set there. -Not verified: the software path in a visible window (acrylic through the GDI blit, drag, maximize, live resize), other GPUs and Windows versions, Linux and macOS, accessibility with a screen reader, a wgpu backend. +Not verified: resizing by dragging a window edge, a visible window on Windows 11, other GPUs, Linux and macOS, accessibility with a screen reader, a wgpu backend. ## Layout From 46a5c90d1e0371cd301abd32a4c87b4664dfaea7 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:21:24 +0200 Subject: [PATCH 005/106] chore: exclude the egui port crates from the root workspace --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index a6be599..bdffc8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "2" members = ["src-tauri", "crates/accshift-core", "crates/accshift-cli"] -exclude = ["vendor", "crates/accshift-egui"] +exclude = ["vendor", "crates/accshift-egui", "crates/accshift-theme", "crates/accshift-app"] [workspace.package] version = "1.0.5" From 77e0eacf2b0a38f551bf4cb0e0f8014ec09c64f0 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:40:32 +0200 Subject: [PATCH 006/106] feat(theme): port the theme engine to Rust --- crates/accshift-theme/Cargo.lock | 130 +++ crates/accshift-theme/Cargo.toml | 16 + crates/accshift-theme/README.md | 62 ++ crates/accshift-theme/src/backdrop.rs | 105 ++ crates/accshift-theme/src/color.rs | 567 ++++++++++ crates/accshift-theme/src/contrast.rs | 172 ++++ crates/accshift-theme/src/css.rs | 973 ++++++++++++++++++ crates/accshift-theme/src/css_safety.rs | 227 ++++ crates/accshift-theme/src/js.rs | 149 +++ crates/accshift-theme/src/lib.rs | 61 ++ crates/accshift-theme/src/motion.rs | 81 ++ crates/accshift-theme/src/schema.rs | 644 ++++++++++++ crates/accshift-theme/src/themes.rs | 808 +++++++++++++++ crates/accshift-theme/src/tokens.rs | 654 ++++++++++++ .../tests/data/builtin-dark.json | 526 ++++++++++ .../tests/data/builtin-glass-dark.json | 526 ++++++++++ .../tests/data/builtin-glass-light.json | 526 ++++++++++ .../tests/data/builtin-light.json | 526 ++++++++++ .../tests/data/builtin-liquid-glass.json | 526 ++++++++++ .../tests/data/builtin-midnight.json | 526 ++++++++++ .../tests/data/custom-bevelled.json | 544 ++++++++++ .../tests/data/custom-glassy.json | 537 ++++++++++ .../tests/data/custom-legacy-v1.json | 535 ++++++++++ .../tests/data/custom-my-liquid.json | 536 ++++++++++ .../tests/data/custom-with-css.json | 537 ++++++++++ crates/accshift-theme/tests/dump.rs | 283 +++++ crates/accshift-theme/tests/ported.rs | 930 +++++++++++++++++ crates/accshift-theme/tools/variables.dump.ts | 177 ++++ .../tools/vitest.dump.config.mjs | 17 + 29 files changed, 11901 insertions(+) create mode 100644 crates/accshift-theme/Cargo.lock create mode 100644 crates/accshift-theme/Cargo.toml create mode 100644 crates/accshift-theme/README.md create mode 100644 crates/accshift-theme/src/backdrop.rs create mode 100644 crates/accshift-theme/src/color.rs create mode 100644 crates/accshift-theme/src/contrast.rs create mode 100644 crates/accshift-theme/src/css.rs create mode 100644 crates/accshift-theme/src/css_safety.rs create mode 100644 crates/accshift-theme/src/js.rs create mode 100644 crates/accshift-theme/src/lib.rs create mode 100644 crates/accshift-theme/src/motion.rs create mode 100644 crates/accshift-theme/src/schema.rs create mode 100644 crates/accshift-theme/src/themes.rs create mode 100644 crates/accshift-theme/src/tokens.rs create mode 100644 crates/accshift-theme/tests/data/builtin-dark.json create mode 100644 crates/accshift-theme/tests/data/builtin-glass-dark.json create mode 100644 crates/accshift-theme/tests/data/builtin-glass-light.json create mode 100644 crates/accshift-theme/tests/data/builtin-light.json create mode 100644 crates/accshift-theme/tests/data/builtin-liquid-glass.json create mode 100644 crates/accshift-theme/tests/data/builtin-midnight.json create mode 100644 crates/accshift-theme/tests/data/custom-bevelled.json create mode 100644 crates/accshift-theme/tests/data/custom-glassy.json create mode 100644 crates/accshift-theme/tests/data/custom-legacy-v1.json create mode 100644 crates/accshift-theme/tests/data/custom-my-liquid.json create mode 100644 crates/accshift-theme/tests/data/custom-with-css.json create mode 100644 crates/accshift-theme/tests/dump.rs create mode 100644 crates/accshift-theme/tests/ported.rs create mode 100644 crates/accshift-theme/tools/variables.dump.ts create mode 100644 crates/accshift-theme/tools/vitest.dump.config.mjs diff --git a/crates/accshift-theme/Cargo.lock b/crates/accshift-theme/Cargo.lock new file mode 100644 index 0000000..3b6f681 --- /dev/null +++ b/crates/accshift-theme/Cargo.lock @@ -0,0 +1,130 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "accshift-theme" +version = "0.0.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.152" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1741ab7a6cc54a03a89b5d563ed60075c277d9e3cfa73ad0c1f23f23974703c6" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d62a2e0561533f2ca2561d0cf27fd9fedb640a1bf2616ff5d5c80d99017faadc" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/accshift-theme/Cargo.toml b/crates/accshift-theme/Cargo.toml new file mode 100644 index 0000000..8d0c675 --- /dev/null +++ b/crates/accshift-theme/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "accshift-theme" +description = "The accshift theme engine (tokens, schema, surface opacities, CSS variables) as plain Rust" +version = "0.0.0" +edition = "2021" +publish = false + +# Standalone like accshift-egui: the root workspace excludes this folder, so the +# egui port can depend on it without touching the Tauri build. +[workspace] + +[dependencies] +serde = { version = "1", features = ["derive"] } +# preserve_order: object keys keep file order, which is what JSON.stringify +# does and what a byte-for-byte theme export needs. +serde_json = { version = "1", features = ["preserve_order"] } diff --git a/crates/accshift-theme/README.md b/crates/accshift-theme/README.md new file mode 100644 index 0000000..098697a --- /dev/null +++ b/crates/accshift-theme/README.md @@ -0,0 +1,62 @@ +# accshift-theme + +The theme engine of `src/lib/theme/` as a plain Rust crate, for the egui port. It answers with concrete values (RGBA colours, lengths, numbers) that match what the WebView computes for the same theme. No egui dependency; `serde` and `serde_json` only. + +Standalone like `crates/accshift-egui`: it has its own empty `[workspace]`, and the root `Cargo.toml` excludes it. + +## What it ports + +| TypeScript | Rust | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | +| `tokens.ts`: token specs, density scale, choices, value validation | `tokens` | +| `schema.ts`: parse, validate, serialize, duplicate, `resolveThemeTokens` | `schema` | +| `themes.ts`: built-in documents, `extends`, custom theme payloads, `resolveThemeSurfaceOpacities`, `themeUsesLiquidGlass`, the variables `applyThemeToDocument` writes | `themes` | +| `contrast.ts`, `cssSafety.ts` | `contrast`, `css_safety` | +| `motion.ts` (the `data-motion` decision) | `motion` | +| `backdrop.ts` (which OS effect a theme asks for, not the OS call) | `backdrop` | +| `:root` of `src/app.css` plus `var()`, `calc()`, `color-mix()` | `css` | + +`src/lib/theme/builtin/*.json` and `src/app.css` are embedded with `include_str!` from where they live, so a change there reaches the crate on the next build. + +## API + +```rust +use accshift_theme::{theme_application, ResolvedTheme, ThemeRegistry}; + +let mut registry = ThemeRegistry::new(); +registry.apply_custom_theme_payloads(&payloads_from_disk); +let theme = registry.theme(Some(&settings.theme)); +let applied = theme_application(&theme, settings.background_opacity, backdrop_available); +let resolved = ResolvedTheme::new(&applied); + +let card = resolved.color("--bg-card"); // Option +let radius = resolved.length_px("--radius-md"); // Option +let scale = resolved.number("--density-scale"); // Option +let entrance = resolved.duration_ms("--motion-card-entrance"); +let shadow = resolved.shadow("--elevation-low"); // Option> +let ring = resolved.eval_color("color-mix(in srgb, var(--fg) 65%, transparent)"); +let row = resolved.with_vars([("--row-custom-color", "#ff8800")]); +for item in resolved.unsupported_css() { log::warn!("theme CSS: {item}"); } +``` + +- `Rgba` is straight-alpha sRGB in f32 (0 to 1), so a `color-mix` result is not rounded before painting. `to_premultiplied_u8()` gives the layout of egui's `Color32`. +- `Length` keeps px, em, %, vw and vh apart (`calc(100% - 26px)` needs layout); `as_px()` returns the px value when nothing else is involved. `rem` is 16px. +- `ThemeApplication` is what `applyThemeToDocument` writes: `data-theme`, `data-glass`, `color-scheme`, the inline variables in order, the theme CSS, and the surface opacities. +- Cascade in `ResolvedTheme::new`, lowest first: `app.css` `:root`, the theme CSS, the inline variables, then the theme CSS `!important` declarations. Only `:root { --x: value }` declarations of a theme's CSS are applied; every other rule, at-rule or declaration is listed by `unsupported_css()`, never an error. +- `ThemeRegistry::prepare_save` and `finish_save` split `saveThemeDocument` around the disk write, which stays with the app. + +## Refreshing the dump + +`tests/data/*.json` holds what the real TypeScript writes on `` for the six built-in themes and five custom ones, each at background opacity 0, 50, 85 and 100, with the backdrop available and not. `tests/dump.rs` compares every variable of every case. After a change to `src/lib/theme/`, regenerate from the repo root: + +```powershell +build-safe node node_modules/vitest/vitest.mjs run --config crates/accshift-theme/tools/vitest.dump.config.mjs +``` + +The script is `tools/variables.dump.ts`; its name keeps it out of the app's own `vitest run`. Then run `cargo test` here. + +## Known differences + +- A document's tokens are a `BTreeMap`: unknown keys cannot be stored, so `validate_theme_document` reports token issues in spec order and never reports `unknownToken`. Parsing still lists unknown keys in `rejected_tokens`. +- `var()` is substituted as text before tokenizing. A value that relies on token boundaries around a `var()` (`var(--n)px`) would parse where a browser refuses it. +- `color-mix` supports `in srgb` only, the one space the app uses. `min()`, `max()` and `clamp()` are not evaluated. diff --git a/crates/accshift-theme/src/backdrop.rs b/crates/accshift-theme/src/backdrop.rs new file mode 100644 index 0000000..6968b47 --- /dev/null +++ b/crates/accshift-theme/src/backdrop.rs @@ -0,0 +1,105 @@ +//! Port of the decision half of `src/lib/theme/backdrop.ts`: which OS effect +//! a theme asks for behind the transparent window. Applying it is the app's +//! job (window-vibrancy on Windows and macOS, nothing on Linux). + +/// macOS vibrancy material. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MacEffect { + HudWindow, + Popover, + UnderWindowBackground, +} + +/// What the window behind the UI should be. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum WindowBackdrop { + /// Plain transparent window, every effect cleared. + Off, + Glass { + /// Material id: `liquid-glass` for any theme that extends it, else + /// the theme id. + material: String, + /// Windows Acrylic. Liquid Glass uses none: the window stays purely + /// transparent and any acrylic left by a previous theme is cleared. + windows_acrylic: bool, + /// Acrylic tint, RGBA. Only the SWCA acrylic path honours it. + acrylic_tint: Option<[u8; 4]>, + mac_effect: MacEffect, + /// Keep DWM drawing the backdrop while the window is unfocused + /// (`set_keep_backdrop_active`): on for acrylic only. + keep_backdrop_active: bool, + }, +} + +impl WindowBackdrop { + /// The memo key of `applyWindowBackdrop`: re-apply only when it changes. + pub fn key(&self) -> String { + match self { + WindowBackdrop::Off => "off".to_owned(), + WindowBackdrop::Glass { material, .. } => format!("backdrop:{material}"), + } + } +} + +/// `applyWindowBackdrop(glass, themeId, liquid)` without the OS call. +/// `liquid` defaults to `theme_id == "liquid-glass"` in TypeScript; pass +/// `AppTheme::liquid`. +pub fn window_backdrop(glass: bool, theme_id: &str, liquid: bool) -> WindowBackdrop { + if !glass { + return WindowBackdrop::Off; + } + let material = if liquid { "liquid-glass" } else { theme_id }; + let acrylic = !liquid; + WindowBackdrop::Glass { + material: material.to_owned(), + windows_acrylic: acrylic, + acrylic_tint: (material == "glass-dark").then_some([0, 0, 0, 150]), + mac_effect: match material { + "glass-dark" => MacEffect::HudWindow, + "glass-light" => MacEffect::Popover, + _ => MacEffect::UnderWindowBackground, + }, + keep_backdrop_active: acrylic, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn picks_the_material_per_theme() { + assert_eq!(window_backdrop(false, "dark", false), WindowBackdrop::Off); + assert_eq!(window_backdrop(false, "dark", false).key(), "off"); + let glass_dark = window_backdrop(true, "glass-dark", false); + assert_eq!( + glass_dark, + WindowBackdrop::Glass { + material: "glass-dark".to_owned(), + windows_acrylic: true, + acrylic_tint: Some([0, 0, 0, 150]), + mac_effect: MacEffect::HudWindow, + keep_backdrop_active: true, + } + ); + let my_liquid = window_backdrop(true, "my-liquid", true); + assert_eq!(my_liquid.key(), "backdrop:liquid-glass"); + assert!(matches!( + my_liquid, + WindowBackdrop::Glass { + windows_acrylic: false, + keep_backdrop_active: false, + mac_effect: MacEffect::UnderWindowBackground, + .. + } + )); + assert!(matches!( + window_backdrop(true, "glass-light", false), + WindowBackdrop::Glass { + acrylic_tint: None, + mac_effect: MacEffect::Popover, + .. + } + )); + } +} diff --git a/crates/accshift-theme/src/color.rs b/crates/accshift-theme/src/color.rs new file mode 100644 index 0000000..8208a95 --- /dev/null +++ b/crates/accshift-theme/src/color.rs @@ -0,0 +1,567 @@ +//! The colour type and every colour notation the app's CSS uses: hex, +//! `rgb()`/`rgba()` in both syntaxes, `hsl()`/`hsla()`, named colours, +//! `transparent`, and `color-mix(in srgb, ...)` per CSS Color 5. + +use crate::css::{components, split_commas, trim_whitespace, Token}; + +/// An sRGB colour with straight (not premultiplied) alpha, every channel in +/// `0.0..=1.0`, kept as f32 so a `color-mix` result is not quantized before it +/// is painted. [`Rgba::to_premultiplied_u8`] gives what egui's `Color32` +/// stores. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Rgba { + pub r: f32, + pub g: f32, + pub b: f32, + pub a: f32, +} + +impl Rgba { + pub const TRANSPARENT: Rgba = Rgba::new(0.0, 0.0, 0.0, 0.0); + + pub const fn new(r: f32, g: f32, b: f32, a: f32) -> Rgba { + Rgba { r, g, b, a } + } + + /// From 0..=255 channels and a 0..=1 alpha, as `rgb(r g b / a)` writes it. + pub fn from_rgb8(r: u8, g: u8, b: u8, a: f32) -> Rgba { + Rgba::new( + f32::from(r) / 255.0, + f32::from(g) / 255.0, + f32::from(b) / 255.0, + a, + ) + } + + /// Straight 8-bit channels, rounded half up. + pub fn to_u8(self) -> [u8; 4] { + [to_u8(self.r), to_u8(self.g), to_u8(self.b), to_u8(self.a)] + } + + /// Premultiplied 8-bit channels: the layout of egui's `Color32`. + pub fn to_premultiplied_u8(self) -> [u8; 4] { + let a = self.a.clamp(0.0, 1.0); + [ + to_u8(self.r * a), + to_u8(self.g * a), + to_u8(self.b * a), + to_u8(a), + ] + } + + /// The colour with its alpha multiplied by `factor`, what `opacity` does to + /// a single flat fill. + pub fn with_alpha_factor(self, factor: f32) -> Rgba { + Rgba { + a: (self.a * factor).clamp(0.0, 1.0), + ..self + } + } + + /// `self` painted over `backdrop` with source-over compositing. + pub fn over(self, backdrop: Rgba) -> Rgba { + let a = self.a + backdrop.a * (1.0 - self.a); + if a <= 0.0 { + return Rgba::TRANSPARENT; + } + let channel = + |top: f32, bottom: f32| (top * self.a + bottom * backdrop.a * (1.0 - self.a)) / a; + Rgba::new( + channel(self.r, backdrop.r), + channel(self.g, backdrop.g), + channel(self.b, backdrop.b), + a, + ) + } +} + +fn to_u8(channel: f32) -> u8 { + (channel.clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u8 +} + +/// CSS named colours (CSS Color 4), `transparent` excluded. +const NAMED_COLORS: &[(&str, u32)] = &[ + ("aliceblue", 0xf0f8ff), + ("antiquewhite", 0xfaebd7), + ("aqua", 0x00ffff), + ("aquamarine", 0x7fffd4), + ("azure", 0xf0ffff), + ("beige", 0xf5f5dc), + ("bisque", 0xffe4c4), + ("black", 0x000000), + ("blanchedalmond", 0xffebcd), + ("blue", 0x0000ff), + ("blueviolet", 0x8a2be2), + ("brown", 0xa52a2a), + ("burlywood", 0xdeb887), + ("cadetblue", 0x5f9ea0), + ("chartreuse", 0x7fff00), + ("chocolate", 0xd2691e), + ("coral", 0xff7f50), + ("cornflowerblue", 0x6495ed), + ("cornsilk", 0xfff8dc), + ("crimson", 0xdc143c), + ("cyan", 0x00ffff), + ("darkblue", 0x00008b), + ("darkcyan", 0x008b8b), + ("darkgoldenrod", 0xb8860b), + ("darkgray", 0xa9a9a9), + ("darkgreen", 0x006400), + ("darkgrey", 0xa9a9a9), + ("darkkhaki", 0xbdb76b), + ("darkmagenta", 0x8b008b), + ("darkolivegreen", 0x556b2f), + ("darkorange", 0xff8c00), + ("darkorchid", 0x9932cc), + ("darkred", 0x8b0000), + ("darksalmon", 0xe9967a), + ("darkseagreen", 0x8fbc8f), + ("darkslateblue", 0x483d8b), + ("darkslategray", 0x2f4f4f), + ("darkslategrey", 0x2f4f4f), + ("darkturquoise", 0x00ced1), + ("darkviolet", 0x9400d3), + ("deeppink", 0xff1493), + ("deepskyblue", 0x00bfff), + ("dimgray", 0x696969), + ("dimgrey", 0x696969), + ("dodgerblue", 0x1e90ff), + ("firebrick", 0xb22222), + ("floralwhite", 0xfffaf0), + ("forestgreen", 0x228b22), + ("fuchsia", 0xff00ff), + ("gainsboro", 0xdcdcdc), + ("ghostwhite", 0xf8f8ff), + ("gold", 0xffd700), + ("goldenrod", 0xdaa520), + ("gray", 0x808080), + ("green", 0x008000), + ("greenyellow", 0xadff2f), + ("grey", 0x808080), + ("honeydew", 0xf0fff0), + ("hotpink", 0xff69b4), + ("indianred", 0xcd5c5c), + ("indigo", 0x4b0082), + ("ivory", 0xfffff0), + ("khaki", 0xf0e68c), + ("lavender", 0xe6e6fa), + ("lavenderblush", 0xfff0f5), + ("lawngreen", 0x7cfc00), + ("lemonchiffon", 0xfffacd), + ("lightblue", 0xadd8e6), + ("lightcoral", 0xf08080), + ("lightcyan", 0xe0ffff), + ("lightgoldenrodyellow", 0xfafad2), + ("lightgray", 0xd3d3d3), + ("lightgreen", 0x90ee90), + ("lightgrey", 0xd3d3d3), + ("lightpink", 0xffb6c1), + ("lightsalmon", 0xffa07a), + ("lightseagreen", 0x20b2aa), + ("lightskyblue", 0x87cefa), + ("lightslategray", 0x778899), + ("lightslategrey", 0x778899), + ("lightsteelblue", 0xb0c4de), + ("lightyellow", 0xffffe0), + ("lime", 0x00ff00), + ("limegreen", 0x32cd32), + ("linen", 0xfaf0e6), + ("magenta", 0xff00ff), + ("maroon", 0x800000), + ("mediumaquamarine", 0x66cdaa), + ("mediumblue", 0x0000cd), + ("mediumorchid", 0xba55d3), + ("mediumpurple", 0x9370db), + ("mediumseagreen", 0x3cb371), + ("mediumslateblue", 0x7b68ee), + ("mediumspringgreen", 0x00fa9a), + ("mediumturquoise", 0x48d1cc), + ("mediumvioletred", 0xc71585), + ("midnightblue", 0x191970), + ("mintcream", 0xf5fffa), + ("mistyrose", 0xffe4e1), + ("moccasin", 0xffe4b5), + ("navajowhite", 0xffdead), + ("navy", 0x000080), + ("oldlace", 0xfdf5e6), + ("olive", 0x808000), + ("olivedrab", 0x6b8e23), + ("orange", 0xffa500), + ("orangered", 0xff4500), + ("orchid", 0xda70d6), + ("palegoldenrod", 0xeee8aa), + ("palegreen", 0x98fb98), + ("paleturquoise", 0xafeeee), + ("palevioletred", 0xdb7093), + ("papayawhip", 0xffefd5), + ("peachpuff", 0xffdab9), + ("peru", 0xcd853f), + ("pink", 0xffc0cb), + ("plum", 0xdda0dd), + ("powderblue", 0xb0e0e6), + ("purple", 0x800080), + ("rebeccapurple", 0x663399), + ("red", 0xff0000), + ("rosybrown", 0xbc8f8f), + ("royalblue", 0x4169e1), + ("saddlebrown", 0x8b4513), + ("salmon", 0xfa8072), + ("sandybrown", 0xf4a460), + ("seagreen", 0x2e8b57), + ("seashell", 0xfff5ee), + ("sienna", 0xa0522d), + ("silver", 0xc0c0c0), + ("skyblue", 0x87ceeb), + ("slateblue", 0x6a5acd), + ("slategray", 0x708090), + ("slategrey", 0x708090), + ("snow", 0xfffafa), + ("springgreen", 0x00ff7f), + ("steelblue", 0x4682b4), + ("tan", 0xd2b48c), + ("teal", 0x008080), + ("thistle", 0xd8bfd8), + ("tomato", 0xff6347), + ("turquoise", 0x40e0d0), + ("violet", 0xee82ee), + ("wheat", 0xf5deb3), + ("white", 0xffffff), + ("whitesmoke", 0xf5f5f5), + ("yellow", 0xffff00), + ("yellowgreen", 0x9acd32), +]; + +fn named_color(name: &str) -> Option { + let lower = name.to_ascii_lowercase(); + if lower == "transparent" { + return Some(Rgba::TRANSPARENT); + } + NAMED_COLORS + .binary_search_by(|(candidate, _)| candidate.cmp(&lower.as_str())) + .ok() + .map(|index| { + let rgb = NAMED_COLORS[index].1; + Rgba::from_rgb8((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8, 1.0) + }) +} + +fn hex_color(hex: &str) -> Option { + if !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let nibble = |index: usize| u8::from_str_radix(&hex[index..=index], 16).ok(); + let byte = |index: usize| u8::from_str_radix(&hex[index..index + 2], 16).ok(); + let (r, g, b, a) = match hex.len() { + 3 | 4 => ( + nibble(0)? * 17, + nibble(1)? * 17, + nibble(2)? * 17, + if hex.len() == 4 { nibble(3)? * 17 } else { 255 }, + ), + 6 | 8 => ( + byte(0)?, + byte(2)?, + byte(4)?, + if hex.len() == 8 { byte(6)? } else { 255 }, + ), + _ => return None, + }; + Some(Rgba::from_rgb8(r, g, b, f32::from(a) / 255.0)) +} + +fn alpha_value(token: &Token) -> Option { + match token { + Token::Number(value) => Some(value.clamp(0.0, 1.0)), + Token::Percentage(value) => Some((value / 100.0).clamp(0.0, 1.0)), + Token::Ident(name) if name.eq_ignore_ascii_case("none") => Some(0.0), + _ => None, + } +} + +/// Splits `a b c / d` or `a, b, c, d` into channels and an optional alpha. +/// The legacy comma form forbids `none` and wants three channels of one type. +fn channels_and_alpha(args: &[Token]) -> Option<(Vec<&Token>, Option<&Token>)> { + let parts = split_commas(args); + if parts.len() > 1 { + let mut items = Vec::new(); + for part in &parts { + let [item] = components(part)[..] else { + return None; + }; + if matches!(item, Token::Ident(_)) { + return None; + } + items.push(item); + } + return match items.len() { + 3 => Some((items, None)), + 4 => { + let alpha = items.pop(); + Some((items, alpha)) + } + _ => None, + }; + } + let items = components(args); + match items.iter().position(|token| matches!(token, Token::Slash)) { + Some(slash) => { + let channels = items[..slash].to_vec(); + let [alpha] = items[slash + 1..] else { + return None; + }; + (channels.len() == 3).then_some((channels, Some(alpha))) + } + None => (items.len() == 3).then_some((items, None)), + } +} + +fn rgb_function(args: &[Token]) -> Option { + let legacy = split_commas(args).len() > 1; + let (channels, alpha) = channels_and_alpha(args)?; + if legacy { + let percent = matches!(channels[0], Token::Percentage(_)); + if channels + .iter() + .any(|channel| matches!(channel, Token::Percentage(_)) != percent) + { + return None; + } + } + let mut rgb = [0.0f64; 3]; + for (slot, channel) in rgb.iter_mut().zip(&channels) { + *slot = match channel { + Token::Number(value) => (value / 255.0).clamp(0.0, 1.0), + Token::Percentage(value) => (value / 100.0).clamp(0.0, 1.0), + Token::Ident(name) if name.eq_ignore_ascii_case("none") => 0.0, + _ => return None, + }; + } + let a = match alpha { + Some(token) => alpha_value(token)?, + None => 1.0, + }; + Some(Rgba::new( + rgb[0] as f32, + rgb[1] as f32, + rgb[2] as f32, + a as f32, + )) +} + +fn hue_degrees(token: &Token) -> Option { + match token { + Token::Number(value) => Some(*value), + Token::Dimension(value, unit) => match unit.as_str() { + "deg" => Some(*value), + "rad" => Some(value.to_degrees()), + "grad" => Some(value * 0.9), + "turn" => Some(value * 360.0), + _ => None, + }, + Token::Ident(name) if name.eq_ignore_ascii_case("none") => Some(0.0), + _ => None, + } +} + +fn hsl_function(args: &[Token]) -> Option { + let (channels, alpha) = channels_and_alpha(args)?; + let hue = hue_degrees(channels[0])?.rem_euclid(360.0); + let fraction = |token: &Token| match token { + Token::Percentage(value) | Token::Number(value) => Some((value / 100.0).clamp(0.0, 1.0)), + Token::Ident(name) if name.eq_ignore_ascii_case("none") => Some(0.0), + _ => None, + }; + let saturation = fraction(channels[1])?; + let lightness = fraction(channels[2])?; + let a = match alpha { + Some(token) => alpha_value(token)?, + None => 1.0, + }; + // CSS Color 4, `hslToRgb`. + let channel = |n: f64| { + let k = (n + hue / 30.0) % 12.0; + let chroma = saturation * lightness.min(1.0 - lightness); + lightness - chroma * (k - 3.0).min(9.0 - k).clamp(-1.0, 1.0) + }; + Some(Rgba::new( + channel(0.0) as f32, + channel(8.0) as f32, + channel(4.0) as f32, + a as f32, + )) +} + +/// One ` ?` argument of `color-mix`, either order. +fn mix_argument(tokens: &[Token]) -> Option<(Rgba, Option)> { + let items = components(tokens); + let (color_token, percent) = match items[..] { + [color] => (color, None), + [Token::Percentage(percent), color] | [color, Token::Percentage(percent)] => { + (color, Some(*percent)) + } + _ => return None, + }; + if percent.is_some_and(|value| !(0.0..=100.0).contains(&value)) { + return None; + } + Some(( + parse_color_tokens(std::slice::from_ref(color_token))?, + percent, + )) +} + +/// `color-mix(in srgb, a p1?, b p2?)`, CSS Color 5: percentages normalized to +/// a sum of 100 (a sum below 100 also scales the result's alpha), then a +/// premultiplied interpolation. +fn color_mix(args: &[Token]) -> Option { + let parts = split_commas(args); + let [space, first, second] = parts[..] else { + return None; + }; + match components(space)[..] { + [Token::Ident(word), Token::Ident(name)] + if word.eq_ignore_ascii_case("in") && name.eq_ignore_ascii_case("srgb") => {} + _ => return None, + } + let (c1, p1) = mix_argument(first)?; + let (c2, p2) = mix_argument(second)?; + let (p1, p2) = match (p1, p2) { + (None, None) => (50.0, 50.0), + (Some(p1), None) => (p1, 100.0 - p1), + (None, Some(p2)) => (100.0 - p2, p2), + (Some(p1), Some(p2)) => (p1, p2), + }; + let sum = p1 + p2; + if sum <= 0.0 { + return None; + } + let alpha_multiplier = if sum < 100.0 { sum / 100.0 } else { 1.0 }; + let (w1, w2) = (p1 / sum, p2 / sum); + + let (a1, a2) = (f64::from(c1.a), f64::from(c2.a)); + let alpha = a1 * w1 + a2 * w2; + let channel = |x1: f32, x2: f32| { + if alpha <= 0.0 { + 0.0 + } else { + ((f64::from(x1) * a1 * w1 + f64::from(x2) * a2 * w2) / alpha) as f32 + } + }; + Some(Rgba::new( + channel(c1.r, c2.r), + channel(c1.g, c2.g), + channel(c1.b, c2.b), + (alpha * alpha_multiplier) as f32, + )) +} + +/// A colour from a token list that holds exactly one colour component. +pub(crate) fn parse_color_tokens(tokens: &[Token]) -> Option { + let [token] = trim_whitespace(tokens) else { + return None; + }; + match token { + Token::Hash(hex) => hex_color(hex), + Token::Ident(name) => named_color(name), + Token::Function(name, args) => match name.as_str() { + "rgb" | "rgba" => rgb_function(args), + "hsl" | "hsla" => hsl_function(args), + "color-mix" => color_mix(args), + _ => None, + }, + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::css::tokenize; + + fn color(text: &str) -> Option { + parse_color_tokens(&tokenize(text)?) + } + + fn close(actual: Rgba, expected: Rgba) -> bool { + (actual.r - expected.r).abs() < 1e-4 + && (actual.g - expected.g).abs() < 1e-4 + && (actual.b - expected.b).abs() < 1e-4 + && (actual.a - expected.a).abs() < 1e-4 + } + + #[test] + fn named_colors_are_sorted_for_the_binary_search() { + assert!(NAMED_COLORS.windows(2).all(|pair| pair[0].0 < pair[1].0)); + assert_eq!(NAMED_COLORS.len(), 148); + } + + #[test] + fn reads_hex_rgb_hsl_and_names() { + assert_eq!(color("#fff"), Some(Rgba::new(1.0, 1.0, 1.0, 1.0))); + assert_eq!(color("#1c1c1f").map(Rgba::to_u8), Some([28, 28, 31, 255])); + assert_eq!(color("#00000080").map(Rgba::to_u8), Some([0, 0, 0, 128])); + assert_eq!( + color("rgba(255, 255, 255, 0.34)"), + Some(Rgba::new(1.0, 1.0, 1.0, 0.34)) + ); + assert_eq!(color("rgb(9 9 11)").map(Rgba::to_u8), Some([9, 9, 11, 255])); + assert_eq!( + color("rgb(0 0 0 / 0.18)"), + Some(Rgba::new(0.0, 0.0, 0.0, 0.18)) + ); + assert_eq!( + color("rgb(100% 0% 0% / 50%)"), + Some(Rgba::new(1.0, 0.0, 0.0, 0.5)) + ); + assert_eq!(color("rgb(255, 0%, 0)"), None); + assert_eq!( + color("RebeccaPurple").map(Rgba::to_u8), + Some([102, 51, 153, 255]) + ); + assert_eq!(color("transparent"), Some(Rgba::TRANSPARENT)); + assert!(close( + color("hsl(120 100% 25%)").unwrap(), + Rgba::new(0.0, 0.5, 0.0, 1.0) + )); + assert!(close( + color("hsla(0, 100%, 50%, 0.5)").unwrap(), + Rgba::new(1.0, 0.0, 0.0, 0.5) + )); + assert_eq!(color("currentcolor"), None); + } + + #[test] + fn mixes_like_css_color_5() { + // The spec's own example: premultiplied, then unpremultiplied. + let mixed = color("color-mix(in srgb, rgb(100% 0% 0% / 0.7) 25%, rgb(0% 100% 0% / 0.2))"); + assert!(close( + mixed.unwrap(), + Rgba::new(0.538_461_5, 0.461_538_5, 0.0, 0.325) + )); + // A colour mixed with transparent keeps its hue and loses alpha. + let faded = color("color-mix(in srgb, #ff0000 40%, transparent)").unwrap(); + assert!(close(faded, Rgba::new(1.0, 0.0, 0.0, 0.4))); + // Percentages under 100 scale the alpha after normalizing. + let under = color("color-mix(in srgb, red 20%, blue 60%)").unwrap(); + assert!(close(under, Rgba::new(0.25, 0.0, 0.75, 0.8))); + // Over 100 only normalizes. + let over = color("color-mix(in srgb, red 100%, blue 100%)").unwrap(); + assert!(close(over, Rgba::new(0.5, 0.0, 0.5, 1.0))); + // One percentage: the other is the rest. + let one = color("color-mix(in srgb, #000 75%, #fff)").unwrap(); + assert!(close(one, Rgba::new(0.25, 0.25, 0.25, 1.0))); + let leading = color("color-mix(in srgb, 75% #000, #fff)").unwrap(); + assert_eq!(leading, one); + assert_eq!(color("color-mix(in srgb, red 0%, blue 0%)"), None); + assert_eq!(color("color-mix(in srgb, red 120%, blue)"), None); + assert_eq!(color("color-mix(in oklab, red, blue)"), None); + } + + #[test] + fn premultiplies_for_egui() { + let half_white = Rgba::new(1.0, 1.0, 1.0, 0.5); + assert_eq!(half_white.to_premultiplied_u8(), [128, 128, 128, 128]); + let over = half_white.over(Rgba::new(0.0, 0.0, 0.0, 1.0)); + assert!(close(over, Rgba::new(0.5, 0.5, 0.5, 1.0))); + } +} diff --git a/crates/accshift-theme/src/contrast.rs b/crates/accshift-theme/src/contrast.rs new file mode 100644 index 0000000..4d04531 --- /dev/null +++ b/crates/accshift-theme/src/contrast.rs @@ -0,0 +1,172 @@ +//! Port of `src/lib/theme/contrast.ts`: WCAG 2.1 contrast for the theme +//! editor. Only the notations a theme token may hold are parsed. + +use crate::js::{is_js_space, js_parse_float, js_trim}; + +/// A colour with 0..=255 channels, unrounded (a blend can land between two +/// integers, as in the TypeScript). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Rgb { + pub r: f64, + pub g: f64, + pub b: f64, +} + +impl Rgb { + pub const fn new(r: f64, g: f64, b: f64) -> Rgb { + Rgb { r, g, b } + } +} + +fn parse_hex6(hex: &str) -> Option { + let expanded: String = if hex.chars().count() == 3 { + hex.chars().flat_map(|c| [c, c]).collect() + } else { + hex.to_owned() + }; + if expanded.len() != 6 || !expanded.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let channel = + |index: usize| f64::from(u8::from_str_radix(&expanded[index..index + 2], 16).unwrap_or(0)); + Some(Rgb::new(channel(0), channel(2), channel(4))) +} + +/// `parseColor`: hex, `rgb()`/`rgba()`, an "r g b" triplet, `white` and +/// `black`. Anything else is `None`, and the check is skipped. +pub fn parse_color(value: &str) -> Option { + let raw = js_trim(value); + if raw.is_empty() { + return None; + } + if let Some(hex) = raw.strip_prefix('#') { + return parse_hex6(hex); + } + + // ^rgba?\(([^)]+)\)$ + if let Some(inner) = raw + .strip_prefix("rgba(") + .or_else(|| raw.strip_prefix("rgb(")) + .and_then(|rest| rest.strip_suffix(')')) + .filter(|inner| !inner.is_empty() && !inner.contains(')')) + { + let parts: Vec = inner + .split(|c: char| is_js_space(c) || c == ',' || c == '/') + .filter(|part| !part.is_empty()) + .map(js_parse_float) + .collect(); + if parts.len() < 3 || parts[..3].iter().any(|part| part.is_nan()) { + return None; + } + return Some(Rgb::new(parts[0], parts[1], parts[2])); + } + + let triplet: Vec<&str> = raw + .split(is_js_space) + .filter(|part| !part.is_empty()) + .collect(); + let is_channel = + |part: &&str| (1..=3).contains(&part.len()) && part.bytes().all(|b| b.is_ascii_digit()); + if triplet.len() == 3 && triplet.iter().all(is_channel) { + let channel = |index: usize| triplet[index].parse::().unwrap_or(0.0); + return Some(Rgb::new(channel(0), channel(1), channel(2))); + } + + match raw.to_lowercase().as_str() { + "white" => Some(Rgb::new(255.0, 255.0, 255.0)), + "black" => Some(Rgb::new(0.0, 0.0, 0.0)), + _ => None, + } +} + +/// Source colour painted at `alpha` over an opaque backdrop. +pub fn blend(source: Rgb, alpha: f64, backdrop: Rgb) -> Rgb { + let a = alpha.clamp(0.0, 1.0); + Rgb::new( + source.r * a + backdrop.r * (1.0 - a), + source.g * a + backdrop.g * (1.0 - a), + source.b * a + backdrop.b * (1.0 - a), + ) +} + +fn channel_luminance(channel: f64) -> f64 { + let c = channel.clamp(0.0, 255.0) / 255.0; + if c <= 0.03928 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } +} + +pub fn relative_luminance(color: Rgb) -> f64 { + 0.2126 * channel_luminance(color.r) + + 0.7152 * channel_luminance(color.g) + + 0.0722 * channel_luminance(color.b) +} + +/// WCAG contrast ratio, 1 (identical) to 21 (black on white). +pub fn contrast_ratio(a: Rgb, b: Rgb) -> f64 { + let la = relative_luminance(a); + let lb = relative_luminance(b); + (la.max(lb) + 0.05) / (la.min(lb) + 0.05) +} + +/// Rounded to one decimal, which is how the editor prints it (`Math.round`, +/// so halves go up). +pub fn round_ratio(ratio: f64) -> f64 { + (ratio * 10.0 + 0.5).floor() / 10.0 +} + +#[cfg(test)] +mod tests { + use super::*; + + const WHITE: Rgb = Rgb::new(255.0, 255.0, 255.0); + const BLACK: Rgb = Rgb::new(0.0, 0.0, 0.0); + + #[test] + fn reads_every_notation_a_theme_token_may_hold() { + assert_eq!(parse_color("#fff"), Some(WHITE)); + assert_eq!(parse_color("#1c1c1f"), Some(Rgb::new(28.0, 28.0, 31.0))); + assert_eq!(parse_color("rgb(9, 9, 11)"), Some(Rgb::new(9.0, 9.0, 11.0))); + assert_eq!( + parse_color("rgba(9 9 11 / 0.5)"), + Some(Rgb::new(9.0, 9.0, 11.0)) + ); + assert_eq!(parse_color("9 9 11"), Some(Rgb::new(9.0, 9.0, 11.0))); + assert_eq!(parse_color("white"), Some(WHITE)); + } + + #[test] + fn returns_none_rather_than_guessing() { + assert_eq!(parse_color(""), None); + assert_eq!(parse_color("#12345"), None); + assert_eq!(parse_color("rebeccapurple"), None); + assert_eq!(parse_color("var(--fg)"), None); + } + + #[test] + fn matches_the_wcag_anchors() { + assert!((relative_luminance(WHITE) - 1.0).abs() < 1e-5); + assert!(relative_luminance(BLACK).abs() < 1e-5); + assert_eq!(round_ratio(contrast_ratio(WHITE, BLACK)), 21.0); + assert_eq!(contrast_ratio(WHITE, WHITE), 1.0); + assert_eq!(contrast_ratio(BLACK, WHITE), contrast_ratio(WHITE, BLACK)); + } + + #[test] + fn scores_a_mid_grey_on_white_the_way_webaim_does() { + assert_eq!( + round_ratio(contrast_ratio(Rgb::new(119.0, 119.0, 119.0), WHITE)), + 4.5 + ); + } + + #[test] + fn composites_a_translucent_surface_over_its_backdrop() { + let dark = Rgb::new(20.0, 20.0, 20.0); + assert_eq!(blend(WHITE, 0.0, dark), dark); + assert_eq!(blend(WHITE, 1.0, dark), WHITE); + assert_eq!(blend(WHITE, 0.5, dark), Rgb::new(137.5, 137.5, 137.5)); + } +} diff --git a/crates/accshift-theme/src/css.rs b/crates/accshift-theme/src/css.rs new file mode 100644 index 0000000..21c4a8a --- /dev/null +++ b/crates/accshift-theme/src/css.rs @@ -0,0 +1,973 @@ +//! A small CSS value resolver: the `:root` variables of `src/app.css`, the +//! ones a theme writes on ``, and a theme's own `:root` overrides, +//! resolved into concrete colours, lengths and numbers. +//! +//! Supported: `var()` chains with fallbacks and cycle detection, the colour +//! notations of [`crate::color`], lengths in `px`, `rem` (16px), `em`, `%`, +//! `vw` and `vh`, times in `ms` and `s`, and `calc()` with `+ - * /`. + +use std::collections::HashMap; +use std::sync::OnceLock; + +use crate::color::{parse_color_tokens, Rgba}; +use crate::js::js_trim; +use crate::themes::ThemeApplication; + +const APP_CSS: &str = include_str!("../../../src/app.css"); + +/// Root font size: nothing in the app changes the browser default. +pub const ROOT_FONT_SIZE_PX: f64 = 16.0; + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Token { + Ident(String), + /// Name lowercased, arguments as written. + Function(String, Vec), + Number(f64), + Percentage(f64), + /// Unit lowercased. + Dimension(f64, String), + Hash(String), + Str(String), + /// A parenthesized block that is not a function call. + Block(Vec), + Comma, + Slash, + Delim(char), + Whitespace, +} + +fn is_name_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '-' || c == '_' || !c.is_ascii() +} + +fn is_name_start(c: char) -> bool { + c.is_ascii_alphabetic() || c == '_' || !c.is_ascii() +} + +struct Tokenizer { + chars: Vec, + pos: usize, +} + +impl Tokenizer { + fn peek(&self, offset: usize) -> Option { + self.chars.get(self.pos + offset).copied() + } + + fn starts_ident(&self) -> bool { + match (self.peek(0), self.peek(1)) { + (Some('-'), Some(next)) => is_name_start(next) || next == '-', + (Some(c), _) => is_name_start(c), + _ => false, + } + } + + fn starts_number(&self) -> bool { + let digit = |c: Option| c.is_some_and(|c| c.is_ascii_digit()); + match self.peek(0) { + Some(c) if c.is_ascii_digit() => true, + Some('.') => digit(self.peek(1)), + Some('+' | '-') => { + digit(self.peek(1)) || (self.peek(1) == Some('.') && digit(self.peek(2))) + } + _ => false, + } + } + + fn name(&mut self) -> String { + let start = self.pos; + while self.peek(0).is_some_and(is_name_char) { + self.pos += 1; + } + self.chars[start..self.pos].iter().collect() + } + + fn number(&mut self) -> f64 { + let start = self.pos; + if matches!(self.peek(0), Some('+' | '-')) { + self.pos += 1; + } + while self.peek(0).is_some_and(|c| c.is_ascii_digit()) { + self.pos += 1; + } + if self.peek(0) == Some('.') && self.peek(1).is_some_and(|c| c.is_ascii_digit()) { + self.pos += 1; + while self.peek(0).is_some_and(|c| c.is_ascii_digit()) { + self.pos += 1; + } + } + if matches!(self.peek(0), Some('e' | 'E')) { + let sign = usize::from(matches!(self.peek(1), Some('+' | '-'))); + if self.peek(1 + sign).is_some_and(|c| c.is_ascii_digit()) { + self.pos += 1 + sign; + while self.peek(0).is_some_and(|c| c.is_ascii_digit()) { + self.pos += 1; + } + } + } + let text: String = self.chars[start..self.pos].iter().collect(); + text.parse().unwrap_or(0.0) + } + + /// Tokens until `)` (when `nested`) or the end. + fn list(&mut self, nested: bool) -> Option> { + let mut tokens = Vec::new(); + while let Some(c) = self.peek(0) { + if c.is_whitespace() { + while self.peek(0).is_some_and(char::is_whitespace) { + self.pos += 1; + } + tokens.push(Token::Whitespace); + continue; + } + if c == ')' { + if !nested { + return None; + } + self.pos += 1; + return Some(tokens); + } + if self.starts_number() { + let value = self.number(); + if self.peek(0) == Some('%') { + self.pos += 1; + tokens.push(Token::Percentage(value)); + } else if self.starts_ident() { + let unit = self.name().to_ascii_lowercase(); + tokens.push(Token::Dimension(value, unit)); + } else { + tokens.push(Token::Number(value)); + } + continue; + } + if self.starts_ident() { + let name = self.name(); + if self.peek(0) == Some('(') { + self.pos += 1; + let args = self.list(true)?; + tokens.push(Token::Function(name.to_ascii_lowercase(), args)); + } else { + tokens.push(Token::Ident(name)); + } + continue; + } + self.pos += 1; + match c { + '#' => tokens.push(Token::Hash(self.name())), + '(' => tokens.push(Token::Block(self.list(true)?)), + ',' => tokens.push(Token::Comma), + '/' => tokens.push(Token::Slash), + '"' | '\'' => { + let start = self.pos; + while self.peek(0).is_some_and(|next| next != c) { + self.pos += 1; + } + let text = self.chars[start..self.pos].iter().collect(); + self.pos += 1; + tokens.push(Token::Str(text)); + } + other => tokens.push(Token::Delim(other)), + } + } + (!nested).then_some(tokens) + } +} + +/// Tokenizes a value; `None` when its parentheses do not balance. +pub(crate) fn tokenize(text: &str) -> Option> { + Tokenizer { + chars: text.chars().collect(), + pos: 0, + } + .list(false) +} + +pub(crate) fn trim_whitespace(tokens: &[Token]) -> &[Token] { + let start = tokens + .iter() + .position(|token| *token != Token::Whitespace) + .unwrap_or(tokens.len()); + let end = tokens + .iter() + .rposition(|token| *token != Token::Whitespace) + .map_or(start, |index| index + 1); + &tokens[start..end] +} + +/// The comma-separated parts of a list, at its top level. +pub(crate) fn split_commas(tokens: &[Token]) -> Vec<&[Token]> { + tokens.split(|token| *token == Token::Comma).collect() +} + +/// The tokens of a list, whitespace removed. +pub(crate) fn components(tokens: &[Token]) -> Vec<&Token> { + tokens + .iter() + .filter(|token| **token != Token::Whitespace) + .collect() +} + +/// A length as CSS keeps it before layout: a sum of px, em, %, vw and vh, +/// which is what `calc(100% - 26px)` is. `rem` is folded into px. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Length { + pub px: f32, + pub em: f32, + pub percent: f32, + pub vw: f32, + pub vh: f32, +} + +impl Length { + pub const fn px(px: f32) -> Length { + Length { + px, + em: 0.0, + percent: 0.0, + vw: 0.0, + vh: 0.0, + } + } + + /// The length in px when it does not depend on layout. + pub fn as_px(self) -> Option { + (self.em == 0.0 && self.percent == 0.0 && self.vw == 0.0 && self.vh == 0.0) + .then_some(self.px) + } + + /// The length in px against a font size, a percentage basis and a + /// viewport (all in px). + pub fn resolve(self, font_px: f32, percent_basis: f32, viewport: (f32, f32)) -> f32 { + self.px + + self.em * font_px + + self.percent / 100.0 * percent_basis + + self.vw / 100.0 * viewport.0 + + self.vh / 100.0 * viewport.1 + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Kind { + Number, + Length, + Time, +} + +/// A `calc()` operand or result. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Quantity { + kind: Kind, + /// Number value, px, or ms, by kind. + base: f64, + em: f64, + percent: f64, + vw: f64, + vh: f64, +} + +impl Quantity { + fn new(kind: Kind, base: f64) -> Quantity { + Quantity { + kind, + base, + em: 0.0, + percent: 0.0, + vw: 0.0, + vh: 0.0, + } + } + + fn scale(self, factor: f64) -> Quantity { + Quantity { + kind: self.kind, + base: self.base * factor, + em: self.em * factor, + percent: self.percent * factor, + vw: self.vw * factor, + vh: self.vh * factor, + } + } + + fn add(self, other: Quantity, sign: f64) -> Option { + (self.kind == other.kind).then_some(Quantity { + kind: self.kind, + base: self.base + sign * other.base, + em: self.em + sign * other.em, + percent: self.percent + sign * other.percent, + vw: self.vw + sign * other.vw, + vh: self.vh + sign * other.vh, + }) + } + + fn from_token(token: &Token) -> Option { + let length = |field: fn(&mut Quantity) -> &mut f64, value: f64| { + let mut quantity = Quantity::new(Kind::Length, 0.0); + *field(&mut quantity) = value; + quantity + }; + match token { + Token::Number(value) => Some(Quantity::new(Kind::Number, *value)), + Token::Percentage(value) => Some(length(|q| &mut q.percent, *value)), + Token::Dimension(value, unit) => match unit.as_str() { + "px" => Some(Quantity::new(Kind::Length, *value)), + "rem" => Some(Quantity::new(Kind::Length, value * ROOT_FONT_SIZE_PX)), + "em" => Some(length(|q| &mut q.em, *value)), + "vw" => Some(length(|q| &mut q.vw, *value)), + "vh" => Some(length(|q| &mut q.vh, *value)), + "ms" => Some(Quantity::new(Kind::Time, *value)), + "s" => Some(Quantity::new(Kind::Time, value * 1000.0)), + _ => None, + }, + Token::Function(name, args) if name == "calc" => calc(args), + Token::Block(inner) => calc(inner), + _ => None, + } + } +} + +/// `calc()` contents: sums of products, standard precedence. +fn calc(tokens: &[Token]) -> Option { + let items = components(tokens); + let mut cursor = 0; + let result = calc_sum(&items, &mut cursor)?; + (cursor == items.len()).then_some(result) +} + +fn calc_sum(items: &[&Token], cursor: &mut usize) -> Option { + let mut total = calc_product(items, cursor)?; + while let Some(Token::Delim(op @ ('+' | '-'))) = items.get(*cursor) { + *cursor += 1; + let sign = if *op == '+' { 1.0 } else { -1.0 }; + let next = calc_product(items, cursor)?; + total = total.add(next, sign)?; + } + Some(total) +} + +fn calc_product(items: &[&Token], cursor: &mut usize) -> Option { + let mut total = Quantity::from_token(items.get(*cursor)?)?; + *cursor += 1; + loop { + match items.get(*cursor) { + Some(Token::Delim('*')) => { + *cursor += 1; + let next = Quantity::from_token(items.get(*cursor)?)?; + *cursor += 1; + total = match (total.kind, next.kind) { + (Kind::Number, _) => next.scale(total.base), + (_, Kind::Number) => total.scale(next.base), + _ => return None, + }; + } + Some(Token::Slash) => { + *cursor += 1; + let next = Quantity::from_token(items.get(*cursor)?)?; + *cursor += 1; + if next.kind != Kind::Number || next.base == 0.0 { + return None; + } + total = total.scale(1.0 / next.base); + } + _ => return Some(total), + } + } +} + +fn single_quantity(tokens: &[Token]) -> Option { + match components(tokens)[..] { + [token] => Quantity::from_token(token), + _ => None, + } +} + +fn parse_length_tokens(tokens: &[Token]) -> Option { + let quantity = single_quantity(tokens)?; + match quantity.kind { + Kind::Length => Some(Length { + px: quantity.base as f32, + em: quantity.em as f32, + percent: quantity.percent as f32, + vw: quantity.vw as f32, + vh: quantity.vh as f32, + }), + // A unitless zero is a length. + Kind::Number if quantity.base == 0.0 => Some(Length::default()), + _ => None, + } +} + +/// One `box-shadow` layer, lengths in px. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Shadow { + pub inset: bool, + pub offset_x: f32, + pub offset_y: f32, + pub blur: f32, + pub spread: f32, + /// `None` when the layer names no colour (it would be `currentcolor`). + pub color: Option, +} + +fn parse_shadow_tokens(tokens: &[Token]) -> Option> { + if let [Token::Ident(name)] = components(tokens)[..] { + if name.eq_ignore_ascii_case("none") { + return Some(Vec::new()); + } + } + split_commas(tokens) + .into_iter() + .map(|layer| { + let mut inset = false; + let mut lengths = Vec::new(); + let mut color = None; + for token in components(layer) { + if let Token::Ident(name) = token { + if name.eq_ignore_ascii_case("inset") { + inset = true; + continue; + } + } + if let Some(length) = parse_length_tokens(std::slice::from_ref(token)) { + lengths.push(length.as_px()?); + } else { + color = Some(parse_color_tokens(std::slice::from_ref(token))?); + } + } + if !(2..=4).contains(&lengths.len()) { + return None; + } + Some(Shadow { + inset, + offset_x: lengths[0], + offset_y: lengths[1], + blur: lengths.get(2).copied().unwrap_or(0.0), + spread: lengths.get(3).copied().unwrap_or(0.0), + color, + }) + }) + .collect() +} + +/// Something in a theme's custom CSS the resolver does not apply. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum UnsupportedCss { + /// A rule whose selector is not `:root`. + Rule(String), + /// A declaration in `:root` that is not a custom property. + Declaration(String), + /// An at-rule (`@media`, `@keyframes`...). + AtRule(String), + /// Text the parser could not make sense of. + Malformed(String), +} + +impl std::fmt::Display for UnsupportedCss { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + UnsupportedCss::Rule(selector) => write!(f, "rule `{selector}`"), + UnsupportedCss::Declaration(property) => write!(f, "declaration `{property}` in :root"), + UnsupportedCss::AtRule(name) => write!(f, "at-rule `{name}`"), + UnsupportedCss::Malformed(text) => write!(f, "malformed CSS `{text}`"), + } + } +} + +/// One custom property declaration from a stylesheet. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomProperty { + pub name: String, + pub value: String, + pub important: bool, +} + +fn strip_comments(css: &str) -> String { + let mut out = String::with_capacity(css.len()); + let mut rest = css; + while let Some(start) = rest.find("/*") { + out.push_str(&rest[..start]); + match rest[start + 2..].find("*/") { + Some(length) => rest = &rest[start + 2 + length + 2..], + None => { + rest = ""; + break; + } + } + } + out.push_str(rest); + out +} + +/// Index of the first `stop` char at depth 0 outside strings, from `from`. +fn find_top_level(text: &str, from: usize, stops: &[char]) -> Option { + let mut depth = 0i32; + let mut quote = None; + let mut escaped = false; + for (offset, c) in text[from..].char_indices() { + if escaped { + escaped = false; + continue; + } + if c == '\\' { + escaped = true; + continue; + } + if let Some(open) = quote { + if c == open { + quote = None; + } + continue; + } + if depth == 0 && stops.contains(&c) { + return Some(from + offset); + } + match c { + '"' | '\'' => quote = Some(c), + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => depth -= 1, + _ => {} + } + } + None +} + +/// Splits `name: value; ...` and keeps the custom properties. +fn parse_declarations(block: &str, unsupported: &mut Vec) -> Vec { + let mut properties = Vec::new(); + let mut start = 0; + while start < block.len() { + let end = find_top_level(block, start, &[';']).unwrap_or(block.len()); + let declaration = block[start..end].trim(); + start = end + 1; + if declaration.is_empty() { + continue; + } + let Some((name, value)) = declaration.split_once(':') else { + unsupported.push(UnsupportedCss::Malformed(declaration.to_owned())); + continue; + }; + let name = name.trim(); + if !name.starts_with("--") { + unsupported.push(UnsupportedCss::Declaration(name.to_owned())); + continue; + } + let mut value = value.trim(); + let mut important = false; + if let Some(bang) = value.rfind('!') { + if value[bang + 1..].trim().eq_ignore_ascii_case("important") { + important = true; + value = value[..bang].trim_end(); + } + } + properties.push(CustomProperty { + name: name.to_owned(), + value: value.to_owned(), + important, + }); + } + properties +} + +/// Reads the custom properties of every `:root` rule of a stylesheet, and +/// lists everything else it holds as unsupported. +pub fn parse_root_custom_properties(css: &str) -> (Vec, Vec) { + let css = strip_comments(css); + let mut properties = Vec::new(); + let mut unsupported = Vec::new(); + let mut start = 0; + while start < css.len() { + let Some(end) = find_top_level(&css, start, &['{', ';', '}']) else { + let rest = css[start..].trim(); + if !rest.is_empty() { + unsupported.push(UnsupportedCss::Malformed(rest.to_owned())); + } + break; + }; + let prelude = css[start..end].trim(); + let terminator = css[end..].chars().next(); + if terminator != Some('{') { + if prelude.starts_with('@') { + unsupported.push(UnsupportedCss::AtRule(at_rule_name(prelude))); + } else if !prelude.is_empty() || terminator == Some('}') { + unsupported.push(UnsupportedCss::Malformed( + css[start..=end].trim().to_owned(), + )); + } + start = end + 1; + continue; + } + let Some(close) = find_top_level(&css, end + 1, &['}']) else { + unsupported.push(UnsupportedCss::Malformed(css[start..].trim().to_owned())); + break; + }; + let block = &css[end + 1..close]; + if prelude.starts_with('@') { + unsupported.push(UnsupportedCss::AtRule(at_rule_name(prelude))); + } else if prelude == ":root" { + properties.extend(parse_declarations(block, &mut unsupported)); + } else { + unsupported.push(UnsupportedCss::Rule(prelude.to_owned())); + } + start = close + 1; + } + (properties, unsupported) +} + +fn at_rule_name(prelude: &str) -> String { + prelude + .split(|c: char| c.is_whitespace() || c == '(' || c == '"' || c == '\'') + .next() + .unwrap_or(prelude) + .to_owned() +} + +/// The `:root` custom properties of `src/app.css`, parsed once. +pub fn app_root_properties() -> &'static [CustomProperty] { + static ROOT: OnceLock> = OnceLock::new(); + ROOT.get_or_init(|| parse_root_custom_properties(APP_CSS).0) +} + +/// The custom properties in effect on ``, ready to resolve. +/// +/// Cascade, lowest first: `:root` of `app.css`, the theme's own CSS, the +/// variables `applyThemeToDocument` writes on the inline style, then the +/// theme's own `!important` declarations (an inline style loses to those). +#[derive(Clone, Debug, Default)] +pub struct ResolvedTheme { + declared: HashMap, + unsupported: Vec, +} + +impl ResolvedTheme { + /// Only `app.css`: what the app paints with before a theme lands. + pub fn root() -> ResolvedTheme { + let mut resolved = ResolvedTheme::default(); + for property in app_root_properties() { + resolved + .declared + .insert(property.name.clone(), property.value.clone()); + } + resolved + } + + /// `app.css`, then everything one theme application writes. + pub fn new(application: &ThemeApplication) -> ResolvedTheme { + let mut resolved = ResolvedTheme::root(); + let (theme_properties, unsupported) = match &application.css { + Some(css) => parse_root_custom_properties(css), + None => (Vec::new(), Vec::new()), + }; + resolved.unsupported = unsupported; + for property in theme_properties + .iter() + .filter(|property| !property.important) + { + resolved + .declared + .insert(property.name.clone(), property.value.clone()); + } + for (name, value) in &application.vars { + resolved.declared.insert((*name).to_owned(), value.clone()); + } + for property in theme_properties + .iter() + .filter(|property| property.important) + { + resolved + .declared + .insert(property.name.clone(), property.value.clone()); + } + resolved + } + + /// A copy with more variables, the way a component sets its own + /// (`--card-custom-color`) on an element below ``. + pub fn with_vars<'a>( + &self, + vars: impl IntoIterator, + ) -> ResolvedTheme { + let mut scoped = self.clone(); + for (name, value) in vars { + scoped.declared.insert(name.to_owned(), value.to_owned()); + } + scoped + } + + /// What the theme's custom CSS holds that this resolver does not apply. + pub fn unsupported_css(&self) -> &[UnsupportedCss] { + &self.unsupported + } + + /// The declared value, as written. + pub fn raw(&self, name: &str) -> Option<&str> { + self.declared.get(name).map(String::as_str) + } + + /// The value with every `var()` replaced: the computed value of a custom + /// property. `None` when it is undefined, cyclic, or uses an undefined + /// variable with no fallback. + pub fn substitute(&self, name: &str) -> Option { + self.substitute_property(name, &mut Vec::new()) + } + + fn substitute_property(&self, name: &str, stack: &mut Vec) -> Option { + if stack.iter().any(|entry| entry == name) { + return None; + } + let value = self.declared.get(name)?; + stack.push(name.to_owned()); + let result = self.substitute_text(value, stack); + stack.pop(); + result + } + + fn substitute_text(&self, text: &str, stack: &mut Vec) -> Option { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(start) = find_var(rest) { + out.push_str(&rest[..start]); + let args_start = start + 4; + let close = find_top_level(rest, args_start, &[')'])?; + let args = &rest[args_start..close]; + let (name, fallback) = match find_top_level(args, 0, &[',']) { + Some(comma) => (args[..comma].trim(), Some(&args[comma + 1..])), + None => (args.trim(), None), + }; + let value = match self.substitute_property(name, stack) { + Some(value) => value, + None => self.substitute_text(js_trim(fallback?), stack)?, + }; + out.push_str(&value); + rest = &rest[close + 1..]; + } + out.push_str(rest); + Some(out.trim().to_owned()) + } + + fn tokens(&self, name: &str) -> Option> { + tokenize(&self.substitute(name)?) + } + + fn expression_tokens(&self, expression: &str) -> Option> { + tokenize(&self.substitute_text(expression, &mut Vec::new())?) + } + + pub fn color(&self, name: &str) -> Option { + parse_color_tokens(&self.tokens(name)?) + } + + pub fn length(&self, name: &str) -> Option { + parse_length_tokens(&self.tokens(name)?) + } + + /// A length that does not depend on layout, in px. + pub fn length_px(&self, name: &str) -> Option { + self.length(name)?.as_px() + } + + pub fn number(&self, name: &str) -> Option { + number_from(&self.tokens(name)?) + } + + /// A time (`260ms`, `calc(260ms * var(--motion-scale))`) in ms. + pub fn duration_ms(&self, name: &str) -> Option { + duration_from(&self.tokens(name)?) + } + + pub fn shadow(&self, name: &str) -> Option> { + parse_shadow_tokens(&self.tokens(name)?) + } + + /// A colour written in a component's CSS, such as + /// `color-mix(in srgb, var(--fg) 65%, transparent)`. + pub fn eval_color(&self, expression: &str) -> Option { + parse_color_tokens(&self.expression_tokens(expression)?) + } + + pub fn eval_length(&self, expression: &str) -> Option { + parse_length_tokens(&self.expression_tokens(expression)?) + } + + pub fn eval_number(&self, expression: &str) -> Option { + number_from(&self.expression_tokens(expression)?) + } + + pub fn eval_duration_ms(&self, expression: &str) -> Option { + duration_from(&self.expression_tokens(expression)?) + } + + pub fn eval_shadow(&self, expression: &str) -> Option> { + parse_shadow_tokens(&self.expression_tokens(expression)?) + } + + /// Every declared name, in no particular order. + pub fn names(&self) -> impl Iterator { + self.declared.keys().map(String::as_str) + } +} + +fn number_from(tokens: &[Token]) -> Option { + let quantity = single_quantity(tokens)?; + (quantity.kind == Kind::Number).then_some(quantity.base as f32) +} + +fn duration_from(tokens: &[Token]) -> Option { + let quantity = single_quantity(tokens)?; + (quantity.kind == Kind::Time).then_some(quantity.base as f32) +} + +/// Byte offset of the next `var(` that starts a function name. +fn find_var(text: &str) -> Option { + let bytes = text.as_bytes(); + (0..text.len()).find(|&index| { + bytes[index..].len() >= 4 + && bytes[index..index + 4].eq_ignore_ascii_case(b"var(") + && (index == 0 + || !(bytes[index - 1].is_ascii_alphanumeric() + || matches!(bytes[index - 1], b'-' | b'_'))) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn theme(vars: &[(&str, &str)]) -> ResolvedTheme { + ResolvedTheme::root().with_vars(vars.iter().copied()) + } + + #[test] + fn reads_the_app_css_root_block() { + let root = ResolvedTheme::root(); + assert_eq!(root.raw("--bg-rgb"), Some("9 9 11")); + assert_eq!(root.raw("--z-menu"), Some("1200")); + assert!(root + .raw("--font-stack-base") + .is_some_and(|stack| stack.ends_with("sans-serif"))); + assert_eq!(root.color("--bg").map(Rgba::to_u8), Some([9, 9, 11, 255])); + assert_eq!(root.number("--density-scale"), Some(1.0)); + assert_eq!(root.length_px("--grid-card-width"), Some(100.0)); + assert_eq!(root.duration_ms("--motion-card-entrance"), Some(260.0)); + assert_eq!(root.duration_ms("--motion-avatar-defer"), Some(380.0)); + assert_eq!(root.length_px("--avatar-radius"), Some(6.0)); + assert_eq!(root.number("--z-wallpaper"), Some(-2.0)); + } + + #[test] + fn follows_var_chains_and_fallbacks() { + let resolved = theme(&[ + ("--a", "var(--b)"), + ("--b", "var(--c, #00ff00)"), + ("--loop", "var(--loop)"), + ("--uses-loop", "var(--loop, red)"), + ("--nested", "var(--missing, var(--also-missing, 4px))"), + ]); + assert_eq!(resolved.substitute("--a").as_deref(), Some("#00ff00")); + assert_eq!(resolved.substitute("--loop"), None); + assert_eq!( + resolved.color("--uses-loop").map(Rgba::to_u8), + Some([255, 0, 0, 255]) + ); + assert_eq!(resolved.length_px("--nested"), Some(4.0)); + assert_eq!(resolved.substitute("--undefined"), None); + } + + #[test] + fn evaluates_calc_forms_the_css_uses() { + let resolved = theme(&[("--density-scale", "0.88"), ("--motion-scale", "0")]); + assert_eq!(resolved.length_px("--grid-card-padding"), Some(8.0 * 0.88)); + assert_eq!(resolved.duration_ms("--motion-page-entrance"), Some(0.0)); + let edge = resolved.eval_length("calc(100% - 26px)").unwrap(); + assert_eq!(edge.resolve(16.0, 200.0, (0.0, 0.0)), 174.0); + assert_eq!( + resolved + .eval_length("calc(var(--grid-card-width) + 130px)") + .and_then(Length::as_px), + Some(218.0) + ); + assert_eq!( + resolved + .eval_length("calc(100vw - 24px)") + .map(|l| l.resolve(16.0, 0.0, (800.0, 600.0))), + Some(776.0) + ); + assert_eq!( + resolved.eval_length("1.5rem").and_then(Length::as_px), + Some(24.0) + ); + assert_eq!(resolved.eval_length("calc(2px * 3px)"), None); + assert_eq!(resolved.eval_length("calc(2px + 3)"), None); + assert_eq!(resolved.eval_number("calc((1 + 2) * 2)"), Some(6.0)); + } + + #[test] + fn mixes_the_accent_text_colour() { + let root = ResolvedTheme::root(); + // color-mix(in srgb, #2563eb 55%, #fafafa) + let accent_text = root.color("--accent-text").unwrap(); + let expected = [ + (0x25 as f32 * 0.55 + 0xfa as f32 * 0.45) / 255.0, + (0x63 as f32 * 0.55 + 0xfa as f32 * 0.45) / 255.0, + (0xeb as f32 * 0.55 + 0xfa as f32 * 0.45) / 255.0, + ]; + assert!((accent_text.r - expected[0]).abs() < 1e-5); + assert!((accent_text.g - expected[1]).abs() < 1e-5); + assert!((accent_text.b - expected[2]).abs() < 1e-5); + assert_eq!(accent_text.a, 1.0); + let ring = root + .eval_color("color-mix(in srgb, var(--fg) 65%, transparent)") + .unwrap(); + assert!((ring.a - 0.65).abs() < 1e-6); + assert_eq!(ring.to_u8()[..3], [250, 250, 250]); + } + + #[test] + fn parses_shadows() { + let root = ResolvedTheme::root(); + let low = root.shadow("--elevation-low").unwrap(); + assert_eq!(low.len(), 1); + assert_eq!( + (low[0].offset_x, low[0].offset_y, low[0].blur, low[0].spread), + (0.0, 2.0, 8.0, 0.0) + ); + assert_eq!(low[0].color, Some(Rgba::new(0.0, 0.0, 0.0, 0.18))); + let glaze = root + .eval_shadow("inset 0 1px 0 rgba(255, 255, 255, 0.34), inset 0 12px 24px -18px rgba(255, 255, 255, 0.4)") + .unwrap(); + assert_eq!(glaze.len(), 2); + assert!(glaze[1].inset); + assert_eq!(glaze[1].spread, -18.0); + assert_eq!(root.eval_shadow("none"), Some(Vec::new())); + } + + #[test] + fn reads_custom_css_and_reports_the_rest() { + let (properties, unsupported) = parse_root_custom_properties( + "/* note */ @import x; :root { --a: 1px; color: red; --b: rgb(1 2 3) !important; }\n.card { color: red; }\n@media (x) { :root { --c: 2 } }", + ); + assert_eq!( + properties, + vec![ + CustomProperty { + name: "--a".into(), + value: "1px".into(), + important: false + }, + CustomProperty { + name: "--b".into(), + value: "rgb(1 2 3)".into(), + important: true + }, + ] + ); + assert_eq!( + unsupported, + vec![ + UnsupportedCss::AtRule("@import".into()), + UnsupportedCss::Declaration("color".into()), + UnsupportedCss::Rule(".card".into()), + UnsupportedCss::AtRule("@media".into()), + ] + ); + } +} diff --git a/crates/accshift-theme/src/css_safety.rs b/crates/accshift-theme/src/css_safety.rs new file mode 100644 index 0000000..4570548 --- /dev/null +++ b/crates/accshift-theme/src/css_safety.rs @@ -0,0 +1,227 @@ +//! Port of `src/lib/theme/cssSafety.ts`: the filter between a shared theme +//! file and a network request. + +use crate::js::is_js_space; + +/// Strips comments and resolves escapes the way the CSS tokenizer does, so +/// `u/**/rl(`, `\75rl(` and `u\rl(` all read as `url(`. +fn normalize_css_for_safety(css: &str) -> String { + let mut without_comments = String::with_capacity(css.len()); + let mut rest = css; + while let Some(start) = rest.find("/*") { + let Some(length) = rest[start + 2..].find("*/") else { + break; + }; + without_comments.push_str(&rest[..start]); + rest = &rest[start + 2 + length + 2..]; + } + without_comments.push_str(rest); + + let mut out = String::with_capacity(without_comments.len()); + let mut chars = without_comments.chars().peekable(); + while let Some(c) = chars.next() { + if c != '\\' { + out.push(c); + continue; + } + let mut hex = String::new(); + while hex.len() < 6 { + match chars.peek() { + Some(next) if next.is_ascii_hexdigit() => { + hex.push(*next); + chars.next(); + } + _ => break, + } + } + if !hex.is_empty() { + // One whitespace after a hex escape belongs to the escape. + match chars.peek() { + Some('\r') => { + chars.next(); + if chars.peek() == Some(&'\n') { + chars.next(); + } + } + Some(' ' | '\t' | '\u{c}' | '\n') => { + chars.next(); + } + _ => {} + } + let code = u32::from_str_radix(&hex, 16).unwrap_or(0); + if code != 0 && code <= 0x10ffff { + out.push(char::from_u32(code).unwrap_or('\u{fffd}')); + } + continue; + } + match chars.next() { + // An escaped newline is dropped, `\r\n` included. + Some('\r') => { + if chars.peek() == Some(&'\n') { + chars.next(); + } + } + Some('\n' | '\u{c}') => {} + Some(other) => out.push(other), + None => out.push('\\'), + } + } + out +} + +fn is_word_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' +} + +/// One forbidden construct: `lead`, ASCII case-insensitive, then optional +/// whitespace and `tail` when there is one. +struct Forbidden { + label: &'static str, + lead: &'static str, + /// `\b` before the lead. + boundary_before: bool, + /// `\b` after the lead (only `@import\b`). + boundary_after: bool, + /// `\s*` then this, when set. + tail: Option<&'static str>, +} + +const FORBIDDEN_CSS: &[Forbidden] = &[ + Forbidden { + label: "@import", + lead: "@import", + boundary_before: false, + boundary_after: true, + tail: None, + }, + Forbidden { + label: "url()", + lead: "url", + boundary_before: true, + boundary_after: false, + tail: Some("("), + }, + Forbidden { + label: "image-set()", + lead: "image-set", + boundary_before: false, + boundary_after: false, + tail: Some("("), + }, + Forbidden { + label: "image()", + lead: "image", + boundary_before: true, + boundary_after: false, + tail: Some("("), + }, + Forbidden { + label: "src()", + lead: "src", + boundary_before: true, + boundary_after: false, + tail: Some("("), + }, + Forbidden { + label: "expression()", + lead: "expression", + boundary_before: true, + boundary_after: false, + tail: Some("("), + }, + Forbidden { + label: "javascript:", + lead: "javascript", + boundary_before: false, + boundary_after: false, + tail: Some(":"), + }, + Forbidden { + label: "-moz-binding", + lead: "-moz-binding", + boundary_before: false, + boundary_after: false, + tail: None, + }, + Forbidden { + label: " bool { + haystack.len() >= needle.len() + && haystack[..needle.len()].eq_ignore_ascii_case(needle.as_bytes()) +} + +impl Forbidden { + fn matches_at(&self, text: &str, index: usize) -> bool { + let bytes = text.as_bytes(); + if !starts_with_ci(&bytes[index..], self.lead) { + return false; + } + if self.boundary_before && index > 0 && is_word_byte(bytes[index - 1]) { + return false; + } + let after = index + self.lead.len(); + if self.boundary_after && bytes.get(after).is_some_and(|b| is_word_byte(*b)) { + return false; + } + match self.tail { + None => true, + Some(tail) => { + let rest = text[after..].trim_start_matches(is_js_space); + starts_with_ci(rest.as_bytes(), tail) + } + } + } + + fn found_in(&self, text: &str) -> bool { + // Every lead starts with an ASCII byte, so a match never starts inside + // a multi-byte character. + (0..text.len()).any(|index| self.matches_at(text, index)) + } +} + +/// The construct that makes this CSS unusable, or `None` when it is clean. +pub fn unsafe_css_construct(css: &str) -> Option<&'static str> { + let normalized = normalize_css_for_safety(css); + FORBIDDEN_CSS + .iter() + .find(|entry| entry.found_in(&normalized)) + .map(|entry| entry.label) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_escapes_and_comments() { + assert_eq!(normalize_css_for_safety("u/**/rl("), "url("); + assert_eq!(normalize_css_for_safety("\\75 \\r\\6C("), "url("); + assert_eq!(normalize_css_for_safety("a\\\nb"), "ab"); + assert_eq!(normalize_css_for_safety("/* open"), "/* open"); + assert_eq!(normalize_css_for_safety("end\\"), "end\\"); + assert_eq!(normalize_css_for_safety("\\0 x"), "x"); + } + + #[test] + fn respects_word_boundaries() { + assert_eq!(unsafe_css_construct(".curl(x)"), None); + assert_eq!(unsafe_css_construct("@imports"), None); + assert_eq!( + unsafe_css_construct("a { b: -webkit-image-set(x) }"), + Some("image-set()") + ); + assert_eq!( + unsafe_css_construct("a { b: -webkit-image(x) }"), + Some("image()") + ); + assert_eq!(unsafe_css_construct(" bool { + matches!( + c, + '\t' | '\n' | '\u{b}' | '\u{c}' | '\r' | ' ' | '\u{a0}' | '\u{1680}' | '\u{2000}' + ..='\u{200a}' + | '\u{2028}' + | '\u{2029}' + | '\u{202f}' + | '\u{205f}' + | '\u{3000}' + | '\u{feff}' + ) +} + +pub(crate) fn js_trim(value: &str) -> &str { + value.trim_matches(is_js_space) +} + +/// `value.length` in JavaScript. +pub(crate) fn utf16_len(value: &str) -> usize { + value.encode_utf16().count() +} + +/// `value.slice(0, max)` in JavaScript, never splitting a character: a +/// surrogate pair that straddles the cut is dropped whole. +pub(crate) fn utf16_prefix(value: &str, max: usize) -> &str { + let mut units = 0; + for (index, c) in value.char_indices() { + units += c.len_utf16(); + if units > max { + return &value[..index]; + } + } + value +} + +/// `String(number)` in JavaScript: shortest round-trip digits, exponent past +/// 1e21 and below 1e-6, `0` for negative zero. +pub fn js_number_to_string(value: f64) -> String { + if value.is_nan() { + return "NaN".to_owned(); + } + if value == 0.0 { + return "0".to_owned(); + } + if value.is_infinite() { + return if value > 0.0 { "Infinity" } else { "-Infinity" }.to_owned(); + } + let magnitude = value.abs(); + if !(1e-6..1e21).contains(&magnitude) { + let formatted = format!("{value:e}"); + let (mantissa, exponent) = formatted.split_once('e').unwrap_or((&formatted, "0")); + let sign = if exponent.starts_with('-') { "" } else { "+" }; + return format!("{mantissa}e{sign}{exponent}"); + } + format!("{value}") +} + +/// `parseFloat` in JavaScript: the longest numeric prefix, NaN when there is +/// none. +pub(crate) fn js_parse_float(input: &str) -> f64 { + let text = input.trim_start_matches(is_js_space); + let bytes = text.as_bytes(); + let mut end = 0; + if matches!(bytes.first(), Some(b'+' | b'-')) { + end = 1; + } + if text[end..].starts_with("Infinity") { + return if text.starts_with('-') { + f64::NEG_INFINITY + } else { + f64::INFINITY + }; + } + let int_digits = bytes[end..] + .iter() + .take_while(|b| b.is_ascii_digit()) + .count(); + end += int_digits; + let mut frac_digits = 0; + if bytes.get(end) == Some(&b'.') { + frac_digits = bytes[end + 1..] + .iter() + .take_while(|b| b.is_ascii_digit()) + .count(); + if int_digits > 0 || frac_digits > 0 { + end += 1 + frac_digits; + } + } + if int_digits == 0 && frac_digits == 0 { + return f64::NAN; + } + if matches!(bytes.get(end), Some(b'e' | b'E')) { + let mut cursor = end + 1; + if matches!(bytes.get(cursor), Some(b'+' | b'-')) { + cursor += 1; + } + let exp_digits = bytes[cursor..] + .iter() + .take_while(|b| b.is_ascii_digit()) + .count(); + if exp_digits > 0 { + end = cursor + exp_digits; + } + } + text[..end].parse().unwrap_or(f64::NAN) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prints_numbers_like_javascript() { + assert_eq!(js_number_to_string(1.0), "1"); + assert_eq!(js_number_to_string(0.0), "0"); + assert_eq!(js_number_to_string(-0.0), "0"); + assert_eq!(js_number_to_string(0.55 + 0.14), "0.6900000000000001"); + assert_eq!(js_number_to_string(0.88), "0.88"); + assert_eq!(js_number_to_string(1e21), "1e+21"); + assert_eq!(js_number_to_string(1.5e-7), "1.5e-7"); + assert_eq!(js_number_to_string(0.000001), "0.000001"); + } + + #[test] + fn parses_floats_like_javascript() { + assert_eq!(js_parse_float("50%"), 50.0); + assert_eq!(js_parse_float(" .5px"), 0.5); + assert_eq!(js_parse_float("5."), 5.0); + assert_eq!(js_parse_float("1e3x"), 1000.0); + assert_eq!(js_parse_float("1e"), 1.0); + assert!(js_parse_float("abc").is_nan()); + assert!(js_parse_float(".").is_nan()); + } + + #[test] + fn slices_and_counts_utf16() { + assert_eq!(utf16_len("a\u{1F600}"), 3); + assert_eq!(utf16_prefix("a\u{1F600}b", 2), "a"); + assert_eq!(utf16_prefix("abc", 2), "ab"); + assert_eq!(js_trim("\u{feff} x \u{2028}"), "x"); + } +} diff --git a/crates/accshift-theme/src/lib.rs b/crates/accshift-theme/src/lib.rs new file mode 100644 index 0000000..7f45da3 --- /dev/null +++ b/crates/accshift-theme/src/lib.rs @@ -0,0 +1,61 @@ +//! The accshift theme engine, ported from `src/lib/theme/*.ts` so the egui UI +//! paints with exactly what the browser computes. +//! +//! - [`tokens`]: the token contract, specs and value validation. +//! - [`schema`]: theme files: parse, validate, serialize, duplicate, resolve +//! through `extends`. +//! - [`themes`]: built-in themes, the custom theme registry, surface +//! opacities and the CSS variables a theme writes on ``. +//! - [`css`]: those variables plus the `:root` of `src/app.css`, resolved into +//! [`Rgba`] colours, [`Length`]s, numbers, durations and shadows. +//! - [`contrast`], [`css_safety`], [`motion`], [`backdrop`]: the rest of the +//! TypeScript module, one file each. +//! +//! ``` +//! use accshift_theme::{theme_application, ResolvedTheme, ThemeRegistry}; +//! +//! let registry = ThemeRegistry::new(); +//! let theme = registry.theme(Some("glass-dark")); +//! let applied = theme_application(&theme, 85.0, true); +//! let resolved = ResolvedTheme::new(&applied); +//! let card = resolved.color("--bg-card").unwrap(); +//! assert_eq!(card.to_u8(), [19, 19, 24, 166]); +//! assert_eq!(resolved.length_px("--radius-md"), Some(8.0)); +//! ``` + +pub mod backdrop; +pub mod color; +pub mod contrast; +pub mod css; +pub mod css_safety; +mod js; +pub mod motion; +pub mod schema; +pub mod themes; +pub mod tokens; + +pub use backdrop::{window_backdrop, MacEffect, WindowBackdrop}; +pub use color::Rgba; +pub use css::{ + app_root_properties, parse_root_custom_properties, CustomProperty, Length, ResolvedTheme, + Shadow, UnsupportedCss, +}; +pub use css_safety::unsafe_css_construct; +pub use js::js_number_to_string; +pub use motion::{motion_attribute, AnimationsMode, MotionAttribute}; +pub use schema::{ + document_is_liquid_glass, duplicate_theme_document, parse_theme_json, parse_theme_value, + resolve_theme_tokens, serialize_theme_document, validate_theme_document, ColorScheme, + IssueLevel, ResolvedTokens, RootTokens, ThemeDocument, ThemeIssue, ThemeIssueCode, + ThemeParseError, ThemeParseResult, MAX_THEME_CSS_LENGTH, +}; +pub use themes::{ + built_in_documents, export_theme_json, import_theme_json, is_built_in_theme, + resolve_theme_surface_opacities, root_tokens, theme_application, AppTheme, BuiltInOverwrite, + CustomThemePayload, PendingSave, ThemeApplication, ThemeRegistry, ThemeSurfaceOpacities, + DEFAULT_THEME_ID, +}; +pub use tokens::{ + is_valid_token_value, ThemeTokens, TokenGroup, TokenKey, TokenKind, TokenSpec, + THEME_CONTRACT_VERSION, THEME_TOKEN_SPECS, +}; diff --git a/crates/accshift-theme/src/motion.rs b/crates/accshift-theme/src/motion.rs new file mode 100644 index 0000000..09c41f7 --- /dev/null +++ b/crates/accshift-theme/src/motion.rs @@ -0,0 +1,81 @@ +//! Port of the motion mode of `src/lib/theme/motion.ts`: what +//! `data-motion` on `` says. Listening to the OS setting stays with the +//! app; it calls [`motion_attribute`] again when that setting changes. + +/// The animations setting (`AnimationsMode` in the settings types). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum AnimationsMode { + System, + On, + Off, +} + +impl AnimationsMode { + pub fn from_name(name: &str) -> Option { + match name { + "system" => Some(AnimationsMode::System), + "on" => Some(AnimationsMode::On), + "off" => Some(AnimationsMode::Off), + _ => None, + } + } + + /// Whether the OS reduced-motion setting must be watched (`system` only). + pub fn follows_os(self) -> bool { + self == AnimationsMode::System + } +} + +/// `data-motion` on ``. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MotionAttribute { + Full, + Reduced, +} + +impl MotionAttribute { + pub fn as_str(self) -> &'static str { + match self { + MotionAttribute::Full => "full", + MotionAttribute::Reduced => "reduced", + } + } +} + +/// `off`, or `system` while the OS asks for reduced motion, reduces. +pub fn motion_attribute(mode: AnimationsMode, os_prefers_reduced_motion: bool) -> MotionAttribute { + let reduced = mode == AnimationsMode::Off + || (mode == AnimationsMode::System && os_prefers_reduced_motion); + if reduced { + MotionAttribute::Reduced + } else { + MotionAttribute::Full + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_setting_wins_over_the_os_except_in_system_mode() { + assert_eq!( + motion_attribute(AnimationsMode::On, true), + MotionAttribute::Full + ); + assert_eq!( + motion_attribute(AnimationsMode::Off, false), + MotionAttribute::Reduced + ); + assert_eq!( + motion_attribute(AnimationsMode::System, true), + MotionAttribute::Reduced + ); + assert_eq!( + motion_attribute(AnimationsMode::System, false), + MotionAttribute::Full + ); + assert!(AnimationsMode::System.follows_os()); + assert!(!AnimationsMode::Off.follows_os()); + } +} diff --git a/crates/accshift-theme/src/schema.rs b/crates/accshift-theme/src/schema.rs new file mode 100644 index 0000000..d55b5f6 --- /dev/null +++ b/crates/accshift-theme/src/schema.rs @@ -0,0 +1,644 @@ +//! Port of `src/lib/theme/schema.ts`: theme files, their parsing, export, +//! resolution through `extends`, and validation. + +use std::collections::{BTreeMap, HashSet}; + +use serde_json::{Map, Value}; + +use crate::contrast::{blend, contrast_ratio, parse_color, round_ratio}; +use crate::css_safety::unsafe_css_construct; +use crate::js::{js_trim, utf16_len, utf16_prefix}; +use crate::tokens::{ + get_token_spec, is_essential_token, is_valid_token_value, ThemeTokens, TokenKey, TokenKind, + THEME_CONTRACT_VERSION, +}; + +pub const MAX_THEME_CSS_LENGTH: usize = 20000; +const MAX_NAME_LENGTH: usize = 64; +const MAX_META_LENGTH: usize = 64; +/// Guard against a file that extends a chain built to spin the resolver. +pub(crate) const MAX_EXTENDS_DEPTH: usize = 8; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ColorScheme { + Dark, + Light, +} + +impl ColorScheme { + pub fn as_str(self) -> &'static str { + match self { + ColorScheme::Dark => "dark", + ColorScheme::Light => "light", + } + } +} + +/// A theme file, exactly as written on disk and exchanged between users. +/// +/// Tokens are keyed by `TokenKey`, so a document can only hold known keys; the +/// values are unchecked until resolution or validation, as in TypeScript. +#[derive(Clone, Debug, PartialEq)] +pub struct ThemeDocument { + pub schema_version: u32, + pub id: String, + pub name: String, + pub author: Option, + /// The author's own version string, free form. + pub version: Option, + pub color_scheme: ColorScheme, + pub extends: Option, + pub glass: Option, + pub tokens: BTreeMap, + /// Raw CSS applied with the theme. Never inherited through `extends`. + pub css: Option, +} + +impl ThemeDocument { + /// The base this document names, if any (an empty string names none). + pub fn extends_id(&self) -> Option<&str> { + self.extends.as_deref().filter(|id| !id.is_empty()) + } + + /// A document read as-is from trusted JSON (the built-in files), with no + /// validation: what `midnightDocument as ThemeDocument` does. + pub(crate) fn from_trusted_json(json: &str) -> ThemeDocument { + let value: Value = serde_json::from_str(json).expect("built-in theme JSON"); + let text = |key: &str| value.get(key).and_then(Value::as_str).map(str::to_owned); + let tokens = value + .get("tokens") + .and_then(Value::as_object) + .map(|tokens| { + tokens + .iter() + .filter_map(|(key, value)| { + Some((TokenKey::from_name(key)?, value.as_str()?.to_owned())) + }) + .collect() + }) + .unwrap_or_default(); + ThemeDocument { + schema_version: value + .get("schemaVersion") + .and_then(Value::as_u64) + .and_then(|version| u32::try_from(version).ok()) + .unwrap_or(1), + id: text("id").unwrap_or_default(), + name: text("name").unwrap_or_default(), + author: text("author"), + version: text("version"), + color_scheme: if text("colorScheme").as_deref() == Some("light") { + ColorScheme::Light + } else { + ColorScheme::Dark + }, + extends: text("extends"), + glass: value.get("glass").and_then(Value::as_bool), + tokens, + css: text("css"), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ThemeParseError { + InvalidJson, + InvalidShape, + InvalidId, + InvalidName, + UnsupportedVersion, +} + +impl ThemeParseError { + pub fn code(self) -> &'static str { + match self { + ThemeParseError::InvalidJson => "invalidJson", + ThemeParseError::InvalidShape => "invalidShape", + ThemeParseError::InvalidId => "invalidId", + ThemeParseError::InvalidName => "invalidName", + ThemeParseError::UnsupportedVersion => "unsupportedVersion", + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ThemeParseResult { + pub document: Option, + pub error: Option, + /// Contract version the file was written for, when older than ours. + pub migrated_from: Option, + /// Keys refused because they are unknown or malformed, in file order. + pub rejected_tokens: Vec, + /// Construct that got the custom CSS dropped. + pub rejected_css: Option<&'static str>, + /// Version the file declares. + pub declared_version: i64, +} + +impl ThemeParseResult { + fn failed(error: ThemeParseError, declared_version: i64) -> ThemeParseResult { + ThemeParseResult { + document: None, + error: Some(error), + migrated_from: None, + rejected_tokens: Vec::new(), + rejected_css: None, + declared_version, + } + } +} + +/// `^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$` +pub fn is_valid_theme_id(id: &str) -> bool { + let mut bytes = id.bytes(); + bytes + .next() + .is_some_and(|first| first.is_ascii_alphanumeric()) + && id.len() <= 64 + && bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') +} + +/// True when this document is Liquid Glass or walks to it through `extends`. +pub fn document_is_liquid_glass<'a>( + document: &'a ThemeDocument, + get_document: impl Fn(&str) -> Option<&'a ThemeDocument>, +) -> bool { + let mut current = Some(document); + let mut seen = HashSet::new(); + let mut depth = 0; + while let Some(doc) = current { + if depth >= MAX_EXTENDS_DEPTH { + break; + } + if doc.id == "liquid-glass" { + return true; + } + let Some(parent_id) = doc.extends_id() else { + return false; + }; + if seen.contains(parent_id) { + return false; + } + if parent_id == "liquid-glass" { + return true; + } + seen.insert(parent_id.to_owned()); + current = get_document(parent_id); + depth += 1; + } + false +} + +fn read_string(value: Option<&Value>, max_length: usize) -> String { + match value.and_then(Value::as_str) { + Some(text) => utf16_prefix(js_trim(text), max_length).to_owned(), + None => String::new(), + } +} + +fn read_tokens(raw: Option<&Value>) -> (BTreeMap, Vec) { + let mut tokens = BTreeMap::new(); + let mut rejected = Vec::new(); + let Some(object) = raw.and_then(Value::as_object) else { + return (tokens, rejected); + }; + for (key, value) in object { + match (TokenKey::from_name(key), value.as_str()) { + (Some(token), Some(text)) if is_valid_token_value(key, text) => { + tokens.insert(token, js_trim(text).to_owned()); + } + _ => rejected.push(key.clone()), + } + } + (tokens, rejected) +} + +/// `parseThemeDocument` for a JSON string. +pub fn parse_theme_json(input: &str) -> ThemeParseResult { + match serde_json::from_str::(input) { + Ok(value) => parse_theme_value(&value), + Err(_) => ThemeParseResult::failed( + ThemeParseError::InvalidJson, + i64::from(THEME_CONTRACT_VERSION), + ), + } +} + +/// `parseThemeDocument` for an already parsed value (an IPC payload). +pub fn parse_theme_value(raw: &Value) -> ThemeParseResult { + let current = i64::from(THEME_CONTRACT_VERSION); + let Some(record) = raw.as_object() else { + return ThemeParseResult::failed(ThemeParseError::InvalidShape, current); + }; + let declared_version = record + .get("schemaVersion") + .and_then(Value::as_f64) + .filter(|version| version.is_finite()) + .map(|version| version.trunc() as i64) + .unwrap_or(1); + if declared_version > current { + return ThemeParseResult::failed(ThemeParseError::UnsupportedVersion, declared_version); + } + + let id = read_string(record.get("id"), 64); + if !is_valid_theme_id(&id) { + return ThemeParseResult::failed(ThemeParseError::InvalidId, declared_version); + } + let name = read_string(record.get("name"), MAX_NAME_LENGTH); + if name.is_empty() { + return ThemeParseResult::failed(ThemeParseError::InvalidName, declared_version); + } + + let color_scheme = if record.get("colorScheme").and_then(Value::as_str) == Some("light") { + ColorScheme::Light + } else { + ColorScheme::Dark + }; + let raw_extends = read_string(record.get("extends"), 64); + let extends_id = is_valid_theme_id(&raw_extends).then_some(raw_extends); + let (tokens, rejected_tokens) = read_tokens(record.get("tokens")); + let author = read_string(record.get("author"), MAX_META_LENGTH); + let version = read_string(record.get("version"), MAX_META_LENGTH); + + // A version 1 file never named a base; its own scheme's built-in is it. + let extends = + extends_id.or_else(|| (declared_version < 2).then(|| color_scheme.as_str().to_owned())); + + let mut rejected_css = None; + let mut css = None; + if let Some(text) = record.get("css").and_then(Value::as_str) { + let text = utf16_prefix(text, MAX_THEME_CSS_LENGTH); + if !js_trim(text).is_empty() { + rejected_css = unsafe_css_construct(text); + if rejected_css.is_none() { + css = Some(text.to_owned()); + } + } + } + + let document = ThemeDocument { + schema_version: THEME_CONTRACT_VERSION, + id, + name, + author: (!author.is_empty()).then_some(author), + version: (!version.is_empty()).then_some(version), + color_scheme, + extends, + glass: record.get("glass").and_then(Value::as_bool), + tokens, + css, + }; + ThemeParseResult { + document: Some(document), + error: None, + migrated_from: (declared_version < current).then_some(declared_version), + rejected_tokens, + rejected_css, + declared_version, + } +} + +/// Stable key order so a re-export produces the same file, byte for byte. +pub fn serialize_theme_document(document: &ThemeDocument) -> String { + let mut tokens = Map::new(); + for (key, value) in &document.tokens { + tokens.insert(key.name().to_owned(), Value::String(value.clone())); + } + let mut ordered = Map::new(); + ordered.insert("schemaVersion".into(), Value::from(document.schema_version)); + ordered.insert("id".into(), Value::String(document.id.clone())); + ordered.insert("name".into(), Value::String(document.name.clone())); + if let Some(author) = document.author.as_ref().filter(|text| !text.is_empty()) { + ordered.insert("author".into(), Value::String(author.clone())); + } + if let Some(version) = document.version.as_ref().filter(|text| !text.is_empty()) { + ordered.insert("version".into(), Value::String(version.clone())); + } + ordered.insert( + "colorScheme".into(), + Value::String(document.color_scheme.as_str().to_owned()), + ); + if let Some(base) = document.extends_id() { + ordered.insert("extends".into(), Value::String(base.to_owned())); + } + if document.glass == Some(true) { + ordered.insert("glass".into(), Value::Bool(true)); + } + ordered.insert("tokens".into(), Value::Object(tokens)); + if let Some(css) = document.css.as_ref().filter(|css| !js_trim(css).is_empty()) { + ordered.insert("css".into(), Value::String(css.clone())); + } + serde_json::to_string_pretty(&Value::Object(ordered)).unwrap_or_default() +} + +/// The two roots every resolution ends on. +#[derive(Clone, Debug, PartialEq)] +pub struct RootTokens { + pub dark: ThemeTokens, + pub light: ThemeTokens, +} + +impl RootTokens { + pub fn for_scheme(&self, scheme: ColorScheme) -> &ThemeTokens { + match scheme { + ColorScheme::Dark => &self.dark, + ColorScheme::Light => &self.light, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ResolvedTokens { + pub tokens: ThemeTokens, + /// Documents walked, nearest first, the document itself included. + pub chain: Vec, + /// Tokens no document in the chain sets, filled from the built-in root. + pub filled_from_root: Vec, + /// `extends` pointed at a theme that does not exist, or at a cycle. + pub broken_extends: Option, +} + +/// Flattens a document and everything it extends into a complete token set. +pub fn resolve_theme_tokens<'a>( + document: &'a ThemeDocument, + get_document: impl Fn(&str) -> Option<&'a ThemeDocument>, + roots: &RootTokens, +) -> ResolvedTokens { + let mut chain = vec![document.id.clone()]; + let mut layers = vec![&document.tokens]; + let mut seen: HashSet<&str> = HashSet::from([document.id.as_str()]); + let mut broken_extends = None; + + let mut current = document; + while let Some(parent_id) = current.extends_id() { + if chain.len() >= MAX_EXTENDS_DEPTH { + break; + } + if seen.contains(parent_id) { + broken_extends = Some(parent_id.to_owned()); + break; + } + let Some(parent) = get_document(parent_id) else { + broken_extends = Some(parent_id.to_owned()); + break; + }; + seen.insert(parent_id); + chain.push(parent_id.to_owned()); + layers.push(&parent.tokens); + current = parent; + } + + let mut tokens = roots.for_scheme(document.color_scheme).clone(); + let mut filled_from_root = Vec::new(); + for key in TokenKey::ALL { + match layers.iter().find_map(|layer| layer.get(&key)) { + Some(value) => tokens.set(key, value.clone()), + None => filled_from_root.push(key), + } + } + ResolvedTokens { + tokens, + chain, + filled_from_root, + broken_extends, + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum IssueLevel { + Error, + Warning, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ThemeIssueCode { + InvalidValue, + UnknownToken, + MissingToken, + UnknownBase, + UnsafeCss, + CssTooLong, + Contrast, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ThemeIssue { + pub level: IssueLevel, + pub code: ThemeIssueCode, + /// Token the issue is about, so the editor can flag its row. + pub token: Option, + pub against: Option, + pub ratio: Option, + pub target: Option, + pub expected: Option, + pub base: Option, + /// Forbidden construct found in the custom CSS. + pub construct: Option<&'static str>, + /// UTF-16 length of the custom CSS, against its cap. + pub length: Option, + pub limit: Option, +} + +impl ThemeIssue { + fn new(level: IssueLevel, code: ThemeIssueCode) -> ThemeIssue { + ThemeIssue { + level, + code, + token: None, + against: None, + ratio: None, + target: None, + expected: None, + base: None, + construct: None, + length: None, + limit: None, + } + } +} + +struct ContrastCheck { + foreground: TokenKey, + background: TokenKey, + target: f64, + floor: f64, +} + +const CONTRAST_CHECKS: [ContrastCheck; 10] = [ + ContrastCheck { + foreground: TokenKey::Fg, + background: TokenKey::BgRgb, + target: 4.5, + floor: 3.0, + }, + ContrastCheck { + foreground: TokenKey::Fg, + background: TokenKey::BgCard, + target: 4.5, + floor: 3.0, + }, + ContrastCheck { + foreground: TokenKey::FgMuted, + background: TokenKey::BgCard, + target: 4.5, + floor: 3.0, + }, + ContrastCheck { + foreground: TokenKey::FgSubtle, + background: TokenKey::BgCard, + target: 3.0, + floor: 2.0, + }, + ContrastCheck { + foreground: TokenKey::AfkText, + background: TokenKey::BgCard, + target: 3.0, + floor: 2.0, + }, + ContrastCheck { + foreground: TokenKey::Danger, + background: TokenKey::BgCard, + target: 3.0, + floor: 2.0, + }, + ContrastCheck { + foreground: TokenKey::Success, + background: TokenKey::BgCard, + target: 3.0, + floor: 2.0, + }, + ContrastCheck { + foreground: TokenKey::Warning, + background: TokenKey::BgCard, + target: 3.0, + floor: 2.0, + }, + ContrastCheck { + foreground: TokenKey::Accent, + background: TokenKey::BgCard, + target: 3.0, + floor: 2.0, + }, + ContrastCheck { + foreground: TokenKey::AccentFg, + background: TokenKey::Accent, + target: 4.5, + floor: 3.0, + }, +]; + +/// Surfaces `card_alpha` applies to. +const TRANSLUCENT_SURFACES: [TokenKey; 4] = [ + TokenKey::BgCard, + TokenKey::BgCardHover, + TokenKey::BgMuted, + TokenKey::BgElevated, +]; + +/// Everything wrong with a theme, in the order a user should fix it. +/// `card_alpha` is the alpha cards are painted at (1 when `None`). +/// +/// Token issues come in spec order rather than file order, and +/// `UnknownToken` cannot occur: a `ThemeDocument` only holds known keys. +pub fn validate_theme_document( + document: &ThemeDocument, + resolved: &ResolvedTokens, + card_alpha: Option, +) -> Vec { + let mut issues = Vec::new(); + + for (key, value) in &document.tokens { + if !is_valid_token_value(key.name(), value) { + issues.push(ThemeIssue { + token: Some(*key), + expected: get_token_spec(key.name()).map(|spec| spec.kind), + ..ThemeIssue::new(IssueLevel::Error, ThemeIssueCode::InvalidValue) + }); + } + } + + if let Some(base) = &resolved.broken_extends { + issues.push(ThemeIssue { + base: Some(base.clone()), + ..ThemeIssue::new(IssueLevel::Warning, ThemeIssueCode::UnknownBase) + }); + } + + let css = document.css.as_deref().unwrap_or(""); + if !js_trim(css).is_empty() { + if let Some(construct) = unsafe_css_construct(css) { + issues.push(ThemeIssue { + construct: Some(construct), + ..ThemeIssue::new(IssueLevel::Error, ThemeIssueCode::UnsafeCss) + }); + } + } + let css_length = utf16_len(css); + if css_length > MAX_THEME_CSS_LENGTH { + issues.push(ThemeIssue { + length: Some(css_length), + limit: Some(MAX_THEME_CSS_LENGTH), + ..ThemeIssue::new(IssueLevel::Error, ThemeIssueCode::CssTooLong) + }); + } + + if document.extends_id().is_none() { + for key in &resolved.filled_from_root { + if is_essential_token(key.name()) { + issues.push(ThemeIssue { + token: Some(*key), + ..ThemeIssue::new(IssueLevel::Warning, ThemeIssueCode::MissingToken) + }); + } + } + } + + let backdrop = parse_color(resolved.tokens.get(TokenKey::BgRgb)); + let card_alpha = card_alpha.unwrap_or(1.0); + for check in &CONTRAST_CHECKS { + let (Some(foreground), Some(mut background)) = ( + parse_color(resolved.tokens.get(check.foreground)), + parse_color(resolved.tokens.get(check.background)), + ) else { + continue; + }; + if let Some(backdrop) = backdrop { + if TRANSLUCENT_SURFACES.contains(&check.background) && card_alpha < 1.0 { + background = blend(background, card_alpha, backdrop); + } + } + let ratio = contrast_ratio(foreground, background); + if ratio >= check.target { + continue; + } + let level = if ratio < check.floor { + IssueLevel::Error + } else { + IssueLevel::Warning + }; + issues.push(ThemeIssue { + token: Some(check.foreground), + against: Some(check.background), + ratio: Some(round_ratio(ratio)), + target: Some(check.target), + ..ThemeIssue::new(level, ThemeIssueCode::Contrast) + }); + } + issues +} + +/// A copy of `source`, ready to be edited under a new identity. +pub fn duplicate_theme_document(source: &ThemeDocument, id: &str, name: &str) -> ThemeDocument { + ThemeDocument { + schema_version: THEME_CONTRACT_VERSION, + id: id.to_owned(), + name: name.to_owned(), + author: None, + version: None, + color_scheme: source.color_scheme, + extends: Some(source.id.clone()), + glass: source.glass, + tokens: BTreeMap::new(), + css: source.css.clone().filter(|css| !css.is_empty()), + } +} diff --git a/crates/accshift-theme/src/themes.rs b/crates/accshift-theme/src/themes.rs new file mode 100644 index 0000000..7cf9ee1 --- /dev/null +++ b/crates/accshift-theme/src/themes.rs @@ -0,0 +1,808 @@ +//! Port of `src/lib/theme/themes.ts`: the built-in themes, the registry of +//! custom ones, the surface opacity rules and the CSS variables a theme writes +//! on ``. +//! +//! The TypeScript keeps its registry in module state; here it is a +//! [`ThemeRegistry`] value the app owns. Nothing here touches the disk: saving +//! and deleting return what the caller must write. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::OnceLock; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::js::{js_number_to_string, js_trim}; +use crate::schema::{ + document_is_liquid_glass, parse_theme_json, parse_theme_value, resolve_theme_tokens, + serialize_theme_document, ColorScheme, RootTokens, ThemeDocument, ThemeParseResult, +}; +use crate::tokens::{ + avatar_radius, density_scale, font_smoothing_mac_value, font_smoothing_value, ThemeTokens, + TokenKey, THEME_CONTRACT_VERSION, +}; + +pub const DEFAULT_THEME_ID: &str = "dark"; + +/// Version 3 tokens shared by both roots: structure, not colour. +const SHARED_STRUCTURE_TOKENS: [(TokenKey, &str); 13] = [ + (TokenKey::BgImage, "none"), + (TokenKey::CardBgImage, "none"), + (TokenKey::BorderWidth, "1px"), + (TokenKey::BorderStyle, "solid"), + (TokenKey::AvatarShape, "rounded"), + (TokenKey::FocusRing, "solid"), + (TokenKey::OverlayBlur, "4px"), + (TokenKey::FontDisplay, "Inter"), + (TokenKey::LineHeight, "1.5"), + (TokenKey::LetterSpacing, "0"), + (TokenKey::LabelCase, "none"), + (TokenKey::FontSmoothing, "auto"), + (TokenKey::MotionScale, "1"), +]; + +const DARK_TOKENS: [(TokenKey, &str); 23] = [ + (TokenKey::BgRgb, "9 9 11"), + (TokenKey::BgCard, "#1c1c1f"), + (TokenKey::BgCardHover, "#252528"), + (TokenKey::BgMuted, "#27272a"), + (TokenKey::BgElevated, "#3f3f46"), + (TokenKey::Fg, "#fafafa"), + (TokenKey::FgMuted, "#a1a1aa"), + (TokenKey::FgSubtle, "#71717a"), + (TokenKey::Border, "#27272a"), + (TokenKey::Danger, "#dc2626"), + (TokenKey::AfkText, "#ffffff"), + (TokenKey::Accent, "#2563eb"), + (TokenKey::AccentFg, "#ffffff"), + (TokenKey::Success, "#22c55e"), + (TokenKey::Warning, "#eab308"), + (TokenKey::RadiusSm, "4px"), + (TokenKey::RadiusMd, "8px"), + (TokenKey::RadiusLg, "12px"), + (TokenKey::ElevationLow, "0 2px 8px rgb(0 0 0 / 0.18)"), + (TokenKey::ElevationMedium, "0 8px 24px rgb(0 0 0 / 0.32)"), + (TokenKey::ElevationHigh, "0 18px 48px rgb(0 0 0 / 0.45)"), + (TokenKey::Density, "cozy"), + (TokenKey::FontUi, "Inter"), +]; + +const LIGHT_TOKENS: [(TokenKey, &str); 23] = [ + (TokenKey::BgRgb, "241 241 243"), + (TokenKey::BgCard, "#d8d8de"), + (TokenKey::BgCardHover, "#cfcfd7"), + (TokenKey::BgMuted, "#c4c4ce"), + (TokenKey::BgElevated, "#aeaebc"), + (TokenKey::Fg, "#0b0b0f"), + (TokenKey::FgMuted, "#2b2b36"), + (TokenKey::FgSubtle, "#4e4e5d"), + (TokenKey::Border, "#b8b8c5"), + (TokenKey::Danger, "#dc2626"), + (TokenKey::AfkText, "#000000"), + (TokenKey::Accent, "#2563eb"), + (TokenKey::AccentFg, "#ffffff"), + (TokenKey::Success, "#15803d"), + (TokenKey::Warning, "#a16207"), + (TokenKey::RadiusSm, "4px"), + (TokenKey::RadiusMd, "8px"), + (TokenKey::RadiusLg, "12px"), + (TokenKey::ElevationLow, "0 2px 8px rgb(0 0 0 / 0.10)"), + (TokenKey::ElevationMedium, "0 8px 24px rgb(0 0 0 / 0.16)"), + (TokenKey::ElevationHigh, "0 18px 48px rgb(0 0 0 / 0.22)"), + (TokenKey::Density, "cozy"), + (TokenKey::FontUi, "Inter"), +]; + +const GLASS_LIGHT_TOKENS: [(TokenKey, &str); 11] = [ + (TokenKey::BgRgb, "236 238 244"), + (TokenKey::BgCard, "#fbfbfd"), + (TokenKey::BgCardHover, "#f0f1f6"), + (TokenKey::BgMuted, "#e2e4ec"), + (TokenKey::BgElevated, "#c8ccd8"), + (TokenKey::Fg, "#101018"), + (TokenKey::FgMuted, "#3a3a48"), + (TokenKey::FgSubtle, "#5e5e70"), + (TokenKey::Border, "#c2c6d2"), + (TokenKey::Danger, "#dc2626"), + (TokenKey::AfkText, "#000000"), +]; + +const LIQUID_GLASS_TOKENS: [(TokenKey, &str); 11] = [ + (TokenKey::BgRgb, "22 24 30"), + (TokenKey::BgCard, "#ffffff"), + (TokenKey::BgCardHover, "#ffffff"), + (TokenKey::BgMuted, "#ffffff"), + (TokenKey::BgElevated, "#ffffff"), + (TokenKey::Fg, "#ffffff"), + (TokenKey::FgMuted, "#dbdfe7"), + (TokenKey::FgSubtle, "#b0b6c2"), + (TokenKey::Border, "#a9b1c0"), + (TokenKey::Danger, "#ef4444"), + (TokenKey::AfkText, "#f4f6f9"), +]; + +const MIDNIGHT_JSON: &str = include_str!("../../../src/lib/theme/builtin/midnight.json"); +const GLASS_DARK_JSON: &str = include_str!("../../../src/lib/theme/builtin/glass-dark.json"); + +fn token_map(pairs: &[(TokenKey, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(key, value)| (*key, (*value).to_owned())) + .collect() +} + +fn complete_tokens(colour: &[(TokenKey, &str)]) -> ThemeTokens { + let map = token_map(colour); + let structure = token_map(&SHARED_STRUCTURE_TOKENS); + ThemeTokens::from_fn(|key| { + map.get(&key) + .or_else(|| structure.get(&key)) + .cloned() + .expect("a root sets every token") + }) +} + +/// `ROOT_TOKENS`: the two complete token sets every resolution ends on. +pub fn root_tokens() -> &'static RootTokens { + static ROOTS: OnceLock = OnceLock::new(); + ROOTS.get_or_init(|| RootTokens { + dark: complete_tokens(&DARK_TOKENS), + light: complete_tokens(&LIGHT_TOKENS), + }) +} + +fn code_document( + id: &str, + name: &str, + scheme: ColorScheme, + extends: Option<&str>, + glass: Option, + tokens: BTreeMap, +) -> ThemeDocument { + ThemeDocument { + schema_version: THEME_CONTRACT_VERSION, + id: id.to_owned(), + name: name.to_owned(), + author: None, + version: None, + color_scheme: scheme, + extends: extends.map(str::to_owned), + glass, + tokens, + css: None, + } +} + +fn full_map(tokens: &ThemeTokens) -> BTreeMap { + tokens + .iter() + .map(|(key, value)| (key, value.to_owned())) + .collect() +} + +/// `BUILT_IN_DOCUMENTS`, in menu order. +pub fn built_in_documents() -> &'static [ThemeDocument] { + static DOCUMENTS: OnceLock> = OnceLock::new(); + DOCUMENTS.get_or_init(|| { + let roots = root_tokens(); + vec![ + code_document( + "dark", + "Dark", + ColorScheme::Dark, + None, + None, + full_map(&roots.dark), + ), + code_document( + "light", + "Light", + ColorScheme::Light, + None, + None, + full_map(&roots.light), + ), + ThemeDocument::from_trusted_json(MIDNIGHT_JSON), + ThemeDocument::from_trusted_json(GLASS_DARK_JSON), + code_document( + "glass-light", + "Glass Light", + ColorScheme::Light, + Some("light"), + Some(true), + token_map(&GLASS_LIGHT_TOKENS), + ), + code_document( + "liquid-glass", + "Liquid Glass", + ColorScheme::Dark, + Some("dark"), + Some(true), + token_map(&LIQUID_GLASS_TOKENS), + ), + ] + }) +} + +fn built_in_label_key(id: &str) -> Option<&'static str> { + match id { + "dark" => Some("theme.dark"), + "light" => Some("theme.light"), + "midnight" => Some("theme.midnight"), + "glass-dark" => Some("theme.glassDark"), + "glass-light" => Some("theme.glassLight"), + "liquid-glass" => Some("theme.liquidGlass"), + _ => None, + } +} + +fn built_in_document(id: &str) -> Option<&'static ThemeDocument> { + built_in_documents() + .iter() + .find(|document| document.id == id) +} + +/// Built-in themes are read only: editing one means editing a copy of it. +pub fn is_built_in_theme(id: &str) -> bool { + built_in_document(id).is_some() +} + +/// `AppThemeDefinition`: a document resolved into what the app paints with. +#[derive(Clone, Debug, PartialEq)] +pub struct AppTheme { + pub id: String, + /// i18n key of the menu label (`theme.custom` for a custom theme). + pub label_key: &'static str, + pub color_scheme: ColorScheme, + pub tokens: ThemeTokens, + /// Glassmorphism theme. + pub glass: bool, + pub is_custom: bool, + /// Raw CSS the theme carries, already checked by the parser. + pub css: Option, + pub display_name: String, + /// Liquid Glass or a theme that extends it, decided against the registry + /// the theme was resolved in (`themeUsesLiquidGlass`). + pub liquid: bool, + pub document: ThemeDocument, +} + +/// A theme file as it crosses the IPC boundary, the on-disk shape. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomThemePayload { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schema_version: Option, + pub id: String, + pub name: String, + #[serde(default)] + pub author: Option, + #[serde(default)] + pub version: Option, + pub color_scheme: String, + #[serde(default)] + pub extends: Option, + #[serde(default)] + pub glass: Option, + pub tokens: BTreeMap, + #[serde(default)] + pub css: Option, +} + +/// Refused by `prepare_save`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BuiltInOverwrite(pub String); + +impl std::fmt::Display for BuiltInOverwrite { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Cannot overwrite built-in theme: {}", self.0) + } +} + +impl std::error::Error for BuiltInOverwrite {} + +/// A save on its way to disk: write `payload`, then hand this back to +/// [`ThemeRegistry::finish_save`]. +#[derive(Clone, Debug, PartialEq)] +pub struct PendingSave { + pub payload: CustomThemePayload, + document: ThemeDocument, + kept_refused_css: bool, +} + +/// The documents the app knows: the built-ins plus the custom themes loaded +/// from disk, in load order. +#[derive(Clone, Debug, Default)] +pub struct ThemeRegistry { + custom: Vec, + /// CSS a theme file carries but the safety filter refused, by theme id. + refused_css: HashMap, +} + +impl ThemeRegistry { + pub fn new() -> ThemeRegistry { + ThemeRegistry::default() + } + + /// `getThemeDocument`: custom first, then built-in. + pub fn document(&self, id: &str) -> Option<&ThemeDocument> { + self.custom + .iter() + .find(|document| document.id == id) + .or_else(|| built_in_document(id)) + } + + /// `themeFromDocument`. Never fails: unknown bases, cycles and holes are + /// absorbed by the resolver. + pub fn theme_from_document(&self, document: &ThemeDocument) -> AppTheme { + let resolved = resolve_theme_tokens(document, |id| self.document(id), root_tokens()); + let label = built_in_label_key(&document.id); + AppTheme { + id: document.id.clone(), + label_key: label.unwrap_or("theme.custom"), + color_scheme: document.color_scheme, + tokens: resolved.tokens, + glass: document.glass == Some(true), + is_custom: label.is_none(), + css: document.css.clone(), + display_name: document.name.clone(), + liquid: document_is_liquid_glass(document, |id| self.document(id)), + document: document.clone(), + } + } + + fn resolve_by_id(&self, id: &str) -> Option { + self.document(id) + .map(|document| self.theme_from_document(document)) + } + + /// `getThemeDefinition`: the theme, or the default one. + pub fn theme(&self, id: Option<&str>) -> AppTheme { + id.filter(|id| !id.is_empty()) + .and_then(|id| self.resolve_by_id(id)) + .or_else(|| self.resolve_by_id(DEFAULT_THEME_ID)) + .expect("the default theme is built in") + } + + /// `BUILT_IN_THEMES`. + pub fn built_in_themes(&self) -> Vec { + built_in_documents() + .iter() + .map(|document| self.theme_from_document(document)) + .collect() + } + + /// `getAllThemes`: built-ins, then custom themes in load order. + pub fn all_themes(&self) -> Vec { + let mut themes = self.built_in_themes(); + themes.extend( + self.custom + .iter() + .map(|document| self.theme_from_document(document)), + ); + themes + } + + /// Whether `theme` is Liquid Glass or extends it. + pub fn uses_liquid_glass(&self, theme: &AppTheme) -> bool { + document_is_liquid_glass(&theme.document, |id| self.document(id)) + } + + fn set_custom(&mut self, document: ThemeDocument) { + match self + .custom + .iter_mut() + .find(|existing| existing.id == document.id) + { + Some(existing) => *existing = document, + None => self.custom.push(document), + } + } + + /// `applyCustomThemePayloads`: replaces every custom theme. A file that + /// fails to parse, or reuses a built-in id, is skipped. + pub fn apply_custom_theme_payloads(&mut self, payloads: &[CustomThemePayload]) { + self.custom.clear(); + self.refused_css.clear(); + for payload in payloads { + let value = serde_json::to_value(payload).unwrap_or(Value::Null); + let parsed = parse_theme_value(&value); + let Some(document) = parsed.document else { + continue; + }; + if is_built_in_theme(&document.id) { + continue; + } + if parsed.rejected_css.is_some() { + if let Some(css) = &payload.css { + self.refused_css.insert(document.id.clone(), css.clone()); + } + } + self.set_custom(document); + } + } + + /// The CSS the filter refused for this theme, kept for the next save. + pub fn refused_css(&self, id: &str) -> Option<&str> { + self.refused_css.get(id).map(String::as_str) + } + + /// First half of `saveThemeDocument`: the payload to write. + /// `keep_refused_css`: an edit of a theme already on disk, whose refused + /// CSS stays in the file unless the edit brings CSS of its own. + pub fn prepare_save( + &self, + document: &ThemeDocument, + keep_refused_css: bool, + ) -> Result { + if is_built_in_theme(&document.id) { + return Err(BuiltInOverwrite(document.id.clone())); + } + let has_css = document.css.as_deref().is_some_and(|css| !css.is_empty()); + let kept_css = if keep_refused_css && !has_css { + self.refused_css(&document.id).map(str::to_owned) + } else { + None + }; + let payload = CustomThemePayload { + schema_version: Some(document.schema_version.into()), + id: document.id.clone(), + name: document.name.clone(), + author: document.author.clone(), + version: document.version.clone(), + color_scheme: document.color_scheme.as_str().to_owned(), + extends: document.extends.clone(), + glass: document.glass, + tokens: document + .tokens + .iter() + .map(|(key, value)| (key.name().to_owned(), value.clone())) + .collect(), + css: document.css.clone().or_else(|| kept_css.clone()), + }; + Ok(PendingSave { + payload, + document: document.clone(), + kept_refused_css: kept_css.is_some(), + }) + } + + /// Second half of `saveThemeDocument`, once the payload is on disk. + pub fn finish_save(&mut self, pending: PendingSave) { + if !pending.kept_refused_css { + self.refused_css.remove(&pending.document.id); + } + self.set_custom(pending.document); + } + + /// `deleteCustomTheme`, once the file is gone. + pub fn delete_custom_theme(&mut self, id: &str) { + self.custom.retain(|document| document.id != id); + } + + /// `suggestThemeId`: an id derived from a name, unique in this registry. + pub fn suggest_theme_id(&self, name: &str) -> String { + let lower = name.to_lowercase(); + let mut slug = String::new(); + let mut in_run = false; + for c in lower.chars() { + if c.is_ascii_lowercase() || c.is_ascii_digit() { + slug.push(c); + in_run = false; + } else if !in_run { + slug.push('-'); + in_run = true; + } + } + let trimmed = slug.trim_matches('-'); + let base = if trimmed.is_empty() { + "theme".to_owned() + } else { + trimmed[..trimmed.len().min(48)].to_owned() + }; + let mut candidate = base.clone(); + let mut counter = 2; + while self.document(&candidate).is_some() { + candidate = format!("{base}-{counter}"); + counter += 1; + } + candidate + } +} + +/// `exportThemeJson`: the shareable file. +pub fn export_theme_json(theme: &AppTheme) -> String { + serialize_theme_document(&theme.document) +} + +/// `importThemeJson`. +pub fn import_theme_json(json: &str) -> ThemeParseResult { + parse_theme_json(json) +} + +/// Fixed window fill per glass theme. +fn glass_window_opacity(id: &str) -> Option { + match id { + "glass-dark" | "glass-light" => Some(0.55), + "liquid-glass" => Some(0.18), + _ => None, + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ThemeSurfaceOpacities { + pub window_opacity: f64, + pub card_opacity: f64, + pub hover_opacity: f64, + pub muted_opacity: f64, + pub elevated_opacity: f64, + pub overlay_opacity: f64, + pub is_liquid: bool, +} + +struct SurfaceRule { + liquid: f64, + /// Cap, bump and floor on a glass theme with no live backdrop. + glass: [f64; 3], + /// The same three on a regular theme. + plain: [f64; 3], +} + +const CARD: SurfaceRule = SurfaceRule { + liquid: 0.13, + glass: [0.72, 0.1, 0.4], + plain: [1.0, 0.14, 0.66], +}; +const HOVER: SurfaceRule = SurfaceRule { + liquid: 0.21, + glass: [0.8, 0.08, 0.0], + plain: [1.0, 0.06, 0.72], +}; +const MUTED: SurfaceRule = SurfaceRule { + liquid: 0.16, + glass: [0.78, 0.14, 0.46], + plain: [1.0, 0.18, 0.72], +}; +const ELEVATED: SurfaceRule = SurfaceRule { + liquid: 0.34, + glass: [0.85, 0.2, 0.55], + plain: [1.0, 0.22, 0.78], +}; + +/// `Math.min(cap, Math.max(base + bump, floor))`, with JavaScript's NaN rules. +fn surface_opacity(rule: &SurfaceRule, base: f64, is_liquid: bool, glass: bool) -> f64 { + if is_liquid { + return rule.liquid; + } + let [cap, bump, floor] = if glass { rule.glass } else { rule.plain }; + js_min(cap, js_max(base + bump, floor)) +} + +fn js_min(a: f64, b: f64) -> f64 { + if a.is_nan() || b.is_nan() { + f64::NAN + } else { + a.min(b) + } +} + +fn js_max(a: f64, b: f64) -> f64 { + if a.is_nan() || b.is_nan() { + f64::NAN + } else { + a.max(b) + } +} + +/// `resolveThemeSurfaceOpacities`. `backdrop_available` is the TypeScript +/// `opts.backdropAvailable !== false`. +pub fn resolve_theme_surface_opacities( + theme: &AppTheme, + background_opacity_percent: f64, + backdrop_available: bool, +) -> ThemeSurfaceOpacities { + let raw_opacity = js_min(100.0, js_max(0.0, background_opacity_percent)) / 100.0; + let liquid = theme.liquid; + let is_liquid = liquid && backdrop_available; + let glass = theme.glass; + let window_opacity = if !glass { + raw_opacity + } else if !backdrop_available { + 0.96 + } else if liquid { + 0.18 + } else { + glass_window_opacity(&theme.id).unwrap_or(0.55) + }; + let card_opacity = surface_opacity(&CARD, window_opacity, is_liquid, glass); + let hover_opacity = surface_opacity(&HOVER, card_opacity, is_liquid, glass); + let muted_opacity = surface_opacity(&MUTED, window_opacity, is_liquid, glass); + let elevated_opacity = surface_opacity(&ELEVATED, window_opacity, is_liquid, glass); + let overlay_opacity = js_min(1.0, js_max(window_opacity + 0.3, 0.86)); + ThemeSurfaceOpacities { + window_opacity, + card_opacity, + hover_opacity, + muted_opacity, + elevated_opacity, + overlay_opacity, + is_liquid, + } +} + +/// `hexToRgbTriplet`: "r g b", or "0 0 0" for a malformed token. +fn hex_to_rgb_triplet(color: &str) -> String { + let trimmed = js_trim(color); + let hex = trimmed.strip_prefix('#').unwrap_or(trimmed); + let normalized: String = if hex.chars().count() == 3 { + hex.chars().flat_map(|c| [c, c]).collect() + } else { + hex.to_owned() + }; + if normalized.len() != 6 || !normalized.bytes().all(|b| b.is_ascii_hexdigit()) { + return "0 0 0".to_owned(); + } + let channel = |index: usize| u8::from_str_radix(&normalized[index..index + 2], 16).unwrap_or(0); + format!("{} {} {}", channel(0), channel(2), channel(4)) +} + +/// What `applyThemeToDocument` writes on `` for one theme. +#[derive(Clone, Debug, PartialEq)] +pub struct ThemeApplication { + /// `data-theme`. + pub data_theme: String, + /// `data-glass`: "1" or "0". + pub data_glass: &'static str, + /// `style.colorScheme`. + pub color_scheme: ColorScheme, + /// Custom properties set on the inline style, in the order they are set. + pub vars: Vec<(&'static str, String)>, + /// The theme's own CSS, appended last in the head when it is not blank. + pub css: Option, + pub surfaces: ThemeSurfaceOpacities, +} + +impl ThemeApplication { + pub fn var(&self, name: &str) -> Option<&str> { + self.vars + .iter() + .find(|(key, _)| *key == name) + .map(|(_, value)| value.as_str()) + } +} + +/// `applyThemeToDocument` without a document: every value it would write. +pub fn theme_application( + theme: &AppTheme, + background_opacity_percent: f64, + backdrop_available: bool, +) -> ThemeApplication { + let surfaces = + resolve_theme_surface_opacities(theme, background_opacity_percent, backdrop_available); + let tokens = &theme.tokens; + let t = |key: TokenKey| tokens.get(key).to_owned(); + let n = js_number_to_string; + let bg_rgb = tokens.get(TokenKey::BgRgb); + let bg_card_rgb = hex_to_rgb_triplet(tokens.get(TokenKey::BgCard)); + let bg_card_hover_rgb = hex_to_rgb_triplet(tokens.get(TokenKey::BgCardHover)); + let bg_muted_rgb = hex_to_rgb_triplet(tokens.get(TokenKey::BgMuted)); + let bg_elevated_rgb = hex_to_rgb_triplet(tokens.get(TokenKey::BgElevated)); + let surface_images_allowed = !theme.glass; + + let vars: Vec<(&'static str, String)> = vec![ + ("--bg-rgb", bg_rgb.to_owned()), + ("--bg-opacity", n(surfaces.window_opacity)), + ("--bg-solid", format!("rgb({bg_rgb})")), + ( + "--bg-input", + if theme.glass { + format!("rgb({bg_rgb} / 0.55)") + } else { + format!("rgb({bg_rgb})") + }, + ), + ( + "--bg-card", + format!("rgb({bg_card_rgb} / {})", n(surfaces.card_opacity)), + ), + ( + "--bg-card-hover", + format!("rgb({bg_card_hover_rgb} / {})", n(surfaces.hover_opacity)), + ), + ( + "--bg-muted", + format!("rgb({bg_muted_rgb} / {})", n(surfaces.muted_opacity)), + ), + ( + "--bg-elevated", + format!("rgb({bg_elevated_rgb} / {})", n(surfaces.elevated_opacity)), + ), + ( + "--bg-overlay", + if surfaces.is_liquid { + format!("rgb({bg_rgb} / 0.94)") + } else { + format!("rgb({bg_card_rgb} / {})", n(surfaces.overlay_opacity)) + }, + ), + ("--fg", t(TokenKey::Fg)), + ("--fg-muted", t(TokenKey::FgMuted)), + ("--fg-subtle", t(TokenKey::FgSubtle)), + ("--border", t(TokenKey::Border)), + ("--danger", t(TokenKey::Danger)), + ("--afk-text", t(TokenKey::AfkText)), + ("--accent", t(TokenKey::Accent)), + ("--accent-fg", t(TokenKey::AccentFg)), + ("--success", t(TokenKey::Success)), + ("--warning", t(TokenKey::Warning)), + ("--radius-sm", t(TokenKey::RadiusSm)), + ("--radius-md", t(TokenKey::RadiusMd)), + ("--radius-lg", t(TokenKey::RadiusLg)), + ("--elevation-low", t(TokenKey::ElevationLow)), + ("--elevation-medium", t(TokenKey::ElevationMedium)), + ("--elevation-high", t(TokenKey::ElevationHigh)), + ( + "--density-scale", + n(density_scale(tokens.get(TokenKey::Density)).unwrap_or(1.0)), + ), + ( + "--font-ui", + format!("{}, var(--font-stack-base)", t(TokenKey::FontUi)), + ), + ( + "--font-display", + format!("{}, var(--font-ui)", t(TokenKey::FontDisplay)), + ), + ( + "--bg-image", + if surface_images_allowed { + t(TokenKey::BgImage) + } else { + "none".to_owned() + }, + ), + ( + "--card-bg-image", + if surface_images_allowed { + t(TokenKey::CardBgImage) + } else { + "none".to_owned() + }, + ), + ("--border-width", t(TokenKey::BorderWidth)), + ("--border-style", t(TokenKey::BorderStyle)), + ( + "--avatar-radius", + avatar_radius(tokens.get(TokenKey::AvatarShape)) + .unwrap_or("50%") + .to_owned(), + ), + ("--focus-ring-style", t(TokenKey::FocusRing)), + ("--overlay-blur", t(TokenKey::OverlayBlur)), + ("--line-height", t(TokenKey::LineHeight)), + ("--letter-spacing", t(TokenKey::LetterSpacing)), + ("--label-transform", t(TokenKey::LabelCase)), + ( + "--font-smoothing", + font_smoothing_value(tokens.get(TokenKey::FontSmoothing)) + .unwrap_or("antialiased") + .to_owned(), + ), + ( + "--font-smoothing-mac", + font_smoothing_mac_value(tokens.get(TokenKey::FontSmoothing)) + .unwrap_or("grayscale") + .to_owned(), + ), + ("--motion-scale", t(TokenKey::MotionScale)), + ]; + + ThemeApplication { + data_theme: theme.id.clone(), + data_glass: if theme.glass { "1" } else { "0" }, + color_scheme: theme.color_scheme, + vars, + css: theme.css.clone().filter(|css| !js_trim(css).is_empty()), + surfaces, + } +} diff --git a/crates/accshift-theme/src/tokens.rs b/crates/accshift-theme/src/tokens.rs new file mode 100644 index 0000000..7102b25 --- /dev/null +++ b/crates/accshift-theme/src/tokens.rs @@ -0,0 +1,654 @@ +//! Port of `src/lib/theme/tokens.ts`: the token contract, its specs and the +//! validation every theme value goes through. + +use std::fmt; +use std::ops::Index; + +use crate::js::{is_js_space, js_trim, utf16_len}; + +/// Version of the token contract, not of a theme file. See `tokens.ts`. +pub const THEME_CONTRACT_VERSION: u32 = 3; + +/// Number of tokens in contract version 3. +pub const TOKEN_COUNT: usize = 36; + +macro_rules! token_keys { + ($($variant:ident => $name:literal,)*) => { + /// Every value a theme can set, in the order of `THEME_TOKEN_SPECS` + /// (also the order `Ord` sorts by and the order an export writes). + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub enum TokenKey { + $($variant,)* + } + + impl TokenKey { + /// `THEME_TOKEN_KEYS`. + pub const ALL: [TokenKey; TOKEN_COUNT] = [$(TokenKey::$variant,)*]; + + /// The key as written in a theme file (`bgCardHover`). + pub fn name(self) -> &'static str { + match self { + $(TokenKey::$variant => $name,)* + } + } + + pub fn from_name(name: &str) -> Option { + match name { + $($name => Some(TokenKey::$variant),)* + _ => None, + } + } + } + }; +} + +token_keys! { + BgRgb => "bgRgb", + BgCard => "bgCard", + BgCardHover => "bgCardHover", + BgMuted => "bgMuted", + BgElevated => "bgElevated", + Border => "border", + Fg => "fg", + FgMuted => "fgMuted", + FgSubtle => "fgSubtle", + AfkText => "afkText", + Accent => "accent", + AccentFg => "accentFg", + Success => "success", + Warning => "warning", + Danger => "danger", + RadiusSm => "radiusSm", + RadiusMd => "radiusMd", + RadiusLg => "radiusLg", + ElevationLow => "elevationLow", + ElevationMedium => "elevationMedium", + ElevationHigh => "elevationHigh", + Density => "density", + FontUi => "fontUi", + BgImage => "bgImage", + CardBgImage => "cardBgImage", + BorderWidth => "borderWidth", + BorderStyle => "borderStyle", + AvatarShape => "avatarShape", + FocusRing => "focusRing", + OverlayBlur => "overlayBlur", + FontDisplay => "fontDisplay", + LineHeight => "lineHeight", + LetterSpacing => "letterSpacing", + LabelCase => "labelCase", + FontSmoothing => "fontSmoothing", + MotionScale => "motionScale", +} + +impl TokenKey { + pub fn spec(self) -> &'static TokenSpec { + &THEME_TOKEN_SPECS[self as usize] + } +} + +impl fmt::Display for TokenKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} + +/// How a value is validated and how it reaches CSS. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum TokenKind { + RgbTriplet, + HexColor, + Color, + Length, + SignedLength, + Shadow, + Gradient, + Number, + Choice, + FontStack, +} + +impl TokenKind { + pub fn name(self) -> &'static str { + match self { + TokenKind::RgbTriplet => "rgbTriplet", + TokenKind::HexColor => "hexColor", + TokenKind::Color => "color", + TokenKind::Length => "length", + TokenKind::SignedLength => "signedLength", + TokenKind::Shadow => "shadow", + TokenKind::Gradient => "gradient", + TokenKind::Number => "number", + TokenKind::Choice => "choice", + TokenKind::FontStack => "fontStack", + } + } + + /// `TOKEN_KIND_EXAMPLE`: what the editor shows when a value is refused. + pub fn example(self) -> &'static str { + match self { + TokenKind::RgbTriplet => "9 9 11", + TokenKind::HexColor => "#1c1c1f", + TokenKind::Color => "#fafafa", + TokenKind::Length => "8px", + TokenKind::SignedLength => "-0.02em", + TokenKind::Shadow => "0 2px 8px rgb(0 0 0 / 0.18)", + TokenKind::Gradient => "linear-gradient(180deg, #ffffff, #c0c0c0)", + TokenKind::Number => "1.5", + TokenKind::Choice => "compact | cozy | comfortable", + TokenKind::FontStack => "\"Inter\", sans-serif", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum TokenGroup { + Surface, + Text, + Semantic, + Shape, + Typography, + Motion, +} + +impl TokenGroup { + /// `THEME_TOKEN_GROUPS`, in editor order. + pub const ALL: [TokenGroup; 6] = [ + TokenGroup::Surface, + TokenGroup::Text, + TokenGroup::Semantic, + TokenGroup::Shape, + TokenGroup::Typography, + TokenGroup::Motion, + ]; + + pub fn name(self) -> &'static str { + match self { + TokenGroup::Surface => "surface", + TokenGroup::Text => "text", + TokenGroup::Semantic => "semantic", + TokenGroup::Shape => "shape", + TokenGroup::Typography => "typography", + TokenGroup::Motion => "motion", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TokenSpec { + pub key: TokenKey, + pub kind: TokenKind, + pub group: TokenGroup, + /// Custom property the token feeds on ``. + pub css_var: &'static str, + /// Contract version that introduced the token. + pub since: u32, + /// Allowed values of a `choice` token, empty otherwise. + pub choices: &'static [&'static str], + /// Bounds of a `number` token, inclusive. + pub min: Option, + pub max: Option, +} + +impl TokenSpec { + /// i18n key of the editor label, `themeToken.`. + pub fn label_key(&self) -> String { + format!("themeToken.{}", self.key.name()) + } +} + +pub const DENSITY_CHOICES: &[&str] = &["compact", "cozy", "comfortable"]; +pub const BORDER_STYLE_CHOICES: &[&str] = &[ + "solid", "dashed", "dotted", "double", "groove", "ridge", "inset", "outset", "none", +]; +pub const AVATAR_SHAPE_CHOICES: &[&str] = &["circle", "rounded", "square"]; +pub const FOCUS_RING_CHOICES: &[&str] = &["solid", "dashed", "dotted", "double"]; +pub const LABEL_CASE_CHOICES: &[&str] = &["none", "uppercase"]; +pub const FONT_SMOOTHING_CHOICES: &[&str] = &["auto", "none"]; + +/// `DENSITY_SCALE`: multiplier applied to the card grid metrics. +pub fn density_scale(density: &str) -> Option { + match density { + "compact" => Some(0.88), + "cozy" => Some(1.0), + "comfortable" => Some(1.12), + _ => None, + } +} + +/// `AVATAR_RADIUS`: corner radius per avatar shape. +pub fn avatar_radius(shape: &str) -> Option<&'static str> { + match shape { + "circle" => Some("50%"), + "rounded" => Some("var(--avatar-radius-default, 6px)"), + "square" => Some("0"), + _ => None, + } +} + +/// `FONT_SMOOTHING_VALUE`. +pub fn font_smoothing_value(choice: &str) -> Option<&'static str> { + match choice { + "auto" => Some("antialiased"), + "none" => Some("none"), + _ => None, + } +} + +/// `FONT_SMOOTHING_MAC_VALUE`. +pub fn font_smoothing_mac_value(choice: &str) -> Option<&'static str> { + match choice { + "auto" => Some("grayscale"), + "none" => Some("auto"), + _ => None, + } +} + +const fn spec( + key: TokenKey, + kind: TokenKind, + group: TokenGroup, + css_var: &'static str, + since: u32, +) -> TokenSpec { + TokenSpec { + key, + kind, + group, + css_var, + since, + choices: &[], + min: None, + max: None, + } +} + +const fn choice( + key: TokenKey, + group: TokenGroup, + css_var: &'static str, + since: u32, + choices: &'static [&'static str], +) -> TokenSpec { + TokenSpec { + choices, + ..spec(key, TokenKind::Choice, group, css_var, since) + } +} + +const fn number( + key: TokenKey, + group: TokenGroup, + css_var: &'static str, + min: f64, + max: f64, +) -> TokenSpec { + TokenSpec { + min: Some(min), + max: Some(max), + ..spec(key, TokenKind::Number, group, css_var, 3) + } +} + +use TokenGroup as G; +use TokenKey as K; +use TokenKind as T; + +/// `THEME_TOKEN_SPECS`, indexed by `TokenKey as usize`. +pub static THEME_TOKEN_SPECS: [TokenSpec; TOKEN_COUNT] = [ + spec(K::BgRgb, T::RgbTriplet, G::Surface, "--bg-rgb", 1), + spec(K::BgCard, T::HexColor, G::Surface, "--bg-card", 1), + spec( + K::BgCardHover, + T::HexColor, + G::Surface, + "--bg-card-hover", + 1, + ), + spec(K::BgMuted, T::HexColor, G::Surface, "--bg-muted", 1), + spec(K::BgElevated, T::HexColor, G::Surface, "--bg-elevated", 1), + spec(K::Border, T::Color, G::Surface, "--border", 1), + spec(K::Fg, T::Color, G::Text, "--fg", 1), + spec(K::FgMuted, T::Color, G::Text, "--fg-muted", 1), + spec(K::FgSubtle, T::Color, G::Text, "--fg-subtle", 1), + spec(K::AfkText, T::Color, G::Text, "--afk-text", 1), + spec(K::Accent, T::Color, G::Semantic, "--accent", 2), + spec(K::AccentFg, T::Color, G::Semantic, "--accent-fg", 2), + spec(K::Success, T::Color, G::Semantic, "--success", 2), + spec(K::Warning, T::Color, G::Semantic, "--warning", 2), + spec(K::Danger, T::Color, G::Semantic, "--danger", 1), + spec(K::RadiusSm, T::Length, G::Shape, "--radius-sm", 2), + spec(K::RadiusMd, T::Length, G::Shape, "--radius-md", 2), + spec(K::RadiusLg, T::Length, G::Shape, "--radius-lg", 2), + spec(K::ElevationLow, T::Shadow, G::Shape, "--elevation-low", 2), + spec( + K::ElevationMedium, + T::Shadow, + G::Shape, + "--elevation-medium", + 2, + ), + spec(K::ElevationHigh, T::Shadow, G::Shape, "--elevation-high", 2), + choice(K::Density, G::Shape, "--density-scale", 2, DENSITY_CHOICES), + spec(K::FontUi, T::FontStack, G::Typography, "--font-ui", 2), + spec(K::BgImage, T::Gradient, G::Surface, "--bg-image", 3), + spec( + K::CardBgImage, + T::Gradient, + G::Surface, + "--card-bg-image", + 3, + ), + spec(K::BorderWidth, T::Length, G::Shape, "--border-width", 3), + choice( + K::BorderStyle, + G::Shape, + "--border-style", + 3, + BORDER_STYLE_CHOICES, + ), + choice( + K::AvatarShape, + G::Shape, + "--avatar-radius", + 3, + AVATAR_SHAPE_CHOICES, + ), + choice( + K::FocusRing, + G::Shape, + "--focus-ring-style", + 3, + FOCUS_RING_CHOICES, + ), + spec(K::OverlayBlur, T::Length, G::Shape, "--overlay-blur", 3), + spec( + K::FontDisplay, + T::FontStack, + G::Typography, + "--font-display", + 3, + ), + number(K::LineHeight, G::Typography, "--line-height", 1.0, 2.5), + spec( + K::LetterSpacing, + T::SignedLength, + G::Typography, + "--letter-spacing", + 3, + ), + choice( + K::LabelCase, + G::Typography, + "--label-transform", + 3, + LABEL_CASE_CHOICES, + ), + choice( + K::FontSmoothing, + G::Typography, + "--font-smoothing", + 3, + FONT_SMOOTHING_CHOICES, + ), + number(K::MotionScale, G::Motion, "--motion-scale", 0.0, 3.0), +]; + +pub fn get_token_spec(key: &str) -> Option<&'static TokenSpec> { + TokenKey::from_name(key).map(TokenKey::spec) +} + +pub fn is_theme_token_key(key: &str) -> bool { + TokenKey::from_name(key).is_some() +} + +/// Whether leaving this token unset is worth telling the author about: the +/// colour kinds only. +pub fn is_essential_token(key: &str) -> bool { + get_token_spec(key).is_some_and(|spec| { + matches!( + spec.kind, + TokenKind::RgbTriplet | TokenKind::HexColor | TokenKind::Color + ) + }) +} + +const MAX_VALUE_LENGTH: usize = 200; +const MAX_GRADIENT_LENGTH: usize = 400; + +/// Consumes `min..=max` ASCII digits; fails on more, as the anchored regexes +/// of `tokens.ts` do at every place this is used. +fn digits(text: &str, min: usize, max: usize) -> Option<&str> { + let count = text.bytes().take_while(u8::is_ascii_digit).count(); + (min..=max).contains(&count).then(|| &text[count..]) +} + +/// `\d{1,3}(?:\.\d+)?`, returning what follows. +fn decimal(text: &str) -> Option<&str> { + let rest = digits(text, 1, 3)?; + match rest.strip_prefix('.') { + Some(fraction) => digits(fraction, 1, usize::MAX), + None => Some(rest), + } +} + +/// `^(?:0|\d{1,3}(?:\.\d+)?(?:px|rem|em))$` +fn is_length(value: &str) -> bool { + value == "0" || decimal(value).is_some_and(|unit| matches!(unit, "px" | "rem" | "em")) +} + +fn is_hex_color(value: &str) -> bool { + value + .strip_prefix('#') + .is_some_and(|hex| matches!(hex.len(), 3 | 6) && hex.bytes().all(|b| b.is_ascii_hexdigit())) +} + +/// `^\d{1,3} \d{1,3} \d{1,3}$`, each channel at most 255. +fn is_rgb_triplet(value: &str) -> bool { + let parts: Vec<&str> = value.split(' ').collect(); + parts.len() == 3 + && parts.iter().all(|part| { + digits(part, 1, 3).is_some_and(str::is_empty) + && part.parse::().is_ok_and(|channel| channel <= 255) + }) +} + +/// `^rgba?\([\d\s.,%/]+\)$` +fn is_rgb_function(value: &str) -> bool { + let Some(inner) = value + .strip_prefix("rgba(") + .or_else(|| value.strip_prefix("rgb(")) + .and_then(|rest| rest.strip_suffix(')')) + else { + return false; + }; + !inner.is_empty() + && inner + .chars() + .all(|c| c.is_ascii_digit() || is_js_space(c) || matches!(c, '.' | ',' | '%' | '/')) +} + +/// `^[a-zA-Z]{3,20}$` +fn is_color_keyword(value: &str) -> bool { + (3..=20).contains(&value.len()) && value.bytes().all(|b| b.is_ascii_alphabetic()) +} + +/// The shadow and gradient charset: `[0-9a-zA-Z\s.,%#()/-]`. +fn is_paint_char(c: char) -> bool { + c.is_ascii_alphanumeric() + || is_js_space(c) + || matches!(c, '.' | ',' | '%' | '#' | '(' | ')' | '/' | '-') +} + +/// `/url\(|var\(|expression\(/i` +fn has_forbidden_function(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + lower.contains("url(") || lower.contains("var(") || lower.contains("expression(") +} + +fn is_shadow(value: &str) -> bool { + value == "none" || (value.chars().all(is_paint_char) && !has_forbidden_function(value)) +} + +/// `^(?:repeating-)?(?:linear|radial|conic)-gradient\([charset]*\)$` +fn is_gradient(value: &str) -> bool { + if value == "none" { + return true; + } + let rest = value.strip_prefix("repeating-").unwrap_or(value); + let Some(inner) = ["linear", "radial", "conic"] + .iter() + .find_map(|name| rest.strip_prefix(name)) + .and_then(|rest| rest.strip_prefix("-gradient(")) + .and_then(|rest| rest.strip_suffix(')')) + else { + return false; + }; + inner.chars().all(is_paint_char) && !has_forbidden_function(value) +} + +/// `^[\w\s'",-]+$` +fn is_font_stack(value: &str) -> bool { + value.chars().all(|c| { + c.is_ascii_alphanumeric() + || c == '_' + || is_js_space(c) + || matches!(c, '\'' | '"' | ',' | '-') + }) +} + +/// Whether a raw value is usable for that token (`isValidTokenValue`). +pub fn is_valid_token_value(key: &str, raw_value: &str) -> bool { + let Some(spec) = get_token_spec(key) else { + return false; + }; + let value = js_trim(raw_value); + let max_length = if spec.kind == TokenKind::Gradient { + MAX_GRADIENT_LENGTH + } else { + MAX_VALUE_LENGTH + }; + if value.is_empty() || utf16_len(value) > max_length { + return false; + } + match spec.kind { + TokenKind::RgbTriplet => is_rgb_triplet(value), + TokenKind::HexColor => is_hex_color(value), + TokenKind::Color => { + is_hex_color(value) || is_rgb_function(value) || is_color_keyword(value) + } + TokenKind::Length => is_length(value), + TokenKind::SignedLength => is_length(value.strip_prefix('-').unwrap_or(value)), + TokenKind::Shadow => is_shadow(value), + TokenKind::Gradient => is_gradient(value), + TokenKind::Number => { + if decimal(value).is_none_or(|rest| !rest.is_empty()) { + return false; + } + let parsed: f64 = value.parse().unwrap_or(f64::NAN); + parsed >= spec.min.unwrap_or(0.0) && parsed <= spec.max.unwrap_or(10.0) + } + TokenKind::Choice => spec.choices.contains(&value), + TokenKind::FontStack => is_font_stack(value), + } +} + +/// A complete token set: one value per key, the shape of `ThemeTokens`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ThemeTokens { + values: [String; TOKEN_COUNT], +} + +impl ThemeTokens { + /// Builds a set from a function of the key, so no key can be forgotten. + pub fn from_fn(mut value: impl FnMut(TokenKey) -> String) -> ThemeTokens { + ThemeTokens { + values: std::array::from_fn(|index| value(TokenKey::ALL[index])), + } + } + + pub fn get(&self, key: TokenKey) -> &str { + &self.values[key as usize] + } + + pub fn set(&mut self, key: TokenKey, value: impl Into) { + self.values[key as usize] = value.into(); + } + + /// Every token, in spec order. + pub fn iter(&self) -> impl Iterator { + TokenKey::ALL.iter().map(|key| (*key, self.get(*key))) + } +} + +impl Index for ThemeTokens { + type Output = str; + + fn index(&self, key: TokenKey) -> &str { + self.get(key) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spec_table_is_indexed_by_key() { + for key in TokenKey::ALL { + assert_eq!(key.spec().key, key); + assert_eq!(TokenKey::from_name(key.name()), Some(key)); + } + } + + #[test] + fn validates_like_the_typescript_regexes() { + let valid = [ + ("bgRgb", "9 9 11"), + ("bgCard", " #abc "), + ("border", "rgb(1, 2, 3)"), + ("border", "rgba(1 2 3 / 50%)"), + ("border", "rebeccapurple"), + ("radiusMd", "0"), + ("radiusMd", "12.5px"), + ("letterSpacing", "-0.02em"), + ("elevationLow", "none"), + ( + "bgImage", + "linear-gradient(180deg, #ffffff 0%, #c0c0c0 100%)", + ), + ("bgImage", "repeating-conic-gradient()"), + ("lineHeight", "2.5"), + ("density", "cozy"), + ("fontUi", "\"Inter\", sans-serif"), + ]; + for (key, value) in valid { + assert!(is_valid_token_value(key, value), "{key} = {value}"); + } + let invalid = [ + ("bgRgb", "9 9 256"), + ("bgRgb", "9 9 11"), + ("bgCard", "#abcd"), + ("border", "javascript:alert(1)"), + ("radiusMd", "1234px"), + ("radiusMd", "-2px"), + ("elevationLow", "url(https://example.com/x.png)"), + ("elevationLow", "0 0 var(--x)"), + ( + "cardBgImage", + "linear-gradient(#fff, url(https://example.com/x.png))", + ), + ("bgImage", "image-gradient(#fff)"), + ("lineHeight", "0.9"), + ("motionScale", "3.01"), + ("motionScale", "1."), + ("density", "roomy"), + ("fontUi", "Inter; color: red"), + ("nope", "1"), + ("fg", ""), + ]; + for (key, value) in invalid { + assert!(!is_valid_token_value(key, value), "{key} = {value}"); + } + } +} diff --git a/crates/accshift-theme/tests/data/builtin-dark.json b/crates/accshift-theme/tests/data/builtin-dark.json new file mode 100644 index 0000000..6f26152 --- /dev/null +++ b/crates/accshift-theme/tests/data/builtin-dark.json @@ -0,0 +1,526 @@ +{ + "id": "dark", + "payload": null, + "isCustom": false, + "liquid": false, + "tokens": { + "bgRgb": "9 9 11", + "bgCard": "#1c1c1f", + "bgCardHover": "#252528", + "bgMuted": "#27272a", + "bgElevated": "#3f3f46", + "fg": "#fafafa", + "fgMuted": "#a1a1aa", + "fgSubtle": "#71717a", + "border": "#27272a", + "danger": "#dc2626", + "afkText": "#ffffff", + "accent": "#2563eb", + "accentFg": "#ffffff", + "success": "#22c55e", + "warning": "#eab308", + "radiusSm": "4px", + "radiusMd": "8px", + "radiusLg": "12px", + "elevationLow": "0 2px 8px rgb(0 0 0 / 0.18)", + "elevationMedium": "0 8px 24px rgb(0 0 0 / 0.32)", + "elevationHigh": "0 18px 48px rgb(0 0 0 / 0.45)", + "density": "cozy", + "fontUi": "Inter", + "bgImage": "none", + "cardBgImage": "none", + "borderWidth": "1px", + "borderStyle": "solid", + "avatarShape": "rounded", + "focusRing": "solid", + "overlayBlur": "4px", + "fontDisplay": "Inter", + "lineHeight": "1.5", + "letterSpacing": "0", + "labelCase": "none", + "fontSmoothing": "auto", + "motionScale": "1" + }, + "cases": [ + { + "opacity": 0, + "backdropAvailable": true, + "dataTheme": "dark", + "dataGlass": "0", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0, + "cardOpacity": 0.66, + "hoverOpacity": 0.72, + "mutedOpacity": 0.72, + "elevatedOpacity": 0.78, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "9 9 11", + "--bg-opacity": "0", + "--bg-solid": "rgb(9 9 11)", + "--bg-input": "rgb(9 9 11)", + "--bg-card": "rgb(28 28 31 / 0.66)", + "--bg-card-hover": "rgb(37 37 40 / 0.72)", + "--bg-muted": "rgb(39 39 42 / 0.72)", + "--bg-elevated": "rgb(63 63 70 / 0.78)", + "--bg-overlay": "rgb(28 28 31 / 0.86)", + "--fg": "#fafafa", + "--fg-muted": "#a1a1aa", + "--fg-subtle": "#71717a", + "--border": "#27272a", + "--danger": "#dc2626", + "--afk-text": "#ffffff", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 0, + "backdropAvailable": false, + "dataTheme": "dark", + "dataGlass": "0", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0, + "cardOpacity": 0.66, + "hoverOpacity": 0.72, + "mutedOpacity": 0.72, + "elevatedOpacity": 0.78, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "9 9 11", + "--bg-opacity": "0", + "--bg-solid": "rgb(9 9 11)", + "--bg-input": "rgb(9 9 11)", + "--bg-card": "rgb(28 28 31 / 0.66)", + "--bg-card-hover": "rgb(37 37 40 / 0.72)", + "--bg-muted": "rgb(39 39 42 / 0.72)", + "--bg-elevated": "rgb(63 63 70 / 0.78)", + "--bg-overlay": "rgb(28 28 31 / 0.86)", + "--fg": "#fafafa", + "--fg-muted": "#a1a1aa", + "--fg-subtle": "#71717a", + "--border": "#27272a", + "--danger": "#dc2626", + "--afk-text": "#ffffff", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 50, + "backdropAvailable": true, + "dataTheme": "dark", + "dataGlass": "0", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.5, + "cardOpacity": 0.66, + "hoverOpacity": 0.72, + "mutedOpacity": 0.72, + "elevatedOpacity": 0.78, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "9 9 11", + "--bg-opacity": "0.5", + "--bg-solid": "rgb(9 9 11)", + "--bg-input": "rgb(9 9 11)", + "--bg-card": "rgb(28 28 31 / 0.66)", + "--bg-card-hover": "rgb(37 37 40 / 0.72)", + "--bg-muted": "rgb(39 39 42 / 0.72)", + "--bg-elevated": "rgb(63 63 70 / 0.78)", + "--bg-overlay": "rgb(28 28 31 / 0.86)", + "--fg": "#fafafa", + "--fg-muted": "#a1a1aa", + "--fg-subtle": "#71717a", + "--border": "#27272a", + "--danger": "#dc2626", + "--afk-text": "#ffffff", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 50, + "backdropAvailable": false, + "dataTheme": "dark", + "dataGlass": "0", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.5, + "cardOpacity": 0.66, + "hoverOpacity": 0.72, + "mutedOpacity": 0.72, + "elevatedOpacity": 0.78, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "9 9 11", + "--bg-opacity": "0.5", + "--bg-solid": "rgb(9 9 11)", + "--bg-input": "rgb(9 9 11)", + "--bg-card": "rgb(28 28 31 / 0.66)", + "--bg-card-hover": "rgb(37 37 40 / 0.72)", + "--bg-muted": "rgb(39 39 42 / 0.72)", + "--bg-elevated": "rgb(63 63 70 / 0.78)", + "--bg-overlay": "rgb(28 28 31 / 0.86)", + "--fg": "#fafafa", + "--fg-muted": "#a1a1aa", + "--fg-subtle": "#71717a", + "--border": "#27272a", + "--danger": "#dc2626", + "--afk-text": "#ffffff", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 85, + "backdropAvailable": true, + "dataTheme": "dark", + "dataGlass": "0", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.85, + "cardOpacity": 0.99, + "hoverOpacity": 1, + "mutedOpacity": 1, + "elevatedOpacity": 1, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "9 9 11", + "--bg-opacity": "0.85", + "--bg-solid": "rgb(9 9 11)", + "--bg-input": "rgb(9 9 11)", + "--bg-card": "rgb(28 28 31 / 0.99)", + "--bg-card-hover": "rgb(37 37 40 / 1)", + "--bg-muted": "rgb(39 39 42 / 1)", + "--bg-elevated": "rgb(63 63 70 / 1)", + "--bg-overlay": "rgb(28 28 31 / 1)", + "--fg": "#fafafa", + "--fg-muted": "#a1a1aa", + "--fg-subtle": "#71717a", + "--border": "#27272a", + "--danger": "#dc2626", + "--afk-text": "#ffffff", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 85, + "backdropAvailable": false, + "dataTheme": "dark", + "dataGlass": "0", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.85, + "cardOpacity": 0.99, + "hoverOpacity": 1, + "mutedOpacity": 1, + "elevatedOpacity": 1, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "9 9 11", + "--bg-opacity": "0.85", + "--bg-solid": "rgb(9 9 11)", + "--bg-input": "rgb(9 9 11)", + "--bg-card": "rgb(28 28 31 / 0.99)", + "--bg-card-hover": "rgb(37 37 40 / 1)", + "--bg-muted": "rgb(39 39 42 / 1)", + "--bg-elevated": "rgb(63 63 70 / 1)", + "--bg-overlay": "rgb(28 28 31 / 1)", + "--fg": "#fafafa", + "--fg-muted": "#a1a1aa", + "--fg-subtle": "#71717a", + "--border": "#27272a", + "--danger": "#dc2626", + "--afk-text": "#ffffff", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 100, + "backdropAvailable": true, + "dataTheme": "dark", + "dataGlass": "0", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 1, + "cardOpacity": 1, + "hoverOpacity": 1, + "mutedOpacity": 1, + "elevatedOpacity": 1, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "9 9 11", + "--bg-opacity": "1", + "--bg-solid": "rgb(9 9 11)", + "--bg-input": "rgb(9 9 11)", + "--bg-card": "rgb(28 28 31 / 1)", + "--bg-card-hover": "rgb(37 37 40 / 1)", + "--bg-muted": "rgb(39 39 42 / 1)", + "--bg-elevated": "rgb(63 63 70 / 1)", + "--bg-overlay": "rgb(28 28 31 / 1)", + "--fg": "#fafafa", + "--fg-muted": "#a1a1aa", + "--fg-subtle": "#71717a", + "--border": "#27272a", + "--danger": "#dc2626", + "--afk-text": "#ffffff", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 100, + "backdropAvailable": false, + "dataTheme": "dark", + "dataGlass": "0", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 1, + "cardOpacity": 1, + "hoverOpacity": 1, + "mutedOpacity": 1, + "elevatedOpacity": 1, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "9 9 11", + "--bg-opacity": "1", + "--bg-solid": "rgb(9 9 11)", + "--bg-input": "rgb(9 9 11)", + "--bg-card": "rgb(28 28 31 / 1)", + "--bg-card-hover": "rgb(37 37 40 / 1)", + "--bg-muted": "rgb(39 39 42 / 1)", + "--bg-elevated": "rgb(63 63 70 / 1)", + "--bg-overlay": "rgb(28 28 31 / 1)", + "--fg": "#fafafa", + "--fg-muted": "#a1a1aa", + "--fg-subtle": "#71717a", + "--border": "#27272a", + "--danger": "#dc2626", + "--afk-text": "#ffffff", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + } + ] +} diff --git a/crates/accshift-theme/tests/data/builtin-glass-dark.json b/crates/accshift-theme/tests/data/builtin-glass-dark.json new file mode 100644 index 0000000..4e053b7 --- /dev/null +++ b/crates/accshift-theme/tests/data/builtin-glass-dark.json @@ -0,0 +1,526 @@ +{ + "id": "glass-dark", + "payload": null, + "isCustom": false, + "liquid": false, + "tokens": { + "bgRgb": "5 5 8", + "bgCard": "#131318", + "bgCardHover": "#1b1b22", + "bgMuted": "#17171e", + "bgElevated": "#2a2a34", + "fg": "#f4f4f6", + "fgMuted": "#aeaeba", + "fgSubtle": "#7c7c88", + "border": "#2c2c36", + "danger": "#ef4444", + "afkText": "#ffffff", + "accent": "#2563eb", + "accentFg": "#ffffff", + "success": "#22c55e", + "warning": "#eab308", + "radiusSm": "4px", + "radiusMd": "8px", + "radiusLg": "12px", + "elevationLow": "0 2px 8px rgb(0 0 0 / 0.18)", + "elevationMedium": "0 8px 24px rgb(0 0 0 / 0.32)", + "elevationHigh": "0 18px 48px rgb(0 0 0 / 0.45)", + "density": "cozy", + "fontUi": "Inter", + "bgImage": "none", + "cardBgImage": "none", + "borderWidth": "1px", + "borderStyle": "solid", + "avatarShape": "rounded", + "focusRing": "solid", + "overlayBlur": "4px", + "fontDisplay": "Inter", + "lineHeight": "1.5", + "letterSpacing": "0", + "labelCase": "none", + "fontSmoothing": "auto", + "motionScale": "1" + }, + "cases": [ + { + "opacity": 0, + "backdropAvailable": true, + "dataTheme": "glass-dark", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.55, + "cardOpacity": 0.65, + "hoverOpacity": 0.73, + "mutedOpacity": 0.6900000000000001, + "elevatedOpacity": 0.75, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "5 5 8", + "--bg-opacity": "0.55", + "--bg-solid": "rgb(5 5 8)", + "--bg-input": "rgb(5 5 8 / 0.55)", + "--bg-card": "rgb(19 19 24 / 0.65)", + "--bg-card-hover": "rgb(27 27 34 / 0.73)", + "--bg-muted": "rgb(23 23 30 / 0.6900000000000001)", + "--bg-elevated": "rgb(42 42 52 / 0.75)", + "--bg-overlay": "rgb(19 19 24 / 0.86)", + "--fg": "#f4f4f6", + "--fg-muted": "#aeaeba", + "--fg-subtle": "#7c7c88", + "--border": "#2c2c36", + "--danger": "#ef4444", + "--afk-text": "#ffffff", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 0, + "backdropAvailable": false, + "dataTheme": "glass-dark", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.96, + "cardOpacity": 0.72, + "hoverOpacity": 0.7999999999999999, + "mutedOpacity": 0.78, + "elevatedOpacity": 0.85, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "5 5 8", + "--bg-opacity": "0.96", + "--bg-solid": "rgb(5 5 8)", + "--bg-input": "rgb(5 5 8 / 0.55)", + "--bg-card": "rgb(19 19 24 / 0.72)", + "--bg-card-hover": "rgb(27 27 34 / 0.7999999999999999)", + "--bg-muted": "rgb(23 23 30 / 0.78)", + "--bg-elevated": "rgb(42 42 52 / 0.85)", + "--bg-overlay": "rgb(19 19 24 / 1)", + "--fg": "#f4f4f6", + "--fg-muted": "#aeaeba", + "--fg-subtle": "#7c7c88", + "--border": "#2c2c36", + "--danger": "#ef4444", + "--afk-text": "#ffffff", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 50, + "backdropAvailable": true, + "dataTheme": "glass-dark", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.55, + "cardOpacity": 0.65, + "hoverOpacity": 0.73, + "mutedOpacity": 0.6900000000000001, + "elevatedOpacity": 0.75, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "5 5 8", + "--bg-opacity": "0.55", + "--bg-solid": "rgb(5 5 8)", + "--bg-input": "rgb(5 5 8 / 0.55)", + "--bg-card": "rgb(19 19 24 / 0.65)", + "--bg-card-hover": "rgb(27 27 34 / 0.73)", + "--bg-muted": "rgb(23 23 30 / 0.6900000000000001)", + "--bg-elevated": "rgb(42 42 52 / 0.75)", + "--bg-overlay": "rgb(19 19 24 / 0.86)", + "--fg": "#f4f4f6", + "--fg-muted": "#aeaeba", + "--fg-subtle": "#7c7c88", + "--border": "#2c2c36", + "--danger": "#ef4444", + "--afk-text": "#ffffff", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 50, + "backdropAvailable": false, + "dataTheme": "glass-dark", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.96, + "cardOpacity": 0.72, + "hoverOpacity": 0.7999999999999999, + "mutedOpacity": 0.78, + "elevatedOpacity": 0.85, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "5 5 8", + "--bg-opacity": "0.96", + "--bg-solid": "rgb(5 5 8)", + "--bg-input": "rgb(5 5 8 / 0.55)", + "--bg-card": "rgb(19 19 24 / 0.72)", + "--bg-card-hover": "rgb(27 27 34 / 0.7999999999999999)", + "--bg-muted": "rgb(23 23 30 / 0.78)", + "--bg-elevated": "rgb(42 42 52 / 0.85)", + "--bg-overlay": "rgb(19 19 24 / 1)", + "--fg": "#f4f4f6", + "--fg-muted": "#aeaeba", + "--fg-subtle": "#7c7c88", + "--border": "#2c2c36", + "--danger": "#ef4444", + "--afk-text": "#ffffff", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 85, + "backdropAvailable": true, + "dataTheme": "glass-dark", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.55, + "cardOpacity": 0.65, + "hoverOpacity": 0.73, + "mutedOpacity": 0.6900000000000001, + "elevatedOpacity": 0.75, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "5 5 8", + "--bg-opacity": "0.55", + "--bg-solid": "rgb(5 5 8)", + "--bg-input": "rgb(5 5 8 / 0.55)", + "--bg-card": "rgb(19 19 24 / 0.65)", + "--bg-card-hover": "rgb(27 27 34 / 0.73)", + "--bg-muted": "rgb(23 23 30 / 0.6900000000000001)", + "--bg-elevated": "rgb(42 42 52 / 0.75)", + "--bg-overlay": "rgb(19 19 24 / 0.86)", + "--fg": "#f4f4f6", + "--fg-muted": "#aeaeba", + "--fg-subtle": "#7c7c88", + "--border": "#2c2c36", + "--danger": "#ef4444", + "--afk-text": "#ffffff", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 85, + "backdropAvailable": false, + "dataTheme": "glass-dark", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.96, + "cardOpacity": 0.72, + "hoverOpacity": 0.7999999999999999, + "mutedOpacity": 0.78, + "elevatedOpacity": 0.85, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "5 5 8", + "--bg-opacity": "0.96", + "--bg-solid": "rgb(5 5 8)", + "--bg-input": "rgb(5 5 8 / 0.55)", + "--bg-card": "rgb(19 19 24 / 0.72)", + "--bg-card-hover": "rgb(27 27 34 / 0.7999999999999999)", + "--bg-muted": "rgb(23 23 30 / 0.78)", + "--bg-elevated": "rgb(42 42 52 / 0.85)", + "--bg-overlay": "rgb(19 19 24 / 1)", + "--fg": "#f4f4f6", + "--fg-muted": "#aeaeba", + "--fg-subtle": "#7c7c88", + "--border": "#2c2c36", + "--danger": "#ef4444", + "--afk-text": "#ffffff", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 100, + "backdropAvailable": true, + "dataTheme": "glass-dark", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.55, + "cardOpacity": 0.65, + "hoverOpacity": 0.73, + "mutedOpacity": 0.6900000000000001, + "elevatedOpacity": 0.75, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "5 5 8", + "--bg-opacity": "0.55", + "--bg-solid": "rgb(5 5 8)", + "--bg-input": "rgb(5 5 8 / 0.55)", + "--bg-card": "rgb(19 19 24 / 0.65)", + "--bg-card-hover": "rgb(27 27 34 / 0.73)", + "--bg-muted": "rgb(23 23 30 / 0.6900000000000001)", + "--bg-elevated": "rgb(42 42 52 / 0.75)", + "--bg-overlay": "rgb(19 19 24 / 0.86)", + "--fg": "#f4f4f6", + "--fg-muted": "#aeaeba", + "--fg-subtle": "#7c7c88", + "--border": "#2c2c36", + "--danger": "#ef4444", + "--afk-text": "#ffffff", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 100, + "backdropAvailable": false, + "dataTheme": "glass-dark", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.96, + "cardOpacity": 0.72, + "hoverOpacity": 0.7999999999999999, + "mutedOpacity": 0.78, + "elevatedOpacity": 0.85, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "5 5 8", + "--bg-opacity": "0.96", + "--bg-solid": "rgb(5 5 8)", + "--bg-input": "rgb(5 5 8 / 0.55)", + "--bg-card": "rgb(19 19 24 / 0.72)", + "--bg-card-hover": "rgb(27 27 34 / 0.7999999999999999)", + "--bg-muted": "rgb(23 23 30 / 0.78)", + "--bg-elevated": "rgb(42 42 52 / 0.85)", + "--bg-overlay": "rgb(19 19 24 / 1)", + "--fg": "#f4f4f6", + "--fg-muted": "#aeaeba", + "--fg-subtle": "#7c7c88", + "--border": "#2c2c36", + "--danger": "#ef4444", + "--afk-text": "#ffffff", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + } + ] +} diff --git a/crates/accshift-theme/tests/data/builtin-glass-light.json b/crates/accshift-theme/tests/data/builtin-glass-light.json new file mode 100644 index 0000000..965feac --- /dev/null +++ b/crates/accshift-theme/tests/data/builtin-glass-light.json @@ -0,0 +1,526 @@ +{ + "id": "glass-light", + "payload": null, + "isCustom": false, + "liquid": false, + "tokens": { + "bgRgb": "236 238 244", + "bgCard": "#fbfbfd", + "bgCardHover": "#f0f1f6", + "bgMuted": "#e2e4ec", + "bgElevated": "#c8ccd8", + "fg": "#101018", + "fgMuted": "#3a3a48", + "fgSubtle": "#5e5e70", + "border": "#c2c6d2", + "danger": "#dc2626", + "afkText": "#000000", + "accent": "#2563eb", + "accentFg": "#ffffff", + "success": "#15803d", + "warning": "#a16207", + "radiusSm": "4px", + "radiusMd": "8px", + "radiusLg": "12px", + "elevationLow": "0 2px 8px rgb(0 0 0 / 0.10)", + "elevationMedium": "0 8px 24px rgb(0 0 0 / 0.16)", + "elevationHigh": "0 18px 48px rgb(0 0 0 / 0.22)", + "density": "cozy", + "fontUi": "Inter", + "bgImage": "none", + "cardBgImage": "none", + "borderWidth": "1px", + "borderStyle": "solid", + "avatarShape": "rounded", + "focusRing": "solid", + "overlayBlur": "4px", + "fontDisplay": "Inter", + "lineHeight": "1.5", + "letterSpacing": "0", + "labelCase": "none", + "fontSmoothing": "auto", + "motionScale": "1" + }, + "cases": [ + { + "opacity": 0, + "backdropAvailable": true, + "dataTheme": "glass-light", + "dataGlass": "1", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 0.55, + "cardOpacity": 0.65, + "hoverOpacity": 0.73, + "mutedOpacity": 0.6900000000000001, + "elevatedOpacity": 0.75, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "236 238 244", + "--bg-opacity": "0.55", + "--bg-solid": "rgb(236 238 244)", + "--bg-input": "rgb(236 238 244 / 0.55)", + "--bg-card": "rgb(251 251 253 / 0.65)", + "--bg-card-hover": "rgb(240 241 246 / 0.73)", + "--bg-muted": "rgb(226 228 236 / 0.6900000000000001)", + "--bg-elevated": "rgb(200 204 216 / 0.75)", + "--bg-overlay": "rgb(251 251 253 / 0.86)", + "--fg": "#101018", + "--fg-muted": "#3a3a48", + "--fg-subtle": "#5e5e70", + "--border": "#c2c6d2", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 0, + "backdropAvailable": false, + "dataTheme": "glass-light", + "dataGlass": "1", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 0.96, + "cardOpacity": 0.72, + "hoverOpacity": 0.7999999999999999, + "mutedOpacity": 0.78, + "elevatedOpacity": 0.85, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "236 238 244", + "--bg-opacity": "0.96", + "--bg-solid": "rgb(236 238 244)", + "--bg-input": "rgb(236 238 244 / 0.55)", + "--bg-card": "rgb(251 251 253 / 0.72)", + "--bg-card-hover": "rgb(240 241 246 / 0.7999999999999999)", + "--bg-muted": "rgb(226 228 236 / 0.78)", + "--bg-elevated": "rgb(200 204 216 / 0.85)", + "--bg-overlay": "rgb(251 251 253 / 1)", + "--fg": "#101018", + "--fg-muted": "#3a3a48", + "--fg-subtle": "#5e5e70", + "--border": "#c2c6d2", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 50, + "backdropAvailable": true, + "dataTheme": "glass-light", + "dataGlass": "1", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 0.55, + "cardOpacity": 0.65, + "hoverOpacity": 0.73, + "mutedOpacity": 0.6900000000000001, + "elevatedOpacity": 0.75, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "236 238 244", + "--bg-opacity": "0.55", + "--bg-solid": "rgb(236 238 244)", + "--bg-input": "rgb(236 238 244 / 0.55)", + "--bg-card": "rgb(251 251 253 / 0.65)", + "--bg-card-hover": "rgb(240 241 246 / 0.73)", + "--bg-muted": "rgb(226 228 236 / 0.6900000000000001)", + "--bg-elevated": "rgb(200 204 216 / 0.75)", + "--bg-overlay": "rgb(251 251 253 / 0.86)", + "--fg": "#101018", + "--fg-muted": "#3a3a48", + "--fg-subtle": "#5e5e70", + "--border": "#c2c6d2", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 50, + "backdropAvailable": false, + "dataTheme": "glass-light", + "dataGlass": "1", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 0.96, + "cardOpacity": 0.72, + "hoverOpacity": 0.7999999999999999, + "mutedOpacity": 0.78, + "elevatedOpacity": 0.85, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "236 238 244", + "--bg-opacity": "0.96", + "--bg-solid": "rgb(236 238 244)", + "--bg-input": "rgb(236 238 244 / 0.55)", + "--bg-card": "rgb(251 251 253 / 0.72)", + "--bg-card-hover": "rgb(240 241 246 / 0.7999999999999999)", + "--bg-muted": "rgb(226 228 236 / 0.78)", + "--bg-elevated": "rgb(200 204 216 / 0.85)", + "--bg-overlay": "rgb(251 251 253 / 1)", + "--fg": "#101018", + "--fg-muted": "#3a3a48", + "--fg-subtle": "#5e5e70", + "--border": "#c2c6d2", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 85, + "backdropAvailable": true, + "dataTheme": "glass-light", + "dataGlass": "1", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 0.55, + "cardOpacity": 0.65, + "hoverOpacity": 0.73, + "mutedOpacity": 0.6900000000000001, + "elevatedOpacity": 0.75, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "236 238 244", + "--bg-opacity": "0.55", + "--bg-solid": "rgb(236 238 244)", + "--bg-input": "rgb(236 238 244 / 0.55)", + "--bg-card": "rgb(251 251 253 / 0.65)", + "--bg-card-hover": "rgb(240 241 246 / 0.73)", + "--bg-muted": "rgb(226 228 236 / 0.6900000000000001)", + "--bg-elevated": "rgb(200 204 216 / 0.75)", + "--bg-overlay": "rgb(251 251 253 / 0.86)", + "--fg": "#101018", + "--fg-muted": "#3a3a48", + "--fg-subtle": "#5e5e70", + "--border": "#c2c6d2", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 85, + "backdropAvailable": false, + "dataTheme": "glass-light", + "dataGlass": "1", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 0.96, + "cardOpacity": 0.72, + "hoverOpacity": 0.7999999999999999, + "mutedOpacity": 0.78, + "elevatedOpacity": 0.85, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "236 238 244", + "--bg-opacity": "0.96", + "--bg-solid": "rgb(236 238 244)", + "--bg-input": "rgb(236 238 244 / 0.55)", + "--bg-card": "rgb(251 251 253 / 0.72)", + "--bg-card-hover": "rgb(240 241 246 / 0.7999999999999999)", + "--bg-muted": "rgb(226 228 236 / 0.78)", + "--bg-elevated": "rgb(200 204 216 / 0.85)", + "--bg-overlay": "rgb(251 251 253 / 1)", + "--fg": "#101018", + "--fg-muted": "#3a3a48", + "--fg-subtle": "#5e5e70", + "--border": "#c2c6d2", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 100, + "backdropAvailable": true, + "dataTheme": "glass-light", + "dataGlass": "1", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 0.55, + "cardOpacity": 0.65, + "hoverOpacity": 0.73, + "mutedOpacity": 0.6900000000000001, + "elevatedOpacity": 0.75, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "236 238 244", + "--bg-opacity": "0.55", + "--bg-solid": "rgb(236 238 244)", + "--bg-input": "rgb(236 238 244 / 0.55)", + "--bg-card": "rgb(251 251 253 / 0.65)", + "--bg-card-hover": "rgb(240 241 246 / 0.73)", + "--bg-muted": "rgb(226 228 236 / 0.6900000000000001)", + "--bg-elevated": "rgb(200 204 216 / 0.75)", + "--bg-overlay": "rgb(251 251 253 / 0.86)", + "--fg": "#101018", + "--fg-muted": "#3a3a48", + "--fg-subtle": "#5e5e70", + "--border": "#c2c6d2", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 100, + "backdropAvailable": false, + "dataTheme": "glass-light", + "dataGlass": "1", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 0.96, + "cardOpacity": 0.72, + "hoverOpacity": 0.7999999999999999, + "mutedOpacity": 0.78, + "elevatedOpacity": 0.85, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "236 238 244", + "--bg-opacity": "0.96", + "--bg-solid": "rgb(236 238 244)", + "--bg-input": "rgb(236 238 244 / 0.55)", + "--bg-card": "rgb(251 251 253 / 0.72)", + "--bg-card-hover": "rgb(240 241 246 / 0.7999999999999999)", + "--bg-muted": "rgb(226 228 236 / 0.78)", + "--bg-elevated": "rgb(200 204 216 / 0.85)", + "--bg-overlay": "rgb(251 251 253 / 1)", + "--fg": "#101018", + "--fg-muted": "#3a3a48", + "--fg-subtle": "#5e5e70", + "--border": "#c2c6d2", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + } + ] +} diff --git a/crates/accshift-theme/tests/data/builtin-light.json b/crates/accshift-theme/tests/data/builtin-light.json new file mode 100644 index 0000000..b004229 --- /dev/null +++ b/crates/accshift-theme/tests/data/builtin-light.json @@ -0,0 +1,526 @@ +{ + "id": "light", + "payload": null, + "isCustom": false, + "liquid": false, + "tokens": { + "bgRgb": "241 241 243", + "bgCard": "#d8d8de", + "bgCardHover": "#cfcfd7", + "bgMuted": "#c4c4ce", + "bgElevated": "#aeaebc", + "fg": "#0b0b0f", + "fgMuted": "#2b2b36", + "fgSubtle": "#4e4e5d", + "border": "#b8b8c5", + "danger": "#dc2626", + "afkText": "#000000", + "accent": "#2563eb", + "accentFg": "#ffffff", + "success": "#15803d", + "warning": "#a16207", + "radiusSm": "4px", + "radiusMd": "8px", + "radiusLg": "12px", + "elevationLow": "0 2px 8px rgb(0 0 0 / 0.10)", + "elevationMedium": "0 8px 24px rgb(0 0 0 / 0.16)", + "elevationHigh": "0 18px 48px rgb(0 0 0 / 0.22)", + "density": "cozy", + "fontUi": "Inter", + "bgImage": "none", + "cardBgImage": "none", + "borderWidth": "1px", + "borderStyle": "solid", + "avatarShape": "rounded", + "focusRing": "solid", + "overlayBlur": "4px", + "fontDisplay": "Inter", + "lineHeight": "1.5", + "letterSpacing": "0", + "labelCase": "none", + "fontSmoothing": "auto", + "motionScale": "1" + }, + "cases": [ + { + "opacity": 0, + "backdropAvailable": true, + "dataTheme": "light", + "dataGlass": "0", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 0, + "cardOpacity": 0.66, + "hoverOpacity": 0.72, + "mutedOpacity": 0.72, + "elevatedOpacity": 0.78, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "241 241 243", + "--bg-opacity": "0", + "--bg-solid": "rgb(241 241 243)", + "--bg-input": "rgb(241 241 243)", + "--bg-card": "rgb(216 216 222 / 0.66)", + "--bg-card-hover": "rgb(207 207 215 / 0.72)", + "--bg-muted": "rgb(196 196 206 / 0.72)", + "--bg-elevated": "rgb(174 174 188 / 0.78)", + "--bg-overlay": "rgb(216 216 222 / 0.86)", + "--fg": "#0b0b0f", + "--fg-muted": "#2b2b36", + "--fg-subtle": "#4e4e5d", + "--border": "#b8b8c5", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 0, + "backdropAvailable": false, + "dataTheme": "light", + "dataGlass": "0", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 0, + "cardOpacity": 0.66, + "hoverOpacity": 0.72, + "mutedOpacity": 0.72, + "elevatedOpacity": 0.78, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "241 241 243", + "--bg-opacity": "0", + "--bg-solid": "rgb(241 241 243)", + "--bg-input": "rgb(241 241 243)", + "--bg-card": "rgb(216 216 222 / 0.66)", + "--bg-card-hover": "rgb(207 207 215 / 0.72)", + "--bg-muted": "rgb(196 196 206 / 0.72)", + "--bg-elevated": "rgb(174 174 188 / 0.78)", + "--bg-overlay": "rgb(216 216 222 / 0.86)", + "--fg": "#0b0b0f", + "--fg-muted": "#2b2b36", + "--fg-subtle": "#4e4e5d", + "--border": "#b8b8c5", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 50, + "backdropAvailable": true, + "dataTheme": "light", + "dataGlass": "0", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 0.5, + "cardOpacity": 0.66, + "hoverOpacity": 0.72, + "mutedOpacity": 0.72, + "elevatedOpacity": 0.78, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "241 241 243", + "--bg-opacity": "0.5", + "--bg-solid": "rgb(241 241 243)", + "--bg-input": "rgb(241 241 243)", + "--bg-card": "rgb(216 216 222 / 0.66)", + "--bg-card-hover": "rgb(207 207 215 / 0.72)", + "--bg-muted": "rgb(196 196 206 / 0.72)", + "--bg-elevated": "rgb(174 174 188 / 0.78)", + "--bg-overlay": "rgb(216 216 222 / 0.86)", + "--fg": "#0b0b0f", + "--fg-muted": "#2b2b36", + "--fg-subtle": "#4e4e5d", + "--border": "#b8b8c5", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 50, + "backdropAvailable": false, + "dataTheme": "light", + "dataGlass": "0", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 0.5, + "cardOpacity": 0.66, + "hoverOpacity": 0.72, + "mutedOpacity": 0.72, + "elevatedOpacity": 0.78, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "241 241 243", + "--bg-opacity": "0.5", + "--bg-solid": "rgb(241 241 243)", + "--bg-input": "rgb(241 241 243)", + "--bg-card": "rgb(216 216 222 / 0.66)", + "--bg-card-hover": "rgb(207 207 215 / 0.72)", + "--bg-muted": "rgb(196 196 206 / 0.72)", + "--bg-elevated": "rgb(174 174 188 / 0.78)", + "--bg-overlay": "rgb(216 216 222 / 0.86)", + "--fg": "#0b0b0f", + "--fg-muted": "#2b2b36", + "--fg-subtle": "#4e4e5d", + "--border": "#b8b8c5", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 85, + "backdropAvailable": true, + "dataTheme": "light", + "dataGlass": "0", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 0.85, + "cardOpacity": 0.99, + "hoverOpacity": 1, + "mutedOpacity": 1, + "elevatedOpacity": 1, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "241 241 243", + "--bg-opacity": "0.85", + "--bg-solid": "rgb(241 241 243)", + "--bg-input": "rgb(241 241 243)", + "--bg-card": "rgb(216 216 222 / 0.99)", + "--bg-card-hover": "rgb(207 207 215 / 1)", + "--bg-muted": "rgb(196 196 206 / 1)", + "--bg-elevated": "rgb(174 174 188 / 1)", + "--bg-overlay": "rgb(216 216 222 / 1)", + "--fg": "#0b0b0f", + "--fg-muted": "#2b2b36", + "--fg-subtle": "#4e4e5d", + "--border": "#b8b8c5", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 85, + "backdropAvailable": false, + "dataTheme": "light", + "dataGlass": "0", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 0.85, + "cardOpacity": 0.99, + "hoverOpacity": 1, + "mutedOpacity": 1, + "elevatedOpacity": 1, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "241 241 243", + "--bg-opacity": "0.85", + "--bg-solid": "rgb(241 241 243)", + "--bg-input": "rgb(241 241 243)", + "--bg-card": "rgb(216 216 222 / 0.99)", + "--bg-card-hover": "rgb(207 207 215 / 1)", + "--bg-muted": "rgb(196 196 206 / 1)", + "--bg-elevated": "rgb(174 174 188 / 1)", + "--bg-overlay": "rgb(216 216 222 / 1)", + "--fg": "#0b0b0f", + "--fg-muted": "#2b2b36", + "--fg-subtle": "#4e4e5d", + "--border": "#b8b8c5", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 100, + "backdropAvailable": true, + "dataTheme": "light", + "dataGlass": "0", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 1, + "cardOpacity": 1, + "hoverOpacity": 1, + "mutedOpacity": 1, + "elevatedOpacity": 1, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "241 241 243", + "--bg-opacity": "1", + "--bg-solid": "rgb(241 241 243)", + "--bg-input": "rgb(241 241 243)", + "--bg-card": "rgb(216 216 222 / 1)", + "--bg-card-hover": "rgb(207 207 215 / 1)", + "--bg-muted": "rgb(196 196 206 / 1)", + "--bg-elevated": "rgb(174 174 188 / 1)", + "--bg-overlay": "rgb(216 216 222 / 1)", + "--fg": "#0b0b0f", + "--fg-muted": "#2b2b36", + "--fg-subtle": "#4e4e5d", + "--border": "#b8b8c5", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 100, + "backdropAvailable": false, + "dataTheme": "light", + "dataGlass": "0", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 1, + "cardOpacity": 1, + "hoverOpacity": 1, + "mutedOpacity": 1, + "elevatedOpacity": 1, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "241 241 243", + "--bg-opacity": "1", + "--bg-solid": "rgb(241 241 243)", + "--bg-input": "rgb(241 241 243)", + "--bg-card": "rgb(216 216 222 / 1)", + "--bg-card-hover": "rgb(207 207 215 / 1)", + "--bg-muted": "rgb(196 196 206 / 1)", + "--bg-elevated": "rgb(174 174 188 / 1)", + "--bg-overlay": "rgb(216 216 222 / 1)", + "--fg": "#0b0b0f", + "--fg-muted": "#2b2b36", + "--fg-subtle": "#4e4e5d", + "--border": "#b8b8c5", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + } + ] +} diff --git a/crates/accshift-theme/tests/data/builtin-liquid-glass.json b/crates/accshift-theme/tests/data/builtin-liquid-glass.json new file mode 100644 index 0000000..b645837 --- /dev/null +++ b/crates/accshift-theme/tests/data/builtin-liquid-glass.json @@ -0,0 +1,526 @@ +{ + "id": "liquid-glass", + "payload": null, + "isCustom": false, + "liquid": true, + "tokens": { + "bgRgb": "22 24 30", + "bgCard": "#ffffff", + "bgCardHover": "#ffffff", + "bgMuted": "#ffffff", + "bgElevated": "#ffffff", + "fg": "#ffffff", + "fgMuted": "#dbdfe7", + "fgSubtle": "#b0b6c2", + "border": "#a9b1c0", + "danger": "#ef4444", + "afkText": "#f4f6f9", + "accent": "#2563eb", + "accentFg": "#ffffff", + "success": "#22c55e", + "warning": "#eab308", + "radiusSm": "4px", + "radiusMd": "8px", + "radiusLg": "12px", + "elevationLow": "0 2px 8px rgb(0 0 0 / 0.18)", + "elevationMedium": "0 8px 24px rgb(0 0 0 / 0.32)", + "elevationHigh": "0 18px 48px rgb(0 0 0 / 0.45)", + "density": "cozy", + "fontUi": "Inter", + "bgImage": "none", + "cardBgImage": "none", + "borderWidth": "1px", + "borderStyle": "solid", + "avatarShape": "rounded", + "focusRing": "solid", + "overlayBlur": "4px", + "fontDisplay": "Inter", + "lineHeight": "1.5", + "letterSpacing": "0", + "labelCase": "none", + "fontSmoothing": "auto", + "motionScale": "1" + }, + "cases": [ + { + "opacity": 0, + "backdropAvailable": true, + "dataTheme": "liquid-glass", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.18, + "cardOpacity": 0.13, + "hoverOpacity": 0.21, + "mutedOpacity": 0.16, + "elevatedOpacity": 0.34, + "overlayOpacity": 0.86, + "isLiquid": true + }, + "vars": { + "--bg-rgb": "22 24 30", + "--bg-opacity": "0.18", + "--bg-solid": "rgb(22 24 30)", + "--bg-input": "rgb(22 24 30 / 0.55)", + "--bg-card": "rgb(255 255 255 / 0.13)", + "--bg-card-hover": "rgb(255 255 255 / 0.21)", + "--bg-muted": "rgb(255 255 255 / 0.16)", + "--bg-elevated": "rgb(255 255 255 / 0.34)", + "--bg-overlay": "rgb(22 24 30 / 0.94)", + "--fg": "#ffffff", + "--fg-muted": "#dbdfe7", + "--fg-subtle": "#b0b6c2", + "--border": "#a9b1c0", + "--danger": "#ef4444", + "--afk-text": "#f4f6f9", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 0, + "backdropAvailable": false, + "dataTheme": "liquid-glass", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.96, + "cardOpacity": 0.72, + "hoverOpacity": 0.7999999999999999, + "mutedOpacity": 0.78, + "elevatedOpacity": 0.85, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "22 24 30", + "--bg-opacity": "0.96", + "--bg-solid": "rgb(22 24 30)", + "--bg-input": "rgb(22 24 30 / 0.55)", + "--bg-card": "rgb(255 255 255 / 0.72)", + "--bg-card-hover": "rgb(255 255 255 / 0.7999999999999999)", + "--bg-muted": "rgb(255 255 255 / 0.78)", + "--bg-elevated": "rgb(255 255 255 / 0.85)", + "--bg-overlay": "rgb(255 255 255 / 1)", + "--fg": "#ffffff", + "--fg-muted": "#dbdfe7", + "--fg-subtle": "#b0b6c2", + "--border": "#a9b1c0", + "--danger": "#ef4444", + "--afk-text": "#f4f6f9", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 50, + "backdropAvailable": true, + "dataTheme": "liquid-glass", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.18, + "cardOpacity": 0.13, + "hoverOpacity": 0.21, + "mutedOpacity": 0.16, + "elevatedOpacity": 0.34, + "overlayOpacity": 0.86, + "isLiquid": true + }, + "vars": { + "--bg-rgb": "22 24 30", + "--bg-opacity": "0.18", + "--bg-solid": "rgb(22 24 30)", + "--bg-input": "rgb(22 24 30 / 0.55)", + "--bg-card": "rgb(255 255 255 / 0.13)", + "--bg-card-hover": "rgb(255 255 255 / 0.21)", + "--bg-muted": "rgb(255 255 255 / 0.16)", + "--bg-elevated": "rgb(255 255 255 / 0.34)", + "--bg-overlay": "rgb(22 24 30 / 0.94)", + "--fg": "#ffffff", + "--fg-muted": "#dbdfe7", + "--fg-subtle": "#b0b6c2", + "--border": "#a9b1c0", + "--danger": "#ef4444", + "--afk-text": "#f4f6f9", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 50, + "backdropAvailable": false, + "dataTheme": "liquid-glass", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.96, + "cardOpacity": 0.72, + "hoverOpacity": 0.7999999999999999, + "mutedOpacity": 0.78, + "elevatedOpacity": 0.85, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "22 24 30", + "--bg-opacity": "0.96", + "--bg-solid": "rgb(22 24 30)", + "--bg-input": "rgb(22 24 30 / 0.55)", + "--bg-card": "rgb(255 255 255 / 0.72)", + "--bg-card-hover": "rgb(255 255 255 / 0.7999999999999999)", + "--bg-muted": "rgb(255 255 255 / 0.78)", + "--bg-elevated": "rgb(255 255 255 / 0.85)", + "--bg-overlay": "rgb(255 255 255 / 1)", + "--fg": "#ffffff", + "--fg-muted": "#dbdfe7", + "--fg-subtle": "#b0b6c2", + "--border": "#a9b1c0", + "--danger": "#ef4444", + "--afk-text": "#f4f6f9", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 85, + "backdropAvailable": true, + "dataTheme": "liquid-glass", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.18, + "cardOpacity": 0.13, + "hoverOpacity": 0.21, + "mutedOpacity": 0.16, + "elevatedOpacity": 0.34, + "overlayOpacity": 0.86, + "isLiquid": true + }, + "vars": { + "--bg-rgb": "22 24 30", + "--bg-opacity": "0.18", + "--bg-solid": "rgb(22 24 30)", + "--bg-input": "rgb(22 24 30 / 0.55)", + "--bg-card": "rgb(255 255 255 / 0.13)", + "--bg-card-hover": "rgb(255 255 255 / 0.21)", + "--bg-muted": "rgb(255 255 255 / 0.16)", + "--bg-elevated": "rgb(255 255 255 / 0.34)", + "--bg-overlay": "rgb(22 24 30 / 0.94)", + "--fg": "#ffffff", + "--fg-muted": "#dbdfe7", + "--fg-subtle": "#b0b6c2", + "--border": "#a9b1c0", + "--danger": "#ef4444", + "--afk-text": "#f4f6f9", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 85, + "backdropAvailable": false, + "dataTheme": "liquid-glass", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.96, + "cardOpacity": 0.72, + "hoverOpacity": 0.7999999999999999, + "mutedOpacity": 0.78, + "elevatedOpacity": 0.85, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "22 24 30", + "--bg-opacity": "0.96", + "--bg-solid": "rgb(22 24 30)", + "--bg-input": "rgb(22 24 30 / 0.55)", + "--bg-card": "rgb(255 255 255 / 0.72)", + "--bg-card-hover": "rgb(255 255 255 / 0.7999999999999999)", + "--bg-muted": "rgb(255 255 255 / 0.78)", + "--bg-elevated": "rgb(255 255 255 / 0.85)", + "--bg-overlay": "rgb(255 255 255 / 1)", + "--fg": "#ffffff", + "--fg-muted": "#dbdfe7", + "--fg-subtle": "#b0b6c2", + "--border": "#a9b1c0", + "--danger": "#ef4444", + "--afk-text": "#f4f6f9", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 100, + "backdropAvailable": true, + "dataTheme": "liquid-glass", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.18, + "cardOpacity": 0.13, + "hoverOpacity": 0.21, + "mutedOpacity": 0.16, + "elevatedOpacity": 0.34, + "overlayOpacity": 0.86, + "isLiquid": true + }, + "vars": { + "--bg-rgb": "22 24 30", + "--bg-opacity": "0.18", + "--bg-solid": "rgb(22 24 30)", + "--bg-input": "rgb(22 24 30 / 0.55)", + "--bg-card": "rgb(255 255 255 / 0.13)", + "--bg-card-hover": "rgb(255 255 255 / 0.21)", + "--bg-muted": "rgb(255 255 255 / 0.16)", + "--bg-elevated": "rgb(255 255 255 / 0.34)", + "--bg-overlay": "rgb(22 24 30 / 0.94)", + "--fg": "#ffffff", + "--fg-muted": "#dbdfe7", + "--fg-subtle": "#b0b6c2", + "--border": "#a9b1c0", + "--danger": "#ef4444", + "--afk-text": "#f4f6f9", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 100, + "backdropAvailable": false, + "dataTheme": "liquid-glass", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.96, + "cardOpacity": 0.72, + "hoverOpacity": 0.7999999999999999, + "mutedOpacity": 0.78, + "elevatedOpacity": 0.85, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "22 24 30", + "--bg-opacity": "0.96", + "--bg-solid": "rgb(22 24 30)", + "--bg-input": "rgb(22 24 30 / 0.55)", + "--bg-card": "rgb(255 255 255 / 0.72)", + "--bg-card-hover": "rgb(255 255 255 / 0.7999999999999999)", + "--bg-muted": "rgb(255 255 255 / 0.78)", + "--bg-elevated": "rgb(255 255 255 / 0.85)", + "--bg-overlay": "rgb(255 255 255 / 1)", + "--fg": "#ffffff", + "--fg-muted": "#dbdfe7", + "--fg-subtle": "#b0b6c2", + "--border": "#a9b1c0", + "--danger": "#ef4444", + "--afk-text": "#f4f6f9", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + } + ] +} diff --git a/crates/accshift-theme/tests/data/builtin-midnight.json b/crates/accshift-theme/tests/data/builtin-midnight.json new file mode 100644 index 0000000..fe93fbf --- /dev/null +++ b/crates/accshift-theme/tests/data/builtin-midnight.json @@ -0,0 +1,526 @@ +{ + "id": "midnight", + "payload": null, + "isCustom": false, + "liquid": false, + "tokens": { + "bgRgb": "10 14 28", + "bgCard": "#141c30", + "bgCardHover": "#1a2440", + "bgMuted": "#1c2744", + "bgElevated": "#283a5c", + "fg": "#e4e8f0", + "fgMuted": "#8892a8", + "fgSubtle": "#5a6580", + "border": "#1c2744", + "danger": "#ef4444", + "afkText": "#dce2f0", + "accent": "#2563eb", + "accentFg": "#ffffff", + "success": "#22c55e", + "warning": "#eab308", + "radiusSm": "4px", + "radiusMd": "8px", + "radiusLg": "12px", + "elevationLow": "0 2px 8px rgb(0 0 0 / 0.18)", + "elevationMedium": "0 8px 24px rgb(0 0 0 / 0.32)", + "elevationHigh": "0 18px 48px rgb(0 0 0 / 0.45)", + "density": "cozy", + "fontUi": "Inter", + "bgImage": "none", + "cardBgImage": "none", + "borderWidth": "1px", + "borderStyle": "solid", + "avatarShape": "rounded", + "focusRing": "solid", + "overlayBlur": "4px", + "fontDisplay": "Inter", + "lineHeight": "1.5", + "letterSpacing": "0", + "labelCase": "none", + "fontSmoothing": "auto", + "motionScale": "1" + }, + "cases": [ + { + "opacity": 0, + "backdropAvailable": true, + "dataTheme": "midnight", + "dataGlass": "0", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0, + "cardOpacity": 0.66, + "hoverOpacity": 0.72, + "mutedOpacity": 0.72, + "elevatedOpacity": 0.78, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "10 14 28", + "--bg-opacity": "0", + "--bg-solid": "rgb(10 14 28)", + "--bg-input": "rgb(10 14 28)", + "--bg-card": "rgb(20 28 48 / 0.66)", + "--bg-card-hover": "rgb(26 36 64 / 0.72)", + "--bg-muted": "rgb(28 39 68 / 0.72)", + "--bg-elevated": "rgb(40 58 92 / 0.78)", + "--bg-overlay": "rgb(20 28 48 / 0.86)", + "--fg": "#e4e8f0", + "--fg-muted": "#8892a8", + "--fg-subtle": "#5a6580", + "--border": "#1c2744", + "--danger": "#ef4444", + "--afk-text": "#dce2f0", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 0, + "backdropAvailable": false, + "dataTheme": "midnight", + "dataGlass": "0", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0, + "cardOpacity": 0.66, + "hoverOpacity": 0.72, + "mutedOpacity": 0.72, + "elevatedOpacity": 0.78, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "10 14 28", + "--bg-opacity": "0", + "--bg-solid": "rgb(10 14 28)", + "--bg-input": "rgb(10 14 28)", + "--bg-card": "rgb(20 28 48 / 0.66)", + "--bg-card-hover": "rgb(26 36 64 / 0.72)", + "--bg-muted": "rgb(28 39 68 / 0.72)", + "--bg-elevated": "rgb(40 58 92 / 0.78)", + "--bg-overlay": "rgb(20 28 48 / 0.86)", + "--fg": "#e4e8f0", + "--fg-muted": "#8892a8", + "--fg-subtle": "#5a6580", + "--border": "#1c2744", + "--danger": "#ef4444", + "--afk-text": "#dce2f0", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 50, + "backdropAvailable": true, + "dataTheme": "midnight", + "dataGlass": "0", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.5, + "cardOpacity": 0.66, + "hoverOpacity": 0.72, + "mutedOpacity": 0.72, + "elevatedOpacity": 0.78, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "10 14 28", + "--bg-opacity": "0.5", + "--bg-solid": "rgb(10 14 28)", + "--bg-input": "rgb(10 14 28)", + "--bg-card": "rgb(20 28 48 / 0.66)", + "--bg-card-hover": "rgb(26 36 64 / 0.72)", + "--bg-muted": "rgb(28 39 68 / 0.72)", + "--bg-elevated": "rgb(40 58 92 / 0.78)", + "--bg-overlay": "rgb(20 28 48 / 0.86)", + "--fg": "#e4e8f0", + "--fg-muted": "#8892a8", + "--fg-subtle": "#5a6580", + "--border": "#1c2744", + "--danger": "#ef4444", + "--afk-text": "#dce2f0", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 50, + "backdropAvailable": false, + "dataTheme": "midnight", + "dataGlass": "0", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.5, + "cardOpacity": 0.66, + "hoverOpacity": 0.72, + "mutedOpacity": 0.72, + "elevatedOpacity": 0.78, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "10 14 28", + "--bg-opacity": "0.5", + "--bg-solid": "rgb(10 14 28)", + "--bg-input": "rgb(10 14 28)", + "--bg-card": "rgb(20 28 48 / 0.66)", + "--bg-card-hover": "rgb(26 36 64 / 0.72)", + "--bg-muted": "rgb(28 39 68 / 0.72)", + "--bg-elevated": "rgb(40 58 92 / 0.78)", + "--bg-overlay": "rgb(20 28 48 / 0.86)", + "--fg": "#e4e8f0", + "--fg-muted": "#8892a8", + "--fg-subtle": "#5a6580", + "--border": "#1c2744", + "--danger": "#ef4444", + "--afk-text": "#dce2f0", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 85, + "backdropAvailable": true, + "dataTheme": "midnight", + "dataGlass": "0", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.85, + "cardOpacity": 0.99, + "hoverOpacity": 1, + "mutedOpacity": 1, + "elevatedOpacity": 1, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "10 14 28", + "--bg-opacity": "0.85", + "--bg-solid": "rgb(10 14 28)", + "--bg-input": "rgb(10 14 28)", + "--bg-card": "rgb(20 28 48 / 0.99)", + "--bg-card-hover": "rgb(26 36 64 / 1)", + "--bg-muted": "rgb(28 39 68 / 1)", + "--bg-elevated": "rgb(40 58 92 / 1)", + "--bg-overlay": "rgb(20 28 48 / 1)", + "--fg": "#e4e8f0", + "--fg-muted": "#8892a8", + "--fg-subtle": "#5a6580", + "--border": "#1c2744", + "--danger": "#ef4444", + "--afk-text": "#dce2f0", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 85, + "backdropAvailable": false, + "dataTheme": "midnight", + "dataGlass": "0", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.85, + "cardOpacity": 0.99, + "hoverOpacity": 1, + "mutedOpacity": 1, + "elevatedOpacity": 1, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "10 14 28", + "--bg-opacity": "0.85", + "--bg-solid": "rgb(10 14 28)", + "--bg-input": "rgb(10 14 28)", + "--bg-card": "rgb(20 28 48 / 0.99)", + "--bg-card-hover": "rgb(26 36 64 / 1)", + "--bg-muted": "rgb(28 39 68 / 1)", + "--bg-elevated": "rgb(40 58 92 / 1)", + "--bg-overlay": "rgb(20 28 48 / 1)", + "--fg": "#e4e8f0", + "--fg-muted": "#8892a8", + "--fg-subtle": "#5a6580", + "--border": "#1c2744", + "--danger": "#ef4444", + "--afk-text": "#dce2f0", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 100, + "backdropAvailable": true, + "dataTheme": "midnight", + "dataGlass": "0", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 1, + "cardOpacity": 1, + "hoverOpacity": 1, + "mutedOpacity": 1, + "elevatedOpacity": 1, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "10 14 28", + "--bg-opacity": "1", + "--bg-solid": "rgb(10 14 28)", + "--bg-input": "rgb(10 14 28)", + "--bg-card": "rgb(20 28 48 / 1)", + "--bg-card-hover": "rgb(26 36 64 / 1)", + "--bg-muted": "rgb(28 39 68 / 1)", + "--bg-elevated": "rgb(40 58 92 / 1)", + "--bg-overlay": "rgb(20 28 48 / 1)", + "--fg": "#e4e8f0", + "--fg-muted": "#8892a8", + "--fg-subtle": "#5a6580", + "--border": "#1c2744", + "--danger": "#ef4444", + "--afk-text": "#dce2f0", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 100, + "backdropAvailable": false, + "dataTheme": "midnight", + "dataGlass": "0", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 1, + "cardOpacity": 1, + "hoverOpacity": 1, + "mutedOpacity": 1, + "elevatedOpacity": 1, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "10 14 28", + "--bg-opacity": "1", + "--bg-solid": "rgb(10 14 28)", + "--bg-input": "rgb(10 14 28)", + "--bg-card": "rgb(20 28 48 / 1)", + "--bg-card-hover": "rgb(26 36 64 / 1)", + "--bg-muted": "rgb(28 39 68 / 1)", + "--bg-elevated": "rgb(40 58 92 / 1)", + "--bg-overlay": "rgb(20 28 48 / 1)", + "--fg": "#e4e8f0", + "--fg-muted": "#8892a8", + "--fg-subtle": "#5a6580", + "--border": "#1c2744", + "--danger": "#ef4444", + "--afk-text": "#dce2f0", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + } + ] +} diff --git a/crates/accshift-theme/tests/data/custom-bevelled.json b/crates/accshift-theme/tests/data/custom-bevelled.json new file mode 100644 index 0000000..53a6743 --- /dev/null +++ b/crates/accshift-theme/tests/data/custom-bevelled.json @@ -0,0 +1,544 @@ +{ + "id": "bevelled", + "payload": { + "schemaVersion": 3, + "id": "bevelled", + "name": "Bevelled", + "colorScheme": "light", + "tokens": { + "avatarShape": "circle", + "fontSmoothing": "none", + "borderStyle": "ridge", + "borderWidth": "2px", + "motionScale": "0", + "fontDisplay": "Tahoma", + "density": "compact", + "letterSpacing": "-0.02em", + "cardBgImage": "linear-gradient(180deg, #ffffff, #c0c0c0)", + "bgImage": "radial-gradient(circle, #eeeeee 0%, #cccccc 100%)", + "bgCard": "#abc" + } + }, + "isCustom": true, + "liquid": false, + "tokens": { + "bgRgb": "241 241 243", + "bgCard": "#abc", + "bgCardHover": "#cfcfd7", + "bgMuted": "#c4c4ce", + "bgElevated": "#aeaebc", + "fg": "#0b0b0f", + "fgMuted": "#2b2b36", + "fgSubtle": "#4e4e5d", + "border": "#b8b8c5", + "danger": "#dc2626", + "afkText": "#000000", + "accent": "#2563eb", + "accentFg": "#ffffff", + "success": "#15803d", + "warning": "#a16207", + "radiusSm": "4px", + "radiusMd": "8px", + "radiusLg": "12px", + "elevationLow": "0 2px 8px rgb(0 0 0 / 0.10)", + "elevationMedium": "0 8px 24px rgb(0 0 0 / 0.16)", + "elevationHigh": "0 18px 48px rgb(0 0 0 / 0.22)", + "density": "compact", + "fontUi": "Inter", + "bgImage": "radial-gradient(circle, #eeeeee 0%, #cccccc 100%)", + "cardBgImage": "linear-gradient(180deg, #ffffff, #c0c0c0)", + "borderWidth": "2px", + "borderStyle": "ridge", + "avatarShape": "circle", + "focusRing": "solid", + "overlayBlur": "4px", + "fontDisplay": "Tahoma", + "lineHeight": "1.5", + "letterSpacing": "-0.02em", + "labelCase": "none", + "fontSmoothing": "none", + "motionScale": "0" + }, + "cases": [ + { + "opacity": 0, + "backdropAvailable": true, + "dataTheme": "bevelled", + "dataGlass": "0", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 0, + "cardOpacity": 0.66, + "hoverOpacity": 0.72, + "mutedOpacity": 0.72, + "elevatedOpacity": 0.78, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "241 241 243", + "--bg-opacity": "0", + "--bg-solid": "rgb(241 241 243)", + "--bg-input": "rgb(241 241 243)", + "--bg-card": "rgb(170 187 204 / 0.66)", + "--bg-card-hover": "rgb(207 207 215 / 0.72)", + "--bg-muted": "rgb(196 196 206 / 0.72)", + "--bg-elevated": "rgb(174 174 188 / 0.78)", + "--bg-overlay": "rgb(170 187 204 / 0.86)", + "--fg": "#0b0b0f", + "--fg-muted": "#2b2b36", + "--fg-subtle": "#4e4e5d", + "--border": "#b8b8c5", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "0.88", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Tahoma, var(--font-ui)", + "--bg-image": "radial-gradient(circle, #eeeeee 0%, #cccccc 100%)", + "--card-bg-image": "linear-gradient(180deg, #ffffff, #c0c0c0)", + "--border-width": "2px", + "--border-style": "ridge", + "--avatar-radius": "50%", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "-0.02em", + "--label-transform": "none", + "--font-smoothing": "none", + "--font-smoothing-mac": "auto", + "--motion-scale": "0" + } + }, + { + "opacity": 0, + "backdropAvailable": false, + "dataTheme": "bevelled", + "dataGlass": "0", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 0, + "cardOpacity": 0.66, + "hoverOpacity": 0.72, + "mutedOpacity": 0.72, + "elevatedOpacity": 0.78, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "241 241 243", + "--bg-opacity": "0", + "--bg-solid": "rgb(241 241 243)", + "--bg-input": "rgb(241 241 243)", + "--bg-card": "rgb(170 187 204 / 0.66)", + "--bg-card-hover": "rgb(207 207 215 / 0.72)", + "--bg-muted": "rgb(196 196 206 / 0.72)", + "--bg-elevated": "rgb(174 174 188 / 0.78)", + "--bg-overlay": "rgb(170 187 204 / 0.86)", + "--fg": "#0b0b0f", + "--fg-muted": "#2b2b36", + "--fg-subtle": "#4e4e5d", + "--border": "#b8b8c5", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "0.88", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Tahoma, var(--font-ui)", + "--bg-image": "radial-gradient(circle, #eeeeee 0%, #cccccc 100%)", + "--card-bg-image": "linear-gradient(180deg, #ffffff, #c0c0c0)", + "--border-width": "2px", + "--border-style": "ridge", + "--avatar-radius": "50%", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "-0.02em", + "--label-transform": "none", + "--font-smoothing": "none", + "--font-smoothing-mac": "auto", + "--motion-scale": "0" + } + }, + { + "opacity": 50, + "backdropAvailable": true, + "dataTheme": "bevelled", + "dataGlass": "0", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 0.5, + "cardOpacity": 0.66, + "hoverOpacity": 0.72, + "mutedOpacity": 0.72, + "elevatedOpacity": 0.78, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "241 241 243", + "--bg-opacity": "0.5", + "--bg-solid": "rgb(241 241 243)", + "--bg-input": "rgb(241 241 243)", + "--bg-card": "rgb(170 187 204 / 0.66)", + "--bg-card-hover": "rgb(207 207 215 / 0.72)", + "--bg-muted": "rgb(196 196 206 / 0.72)", + "--bg-elevated": "rgb(174 174 188 / 0.78)", + "--bg-overlay": "rgb(170 187 204 / 0.86)", + "--fg": "#0b0b0f", + "--fg-muted": "#2b2b36", + "--fg-subtle": "#4e4e5d", + "--border": "#b8b8c5", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "0.88", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Tahoma, var(--font-ui)", + "--bg-image": "radial-gradient(circle, #eeeeee 0%, #cccccc 100%)", + "--card-bg-image": "linear-gradient(180deg, #ffffff, #c0c0c0)", + "--border-width": "2px", + "--border-style": "ridge", + "--avatar-radius": "50%", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "-0.02em", + "--label-transform": "none", + "--font-smoothing": "none", + "--font-smoothing-mac": "auto", + "--motion-scale": "0" + } + }, + { + "opacity": 50, + "backdropAvailable": false, + "dataTheme": "bevelled", + "dataGlass": "0", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 0.5, + "cardOpacity": 0.66, + "hoverOpacity": 0.72, + "mutedOpacity": 0.72, + "elevatedOpacity": 0.78, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "241 241 243", + "--bg-opacity": "0.5", + "--bg-solid": "rgb(241 241 243)", + "--bg-input": "rgb(241 241 243)", + "--bg-card": "rgb(170 187 204 / 0.66)", + "--bg-card-hover": "rgb(207 207 215 / 0.72)", + "--bg-muted": "rgb(196 196 206 / 0.72)", + "--bg-elevated": "rgb(174 174 188 / 0.78)", + "--bg-overlay": "rgb(170 187 204 / 0.86)", + "--fg": "#0b0b0f", + "--fg-muted": "#2b2b36", + "--fg-subtle": "#4e4e5d", + "--border": "#b8b8c5", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "0.88", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Tahoma, var(--font-ui)", + "--bg-image": "radial-gradient(circle, #eeeeee 0%, #cccccc 100%)", + "--card-bg-image": "linear-gradient(180deg, #ffffff, #c0c0c0)", + "--border-width": "2px", + "--border-style": "ridge", + "--avatar-radius": "50%", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "-0.02em", + "--label-transform": "none", + "--font-smoothing": "none", + "--font-smoothing-mac": "auto", + "--motion-scale": "0" + } + }, + { + "opacity": 85, + "backdropAvailable": true, + "dataTheme": "bevelled", + "dataGlass": "0", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 0.85, + "cardOpacity": 0.99, + "hoverOpacity": 1, + "mutedOpacity": 1, + "elevatedOpacity": 1, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "241 241 243", + "--bg-opacity": "0.85", + "--bg-solid": "rgb(241 241 243)", + "--bg-input": "rgb(241 241 243)", + "--bg-card": "rgb(170 187 204 / 0.99)", + "--bg-card-hover": "rgb(207 207 215 / 1)", + "--bg-muted": "rgb(196 196 206 / 1)", + "--bg-elevated": "rgb(174 174 188 / 1)", + "--bg-overlay": "rgb(170 187 204 / 1)", + "--fg": "#0b0b0f", + "--fg-muted": "#2b2b36", + "--fg-subtle": "#4e4e5d", + "--border": "#b8b8c5", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "0.88", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Tahoma, var(--font-ui)", + "--bg-image": "radial-gradient(circle, #eeeeee 0%, #cccccc 100%)", + "--card-bg-image": "linear-gradient(180deg, #ffffff, #c0c0c0)", + "--border-width": "2px", + "--border-style": "ridge", + "--avatar-radius": "50%", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "-0.02em", + "--label-transform": "none", + "--font-smoothing": "none", + "--font-smoothing-mac": "auto", + "--motion-scale": "0" + } + }, + { + "opacity": 85, + "backdropAvailable": false, + "dataTheme": "bevelled", + "dataGlass": "0", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 0.85, + "cardOpacity": 0.99, + "hoverOpacity": 1, + "mutedOpacity": 1, + "elevatedOpacity": 1, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "241 241 243", + "--bg-opacity": "0.85", + "--bg-solid": "rgb(241 241 243)", + "--bg-input": "rgb(241 241 243)", + "--bg-card": "rgb(170 187 204 / 0.99)", + "--bg-card-hover": "rgb(207 207 215 / 1)", + "--bg-muted": "rgb(196 196 206 / 1)", + "--bg-elevated": "rgb(174 174 188 / 1)", + "--bg-overlay": "rgb(170 187 204 / 1)", + "--fg": "#0b0b0f", + "--fg-muted": "#2b2b36", + "--fg-subtle": "#4e4e5d", + "--border": "#b8b8c5", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "0.88", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Tahoma, var(--font-ui)", + "--bg-image": "radial-gradient(circle, #eeeeee 0%, #cccccc 100%)", + "--card-bg-image": "linear-gradient(180deg, #ffffff, #c0c0c0)", + "--border-width": "2px", + "--border-style": "ridge", + "--avatar-radius": "50%", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "-0.02em", + "--label-transform": "none", + "--font-smoothing": "none", + "--font-smoothing-mac": "auto", + "--motion-scale": "0" + } + }, + { + "opacity": 100, + "backdropAvailable": true, + "dataTheme": "bevelled", + "dataGlass": "0", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 1, + "cardOpacity": 1, + "hoverOpacity": 1, + "mutedOpacity": 1, + "elevatedOpacity": 1, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "241 241 243", + "--bg-opacity": "1", + "--bg-solid": "rgb(241 241 243)", + "--bg-input": "rgb(241 241 243)", + "--bg-card": "rgb(170 187 204 / 1)", + "--bg-card-hover": "rgb(207 207 215 / 1)", + "--bg-muted": "rgb(196 196 206 / 1)", + "--bg-elevated": "rgb(174 174 188 / 1)", + "--bg-overlay": "rgb(170 187 204 / 1)", + "--fg": "#0b0b0f", + "--fg-muted": "#2b2b36", + "--fg-subtle": "#4e4e5d", + "--border": "#b8b8c5", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "0.88", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Tahoma, var(--font-ui)", + "--bg-image": "radial-gradient(circle, #eeeeee 0%, #cccccc 100%)", + "--card-bg-image": "linear-gradient(180deg, #ffffff, #c0c0c0)", + "--border-width": "2px", + "--border-style": "ridge", + "--avatar-radius": "50%", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "-0.02em", + "--label-transform": "none", + "--font-smoothing": "none", + "--font-smoothing-mac": "auto", + "--motion-scale": "0" + } + }, + { + "opacity": 100, + "backdropAvailable": false, + "dataTheme": "bevelled", + "dataGlass": "0", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 1, + "cardOpacity": 1, + "hoverOpacity": 1, + "mutedOpacity": 1, + "elevatedOpacity": 1, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "241 241 243", + "--bg-opacity": "1", + "--bg-solid": "rgb(241 241 243)", + "--bg-input": "rgb(241 241 243)", + "--bg-card": "rgb(170 187 204 / 1)", + "--bg-card-hover": "rgb(207 207 215 / 1)", + "--bg-muted": "rgb(196 196 206 / 1)", + "--bg-elevated": "rgb(174 174 188 / 1)", + "--bg-overlay": "rgb(170 187 204 / 1)", + "--fg": "#0b0b0f", + "--fg-muted": "#2b2b36", + "--fg-subtle": "#4e4e5d", + "--border": "#b8b8c5", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "0.88", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Tahoma, var(--font-ui)", + "--bg-image": "radial-gradient(circle, #eeeeee 0%, #cccccc 100%)", + "--card-bg-image": "linear-gradient(180deg, #ffffff, #c0c0c0)", + "--border-width": "2px", + "--border-style": "ridge", + "--avatar-radius": "50%", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "-0.02em", + "--label-transform": "none", + "--font-smoothing": "none", + "--font-smoothing-mac": "auto", + "--motion-scale": "0" + } + } + ] +} diff --git a/crates/accshift-theme/tests/data/custom-glassy.json b/crates/accshift-theme/tests/data/custom-glassy.json new file mode 100644 index 0000000..00fd2b8 --- /dev/null +++ b/crates/accshift-theme/tests/data/custom-glassy.json @@ -0,0 +1,537 @@ +{ + "id": "glassy", + "payload": { + "schemaVersion": 3, + "id": "glassy", + "name": "Glassy", + "colorScheme": "dark", + "glass": true, + "tokens": { + "bgImage": "linear-gradient(180deg, #ffffff, #000000)", + "cardBgImage": "linear-gradient(180deg, #ffffff, #000000)", + "density": "comfortable" + } + }, + "isCustom": true, + "liquid": false, + "tokens": { + "bgRgb": "9 9 11", + "bgCard": "#1c1c1f", + "bgCardHover": "#252528", + "bgMuted": "#27272a", + "bgElevated": "#3f3f46", + "fg": "#fafafa", + "fgMuted": "#a1a1aa", + "fgSubtle": "#71717a", + "border": "#27272a", + "danger": "#dc2626", + "afkText": "#ffffff", + "accent": "#2563eb", + "accentFg": "#ffffff", + "success": "#22c55e", + "warning": "#eab308", + "radiusSm": "4px", + "radiusMd": "8px", + "radiusLg": "12px", + "elevationLow": "0 2px 8px rgb(0 0 0 / 0.18)", + "elevationMedium": "0 8px 24px rgb(0 0 0 / 0.32)", + "elevationHigh": "0 18px 48px rgb(0 0 0 / 0.45)", + "density": "comfortable", + "fontUi": "Inter", + "bgImage": "linear-gradient(180deg, #ffffff, #000000)", + "cardBgImage": "linear-gradient(180deg, #ffffff, #000000)", + "borderWidth": "1px", + "borderStyle": "solid", + "avatarShape": "rounded", + "focusRing": "solid", + "overlayBlur": "4px", + "fontDisplay": "Inter", + "lineHeight": "1.5", + "letterSpacing": "0", + "labelCase": "none", + "fontSmoothing": "auto", + "motionScale": "1" + }, + "cases": [ + { + "opacity": 0, + "backdropAvailable": true, + "dataTheme": "glassy", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.55, + "cardOpacity": 0.65, + "hoverOpacity": 0.73, + "mutedOpacity": 0.6900000000000001, + "elevatedOpacity": 0.75, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "9 9 11", + "--bg-opacity": "0.55", + "--bg-solid": "rgb(9 9 11)", + "--bg-input": "rgb(9 9 11 / 0.55)", + "--bg-card": "rgb(28 28 31 / 0.65)", + "--bg-card-hover": "rgb(37 37 40 / 0.73)", + "--bg-muted": "rgb(39 39 42 / 0.6900000000000001)", + "--bg-elevated": "rgb(63 63 70 / 0.75)", + "--bg-overlay": "rgb(28 28 31 / 0.86)", + "--fg": "#fafafa", + "--fg-muted": "#a1a1aa", + "--fg-subtle": "#71717a", + "--border": "#27272a", + "--danger": "#dc2626", + "--afk-text": "#ffffff", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1.12", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 0, + "backdropAvailable": false, + "dataTheme": "glassy", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.96, + "cardOpacity": 0.72, + "hoverOpacity": 0.7999999999999999, + "mutedOpacity": 0.78, + "elevatedOpacity": 0.85, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "9 9 11", + "--bg-opacity": "0.96", + "--bg-solid": "rgb(9 9 11)", + "--bg-input": "rgb(9 9 11 / 0.55)", + "--bg-card": "rgb(28 28 31 / 0.72)", + "--bg-card-hover": "rgb(37 37 40 / 0.7999999999999999)", + "--bg-muted": "rgb(39 39 42 / 0.78)", + "--bg-elevated": "rgb(63 63 70 / 0.85)", + "--bg-overlay": "rgb(28 28 31 / 1)", + "--fg": "#fafafa", + "--fg-muted": "#a1a1aa", + "--fg-subtle": "#71717a", + "--border": "#27272a", + "--danger": "#dc2626", + "--afk-text": "#ffffff", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1.12", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 50, + "backdropAvailable": true, + "dataTheme": "glassy", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.55, + "cardOpacity": 0.65, + "hoverOpacity": 0.73, + "mutedOpacity": 0.6900000000000001, + "elevatedOpacity": 0.75, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "9 9 11", + "--bg-opacity": "0.55", + "--bg-solid": "rgb(9 9 11)", + "--bg-input": "rgb(9 9 11 / 0.55)", + "--bg-card": "rgb(28 28 31 / 0.65)", + "--bg-card-hover": "rgb(37 37 40 / 0.73)", + "--bg-muted": "rgb(39 39 42 / 0.6900000000000001)", + "--bg-elevated": "rgb(63 63 70 / 0.75)", + "--bg-overlay": "rgb(28 28 31 / 0.86)", + "--fg": "#fafafa", + "--fg-muted": "#a1a1aa", + "--fg-subtle": "#71717a", + "--border": "#27272a", + "--danger": "#dc2626", + "--afk-text": "#ffffff", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1.12", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 50, + "backdropAvailable": false, + "dataTheme": "glassy", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.96, + "cardOpacity": 0.72, + "hoverOpacity": 0.7999999999999999, + "mutedOpacity": 0.78, + "elevatedOpacity": 0.85, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "9 9 11", + "--bg-opacity": "0.96", + "--bg-solid": "rgb(9 9 11)", + "--bg-input": "rgb(9 9 11 / 0.55)", + "--bg-card": "rgb(28 28 31 / 0.72)", + "--bg-card-hover": "rgb(37 37 40 / 0.7999999999999999)", + "--bg-muted": "rgb(39 39 42 / 0.78)", + "--bg-elevated": "rgb(63 63 70 / 0.85)", + "--bg-overlay": "rgb(28 28 31 / 1)", + "--fg": "#fafafa", + "--fg-muted": "#a1a1aa", + "--fg-subtle": "#71717a", + "--border": "#27272a", + "--danger": "#dc2626", + "--afk-text": "#ffffff", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1.12", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 85, + "backdropAvailable": true, + "dataTheme": "glassy", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.55, + "cardOpacity": 0.65, + "hoverOpacity": 0.73, + "mutedOpacity": 0.6900000000000001, + "elevatedOpacity": 0.75, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "9 9 11", + "--bg-opacity": "0.55", + "--bg-solid": "rgb(9 9 11)", + "--bg-input": "rgb(9 9 11 / 0.55)", + "--bg-card": "rgb(28 28 31 / 0.65)", + "--bg-card-hover": "rgb(37 37 40 / 0.73)", + "--bg-muted": "rgb(39 39 42 / 0.6900000000000001)", + "--bg-elevated": "rgb(63 63 70 / 0.75)", + "--bg-overlay": "rgb(28 28 31 / 0.86)", + "--fg": "#fafafa", + "--fg-muted": "#a1a1aa", + "--fg-subtle": "#71717a", + "--border": "#27272a", + "--danger": "#dc2626", + "--afk-text": "#ffffff", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1.12", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 85, + "backdropAvailable": false, + "dataTheme": "glassy", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.96, + "cardOpacity": 0.72, + "hoverOpacity": 0.7999999999999999, + "mutedOpacity": 0.78, + "elevatedOpacity": 0.85, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "9 9 11", + "--bg-opacity": "0.96", + "--bg-solid": "rgb(9 9 11)", + "--bg-input": "rgb(9 9 11 / 0.55)", + "--bg-card": "rgb(28 28 31 / 0.72)", + "--bg-card-hover": "rgb(37 37 40 / 0.7999999999999999)", + "--bg-muted": "rgb(39 39 42 / 0.78)", + "--bg-elevated": "rgb(63 63 70 / 0.85)", + "--bg-overlay": "rgb(28 28 31 / 1)", + "--fg": "#fafafa", + "--fg-muted": "#a1a1aa", + "--fg-subtle": "#71717a", + "--border": "#27272a", + "--danger": "#dc2626", + "--afk-text": "#ffffff", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1.12", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 100, + "backdropAvailable": true, + "dataTheme": "glassy", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.55, + "cardOpacity": 0.65, + "hoverOpacity": 0.73, + "mutedOpacity": 0.6900000000000001, + "elevatedOpacity": 0.75, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "9 9 11", + "--bg-opacity": "0.55", + "--bg-solid": "rgb(9 9 11)", + "--bg-input": "rgb(9 9 11 / 0.55)", + "--bg-card": "rgb(28 28 31 / 0.65)", + "--bg-card-hover": "rgb(37 37 40 / 0.73)", + "--bg-muted": "rgb(39 39 42 / 0.6900000000000001)", + "--bg-elevated": "rgb(63 63 70 / 0.75)", + "--bg-overlay": "rgb(28 28 31 / 0.86)", + "--fg": "#fafafa", + "--fg-muted": "#a1a1aa", + "--fg-subtle": "#71717a", + "--border": "#27272a", + "--danger": "#dc2626", + "--afk-text": "#ffffff", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1.12", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 100, + "backdropAvailable": false, + "dataTheme": "glassy", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.96, + "cardOpacity": 0.72, + "hoverOpacity": 0.7999999999999999, + "mutedOpacity": 0.78, + "elevatedOpacity": 0.85, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "9 9 11", + "--bg-opacity": "0.96", + "--bg-solid": "rgb(9 9 11)", + "--bg-input": "rgb(9 9 11 / 0.55)", + "--bg-card": "rgb(28 28 31 / 0.72)", + "--bg-card-hover": "rgb(37 37 40 / 0.7999999999999999)", + "--bg-muted": "rgb(39 39 42 / 0.78)", + "--bg-elevated": "rgb(63 63 70 / 0.85)", + "--bg-overlay": "rgb(28 28 31 / 1)", + "--fg": "#fafafa", + "--fg-muted": "#a1a1aa", + "--fg-subtle": "#71717a", + "--border": "#27272a", + "--danger": "#dc2626", + "--afk-text": "#ffffff", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1.12", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + } + ] +} diff --git a/crates/accshift-theme/tests/data/custom-legacy-v1.json b/crates/accshift-theme/tests/data/custom-legacy-v1.json new file mode 100644 index 0000000..521bc7a --- /dev/null +++ b/crates/accshift-theme/tests/data/custom-legacy-v1.json @@ -0,0 +1,535 @@ +{ + "id": "legacy-v1", + "payload": { + "id": "legacy-v1", + "name": "Legacy", + "colorScheme": "light", + "tokens": { + "bgCard": "#ffffff", + "fg": "#000000", + "density": "roomy" + } + }, + "isCustom": true, + "liquid": false, + "tokens": { + "bgRgb": "241 241 243", + "bgCard": "#ffffff", + "bgCardHover": "#cfcfd7", + "bgMuted": "#c4c4ce", + "bgElevated": "#aeaebc", + "fg": "#000000", + "fgMuted": "#2b2b36", + "fgSubtle": "#4e4e5d", + "border": "#b8b8c5", + "danger": "#dc2626", + "afkText": "#000000", + "accent": "#2563eb", + "accentFg": "#ffffff", + "success": "#15803d", + "warning": "#a16207", + "radiusSm": "4px", + "radiusMd": "8px", + "radiusLg": "12px", + "elevationLow": "0 2px 8px rgb(0 0 0 / 0.10)", + "elevationMedium": "0 8px 24px rgb(0 0 0 / 0.16)", + "elevationHigh": "0 18px 48px rgb(0 0 0 / 0.22)", + "density": "cozy", + "fontUi": "Inter", + "bgImage": "none", + "cardBgImage": "none", + "borderWidth": "1px", + "borderStyle": "solid", + "avatarShape": "rounded", + "focusRing": "solid", + "overlayBlur": "4px", + "fontDisplay": "Inter", + "lineHeight": "1.5", + "letterSpacing": "0", + "labelCase": "none", + "fontSmoothing": "auto", + "motionScale": "1" + }, + "cases": [ + { + "opacity": 0, + "backdropAvailable": true, + "dataTheme": "legacy-v1", + "dataGlass": "0", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 0, + "cardOpacity": 0.66, + "hoverOpacity": 0.72, + "mutedOpacity": 0.72, + "elevatedOpacity": 0.78, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "241 241 243", + "--bg-opacity": "0", + "--bg-solid": "rgb(241 241 243)", + "--bg-input": "rgb(241 241 243)", + "--bg-card": "rgb(255 255 255 / 0.66)", + "--bg-card-hover": "rgb(207 207 215 / 0.72)", + "--bg-muted": "rgb(196 196 206 / 0.72)", + "--bg-elevated": "rgb(174 174 188 / 0.78)", + "--bg-overlay": "rgb(255 255 255 / 0.86)", + "--fg": "#000000", + "--fg-muted": "#2b2b36", + "--fg-subtle": "#4e4e5d", + "--border": "#b8b8c5", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 0, + "backdropAvailable": false, + "dataTheme": "legacy-v1", + "dataGlass": "0", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 0, + "cardOpacity": 0.66, + "hoverOpacity": 0.72, + "mutedOpacity": 0.72, + "elevatedOpacity": 0.78, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "241 241 243", + "--bg-opacity": "0", + "--bg-solid": "rgb(241 241 243)", + "--bg-input": "rgb(241 241 243)", + "--bg-card": "rgb(255 255 255 / 0.66)", + "--bg-card-hover": "rgb(207 207 215 / 0.72)", + "--bg-muted": "rgb(196 196 206 / 0.72)", + "--bg-elevated": "rgb(174 174 188 / 0.78)", + "--bg-overlay": "rgb(255 255 255 / 0.86)", + "--fg": "#000000", + "--fg-muted": "#2b2b36", + "--fg-subtle": "#4e4e5d", + "--border": "#b8b8c5", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 50, + "backdropAvailable": true, + "dataTheme": "legacy-v1", + "dataGlass": "0", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 0.5, + "cardOpacity": 0.66, + "hoverOpacity": 0.72, + "mutedOpacity": 0.72, + "elevatedOpacity": 0.78, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "241 241 243", + "--bg-opacity": "0.5", + "--bg-solid": "rgb(241 241 243)", + "--bg-input": "rgb(241 241 243)", + "--bg-card": "rgb(255 255 255 / 0.66)", + "--bg-card-hover": "rgb(207 207 215 / 0.72)", + "--bg-muted": "rgb(196 196 206 / 0.72)", + "--bg-elevated": "rgb(174 174 188 / 0.78)", + "--bg-overlay": "rgb(255 255 255 / 0.86)", + "--fg": "#000000", + "--fg-muted": "#2b2b36", + "--fg-subtle": "#4e4e5d", + "--border": "#b8b8c5", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 50, + "backdropAvailable": false, + "dataTheme": "legacy-v1", + "dataGlass": "0", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 0.5, + "cardOpacity": 0.66, + "hoverOpacity": 0.72, + "mutedOpacity": 0.72, + "elevatedOpacity": 0.78, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "241 241 243", + "--bg-opacity": "0.5", + "--bg-solid": "rgb(241 241 243)", + "--bg-input": "rgb(241 241 243)", + "--bg-card": "rgb(255 255 255 / 0.66)", + "--bg-card-hover": "rgb(207 207 215 / 0.72)", + "--bg-muted": "rgb(196 196 206 / 0.72)", + "--bg-elevated": "rgb(174 174 188 / 0.78)", + "--bg-overlay": "rgb(255 255 255 / 0.86)", + "--fg": "#000000", + "--fg-muted": "#2b2b36", + "--fg-subtle": "#4e4e5d", + "--border": "#b8b8c5", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 85, + "backdropAvailable": true, + "dataTheme": "legacy-v1", + "dataGlass": "0", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 0.85, + "cardOpacity": 0.99, + "hoverOpacity": 1, + "mutedOpacity": 1, + "elevatedOpacity": 1, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "241 241 243", + "--bg-opacity": "0.85", + "--bg-solid": "rgb(241 241 243)", + "--bg-input": "rgb(241 241 243)", + "--bg-card": "rgb(255 255 255 / 0.99)", + "--bg-card-hover": "rgb(207 207 215 / 1)", + "--bg-muted": "rgb(196 196 206 / 1)", + "--bg-elevated": "rgb(174 174 188 / 1)", + "--bg-overlay": "rgb(255 255 255 / 1)", + "--fg": "#000000", + "--fg-muted": "#2b2b36", + "--fg-subtle": "#4e4e5d", + "--border": "#b8b8c5", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 85, + "backdropAvailable": false, + "dataTheme": "legacy-v1", + "dataGlass": "0", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 0.85, + "cardOpacity": 0.99, + "hoverOpacity": 1, + "mutedOpacity": 1, + "elevatedOpacity": 1, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "241 241 243", + "--bg-opacity": "0.85", + "--bg-solid": "rgb(241 241 243)", + "--bg-input": "rgb(241 241 243)", + "--bg-card": "rgb(255 255 255 / 0.99)", + "--bg-card-hover": "rgb(207 207 215 / 1)", + "--bg-muted": "rgb(196 196 206 / 1)", + "--bg-elevated": "rgb(174 174 188 / 1)", + "--bg-overlay": "rgb(255 255 255 / 1)", + "--fg": "#000000", + "--fg-muted": "#2b2b36", + "--fg-subtle": "#4e4e5d", + "--border": "#b8b8c5", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 100, + "backdropAvailable": true, + "dataTheme": "legacy-v1", + "dataGlass": "0", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 1, + "cardOpacity": 1, + "hoverOpacity": 1, + "mutedOpacity": 1, + "elevatedOpacity": 1, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "241 241 243", + "--bg-opacity": "1", + "--bg-solid": "rgb(241 241 243)", + "--bg-input": "rgb(241 241 243)", + "--bg-card": "rgb(255 255 255 / 1)", + "--bg-card-hover": "rgb(207 207 215 / 1)", + "--bg-muted": "rgb(196 196 206 / 1)", + "--bg-elevated": "rgb(174 174 188 / 1)", + "--bg-overlay": "rgb(255 255 255 / 1)", + "--fg": "#000000", + "--fg-muted": "#2b2b36", + "--fg-subtle": "#4e4e5d", + "--border": "#b8b8c5", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 100, + "backdropAvailable": false, + "dataTheme": "legacy-v1", + "dataGlass": "0", + "colorScheme": "light", + "css": null, + "surfaces": { + "windowOpacity": 1, + "cardOpacity": 1, + "hoverOpacity": 1, + "mutedOpacity": 1, + "elevatedOpacity": 1, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "241 241 243", + "--bg-opacity": "1", + "--bg-solid": "rgb(241 241 243)", + "--bg-input": "rgb(241 241 243)", + "--bg-card": "rgb(255 255 255 / 1)", + "--bg-card-hover": "rgb(207 207 215 / 1)", + "--bg-muted": "rgb(196 196 206 / 1)", + "--bg-elevated": "rgb(174 174 188 / 1)", + "--bg-overlay": "rgb(255 255 255 / 1)", + "--fg": "#000000", + "--fg-muted": "#2b2b36", + "--fg-subtle": "#4e4e5d", + "--border": "#b8b8c5", + "--danger": "#dc2626", + "--afk-text": "#000000", + "--accent": "#2563eb", + "--accent-fg": "#ffffff", + "--success": "#15803d", + "--warning": "#a16207", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.10)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.16)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.22)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + } + ] +} diff --git a/crates/accshift-theme/tests/data/custom-my-liquid.json b/crates/accshift-theme/tests/data/custom-my-liquid.json new file mode 100644 index 0000000..cfef43c --- /dev/null +++ b/crates/accshift-theme/tests/data/custom-my-liquid.json @@ -0,0 +1,536 @@ +{ + "id": "my-liquid", + "payload": { + "schemaVersion": 3, + "id": "my-liquid", + "name": "My Liquid", + "colorScheme": "dark", + "extends": "liquid-glass", + "glass": true, + "tokens": { + "accent": "#ff8800" + } + }, + "isCustom": true, + "liquid": true, + "tokens": { + "bgRgb": "22 24 30", + "bgCard": "#ffffff", + "bgCardHover": "#ffffff", + "bgMuted": "#ffffff", + "bgElevated": "#ffffff", + "fg": "#ffffff", + "fgMuted": "#dbdfe7", + "fgSubtle": "#b0b6c2", + "border": "#a9b1c0", + "danger": "#ef4444", + "afkText": "#f4f6f9", + "accent": "#ff8800", + "accentFg": "#ffffff", + "success": "#22c55e", + "warning": "#eab308", + "radiusSm": "4px", + "radiusMd": "8px", + "radiusLg": "12px", + "elevationLow": "0 2px 8px rgb(0 0 0 / 0.18)", + "elevationMedium": "0 8px 24px rgb(0 0 0 / 0.32)", + "elevationHigh": "0 18px 48px rgb(0 0 0 / 0.45)", + "density": "cozy", + "fontUi": "Inter", + "bgImage": "none", + "cardBgImage": "none", + "borderWidth": "1px", + "borderStyle": "solid", + "avatarShape": "rounded", + "focusRing": "solid", + "overlayBlur": "4px", + "fontDisplay": "Inter", + "lineHeight": "1.5", + "letterSpacing": "0", + "labelCase": "none", + "fontSmoothing": "auto", + "motionScale": "1" + }, + "cases": [ + { + "opacity": 0, + "backdropAvailable": true, + "dataTheme": "my-liquid", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.18, + "cardOpacity": 0.13, + "hoverOpacity": 0.21, + "mutedOpacity": 0.16, + "elevatedOpacity": 0.34, + "overlayOpacity": 0.86, + "isLiquid": true + }, + "vars": { + "--bg-rgb": "22 24 30", + "--bg-opacity": "0.18", + "--bg-solid": "rgb(22 24 30)", + "--bg-input": "rgb(22 24 30 / 0.55)", + "--bg-card": "rgb(255 255 255 / 0.13)", + "--bg-card-hover": "rgb(255 255 255 / 0.21)", + "--bg-muted": "rgb(255 255 255 / 0.16)", + "--bg-elevated": "rgb(255 255 255 / 0.34)", + "--bg-overlay": "rgb(22 24 30 / 0.94)", + "--fg": "#ffffff", + "--fg-muted": "#dbdfe7", + "--fg-subtle": "#b0b6c2", + "--border": "#a9b1c0", + "--danger": "#ef4444", + "--afk-text": "#f4f6f9", + "--accent": "#ff8800", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 0, + "backdropAvailable": false, + "dataTheme": "my-liquid", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.96, + "cardOpacity": 0.72, + "hoverOpacity": 0.7999999999999999, + "mutedOpacity": 0.78, + "elevatedOpacity": 0.85, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "22 24 30", + "--bg-opacity": "0.96", + "--bg-solid": "rgb(22 24 30)", + "--bg-input": "rgb(22 24 30 / 0.55)", + "--bg-card": "rgb(255 255 255 / 0.72)", + "--bg-card-hover": "rgb(255 255 255 / 0.7999999999999999)", + "--bg-muted": "rgb(255 255 255 / 0.78)", + "--bg-elevated": "rgb(255 255 255 / 0.85)", + "--bg-overlay": "rgb(255 255 255 / 1)", + "--fg": "#ffffff", + "--fg-muted": "#dbdfe7", + "--fg-subtle": "#b0b6c2", + "--border": "#a9b1c0", + "--danger": "#ef4444", + "--afk-text": "#f4f6f9", + "--accent": "#ff8800", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 50, + "backdropAvailable": true, + "dataTheme": "my-liquid", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.18, + "cardOpacity": 0.13, + "hoverOpacity": 0.21, + "mutedOpacity": 0.16, + "elevatedOpacity": 0.34, + "overlayOpacity": 0.86, + "isLiquid": true + }, + "vars": { + "--bg-rgb": "22 24 30", + "--bg-opacity": "0.18", + "--bg-solid": "rgb(22 24 30)", + "--bg-input": "rgb(22 24 30 / 0.55)", + "--bg-card": "rgb(255 255 255 / 0.13)", + "--bg-card-hover": "rgb(255 255 255 / 0.21)", + "--bg-muted": "rgb(255 255 255 / 0.16)", + "--bg-elevated": "rgb(255 255 255 / 0.34)", + "--bg-overlay": "rgb(22 24 30 / 0.94)", + "--fg": "#ffffff", + "--fg-muted": "#dbdfe7", + "--fg-subtle": "#b0b6c2", + "--border": "#a9b1c0", + "--danger": "#ef4444", + "--afk-text": "#f4f6f9", + "--accent": "#ff8800", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 50, + "backdropAvailable": false, + "dataTheme": "my-liquid", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.96, + "cardOpacity": 0.72, + "hoverOpacity": 0.7999999999999999, + "mutedOpacity": 0.78, + "elevatedOpacity": 0.85, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "22 24 30", + "--bg-opacity": "0.96", + "--bg-solid": "rgb(22 24 30)", + "--bg-input": "rgb(22 24 30 / 0.55)", + "--bg-card": "rgb(255 255 255 / 0.72)", + "--bg-card-hover": "rgb(255 255 255 / 0.7999999999999999)", + "--bg-muted": "rgb(255 255 255 / 0.78)", + "--bg-elevated": "rgb(255 255 255 / 0.85)", + "--bg-overlay": "rgb(255 255 255 / 1)", + "--fg": "#ffffff", + "--fg-muted": "#dbdfe7", + "--fg-subtle": "#b0b6c2", + "--border": "#a9b1c0", + "--danger": "#ef4444", + "--afk-text": "#f4f6f9", + "--accent": "#ff8800", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 85, + "backdropAvailable": true, + "dataTheme": "my-liquid", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.18, + "cardOpacity": 0.13, + "hoverOpacity": 0.21, + "mutedOpacity": 0.16, + "elevatedOpacity": 0.34, + "overlayOpacity": 0.86, + "isLiquid": true + }, + "vars": { + "--bg-rgb": "22 24 30", + "--bg-opacity": "0.18", + "--bg-solid": "rgb(22 24 30)", + "--bg-input": "rgb(22 24 30 / 0.55)", + "--bg-card": "rgb(255 255 255 / 0.13)", + "--bg-card-hover": "rgb(255 255 255 / 0.21)", + "--bg-muted": "rgb(255 255 255 / 0.16)", + "--bg-elevated": "rgb(255 255 255 / 0.34)", + "--bg-overlay": "rgb(22 24 30 / 0.94)", + "--fg": "#ffffff", + "--fg-muted": "#dbdfe7", + "--fg-subtle": "#b0b6c2", + "--border": "#a9b1c0", + "--danger": "#ef4444", + "--afk-text": "#f4f6f9", + "--accent": "#ff8800", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 85, + "backdropAvailable": false, + "dataTheme": "my-liquid", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.96, + "cardOpacity": 0.72, + "hoverOpacity": 0.7999999999999999, + "mutedOpacity": 0.78, + "elevatedOpacity": 0.85, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "22 24 30", + "--bg-opacity": "0.96", + "--bg-solid": "rgb(22 24 30)", + "--bg-input": "rgb(22 24 30 / 0.55)", + "--bg-card": "rgb(255 255 255 / 0.72)", + "--bg-card-hover": "rgb(255 255 255 / 0.7999999999999999)", + "--bg-muted": "rgb(255 255 255 / 0.78)", + "--bg-elevated": "rgb(255 255 255 / 0.85)", + "--bg-overlay": "rgb(255 255 255 / 1)", + "--fg": "#ffffff", + "--fg-muted": "#dbdfe7", + "--fg-subtle": "#b0b6c2", + "--border": "#a9b1c0", + "--danger": "#ef4444", + "--afk-text": "#f4f6f9", + "--accent": "#ff8800", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 100, + "backdropAvailable": true, + "dataTheme": "my-liquid", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.18, + "cardOpacity": 0.13, + "hoverOpacity": 0.21, + "mutedOpacity": 0.16, + "elevatedOpacity": 0.34, + "overlayOpacity": 0.86, + "isLiquid": true + }, + "vars": { + "--bg-rgb": "22 24 30", + "--bg-opacity": "0.18", + "--bg-solid": "rgb(22 24 30)", + "--bg-input": "rgb(22 24 30 / 0.55)", + "--bg-card": "rgb(255 255 255 / 0.13)", + "--bg-card-hover": "rgb(255 255 255 / 0.21)", + "--bg-muted": "rgb(255 255 255 / 0.16)", + "--bg-elevated": "rgb(255 255 255 / 0.34)", + "--bg-overlay": "rgb(22 24 30 / 0.94)", + "--fg": "#ffffff", + "--fg-muted": "#dbdfe7", + "--fg-subtle": "#b0b6c2", + "--border": "#a9b1c0", + "--danger": "#ef4444", + "--afk-text": "#f4f6f9", + "--accent": "#ff8800", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 100, + "backdropAvailable": false, + "dataTheme": "my-liquid", + "dataGlass": "1", + "colorScheme": "dark", + "css": null, + "surfaces": { + "windowOpacity": 0.96, + "cardOpacity": 0.72, + "hoverOpacity": 0.7999999999999999, + "mutedOpacity": 0.78, + "elevatedOpacity": 0.85, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "22 24 30", + "--bg-opacity": "0.96", + "--bg-solid": "rgb(22 24 30)", + "--bg-input": "rgb(22 24 30 / 0.55)", + "--bg-card": "rgb(255 255 255 / 0.72)", + "--bg-card-hover": "rgb(255 255 255 / 0.7999999999999999)", + "--bg-muted": "rgb(255 255 255 / 0.78)", + "--bg-elevated": "rgb(255 255 255 / 0.85)", + "--bg-overlay": "rgb(255 255 255 / 1)", + "--fg": "#ffffff", + "--fg-muted": "#dbdfe7", + "--fg-subtle": "#b0b6c2", + "--border": "#a9b1c0", + "--danger": "#ef4444", + "--afk-text": "#f4f6f9", + "--accent": "#ff8800", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + } + ] +} diff --git a/crates/accshift-theme/tests/data/custom-with-css.json b/crates/accshift-theme/tests/data/custom-with-css.json new file mode 100644 index 0000000..206be92 --- /dev/null +++ b/crates/accshift-theme/tests/data/custom-with-css.json @@ -0,0 +1,537 @@ +{ + "id": "with-css", + "payload": { + "schemaVersion": 2, + "id": "with-css", + "name": "With CSS", + "colorScheme": "dark", + "extends": "midnight", + "tokens": { + "accent": "rgb(200 40 120)", + "border": "rebeccapurple" + }, + "css": ":root { --accent: #00ff00; --accent-text: red !important; --z-menu: 5; }\n.card { color: red; }" + }, + "isCustom": true, + "liquid": false, + "tokens": { + "bgRgb": "10 14 28", + "bgCard": "#141c30", + "bgCardHover": "#1a2440", + "bgMuted": "#1c2744", + "bgElevated": "#283a5c", + "fg": "#e4e8f0", + "fgMuted": "#8892a8", + "fgSubtle": "#5a6580", + "border": "rebeccapurple", + "danger": "#ef4444", + "afkText": "#dce2f0", + "accent": "rgb(200 40 120)", + "accentFg": "#ffffff", + "success": "#22c55e", + "warning": "#eab308", + "radiusSm": "4px", + "radiusMd": "8px", + "radiusLg": "12px", + "elevationLow": "0 2px 8px rgb(0 0 0 / 0.18)", + "elevationMedium": "0 8px 24px rgb(0 0 0 / 0.32)", + "elevationHigh": "0 18px 48px rgb(0 0 0 / 0.45)", + "density": "cozy", + "fontUi": "Inter", + "bgImage": "none", + "cardBgImage": "none", + "borderWidth": "1px", + "borderStyle": "solid", + "avatarShape": "rounded", + "focusRing": "solid", + "overlayBlur": "4px", + "fontDisplay": "Inter", + "lineHeight": "1.5", + "letterSpacing": "0", + "labelCase": "none", + "fontSmoothing": "auto", + "motionScale": "1" + }, + "cases": [ + { + "opacity": 0, + "backdropAvailable": true, + "dataTheme": "with-css", + "dataGlass": "0", + "colorScheme": "dark", + "css": ":root { --accent: #00ff00; --accent-text: red !important; --z-menu: 5; }\n.card { color: red; }", + "surfaces": { + "windowOpacity": 0, + "cardOpacity": 0.66, + "hoverOpacity": 0.72, + "mutedOpacity": 0.72, + "elevatedOpacity": 0.78, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "10 14 28", + "--bg-opacity": "0", + "--bg-solid": "rgb(10 14 28)", + "--bg-input": "rgb(10 14 28)", + "--bg-card": "rgb(20 28 48 / 0.66)", + "--bg-card-hover": "rgb(26 36 64 / 0.72)", + "--bg-muted": "rgb(28 39 68 / 0.72)", + "--bg-elevated": "rgb(40 58 92 / 0.78)", + "--bg-overlay": "rgb(20 28 48 / 0.86)", + "--fg": "#e4e8f0", + "--fg-muted": "#8892a8", + "--fg-subtle": "#5a6580", + "--border": "rebeccapurple", + "--danger": "#ef4444", + "--afk-text": "#dce2f0", + "--accent": "rgb(200 40 120)", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 0, + "backdropAvailable": false, + "dataTheme": "with-css", + "dataGlass": "0", + "colorScheme": "dark", + "css": ":root { --accent: #00ff00; --accent-text: red !important; --z-menu: 5; }\n.card { color: red; }", + "surfaces": { + "windowOpacity": 0, + "cardOpacity": 0.66, + "hoverOpacity": 0.72, + "mutedOpacity": 0.72, + "elevatedOpacity": 0.78, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "10 14 28", + "--bg-opacity": "0", + "--bg-solid": "rgb(10 14 28)", + "--bg-input": "rgb(10 14 28)", + "--bg-card": "rgb(20 28 48 / 0.66)", + "--bg-card-hover": "rgb(26 36 64 / 0.72)", + "--bg-muted": "rgb(28 39 68 / 0.72)", + "--bg-elevated": "rgb(40 58 92 / 0.78)", + "--bg-overlay": "rgb(20 28 48 / 0.86)", + "--fg": "#e4e8f0", + "--fg-muted": "#8892a8", + "--fg-subtle": "#5a6580", + "--border": "rebeccapurple", + "--danger": "#ef4444", + "--afk-text": "#dce2f0", + "--accent": "rgb(200 40 120)", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 50, + "backdropAvailable": true, + "dataTheme": "with-css", + "dataGlass": "0", + "colorScheme": "dark", + "css": ":root { --accent: #00ff00; --accent-text: red !important; --z-menu: 5; }\n.card { color: red; }", + "surfaces": { + "windowOpacity": 0.5, + "cardOpacity": 0.66, + "hoverOpacity": 0.72, + "mutedOpacity": 0.72, + "elevatedOpacity": 0.78, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "10 14 28", + "--bg-opacity": "0.5", + "--bg-solid": "rgb(10 14 28)", + "--bg-input": "rgb(10 14 28)", + "--bg-card": "rgb(20 28 48 / 0.66)", + "--bg-card-hover": "rgb(26 36 64 / 0.72)", + "--bg-muted": "rgb(28 39 68 / 0.72)", + "--bg-elevated": "rgb(40 58 92 / 0.78)", + "--bg-overlay": "rgb(20 28 48 / 0.86)", + "--fg": "#e4e8f0", + "--fg-muted": "#8892a8", + "--fg-subtle": "#5a6580", + "--border": "rebeccapurple", + "--danger": "#ef4444", + "--afk-text": "#dce2f0", + "--accent": "rgb(200 40 120)", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 50, + "backdropAvailable": false, + "dataTheme": "with-css", + "dataGlass": "0", + "colorScheme": "dark", + "css": ":root { --accent: #00ff00; --accent-text: red !important; --z-menu: 5; }\n.card { color: red; }", + "surfaces": { + "windowOpacity": 0.5, + "cardOpacity": 0.66, + "hoverOpacity": 0.72, + "mutedOpacity": 0.72, + "elevatedOpacity": 0.78, + "overlayOpacity": 0.86, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "10 14 28", + "--bg-opacity": "0.5", + "--bg-solid": "rgb(10 14 28)", + "--bg-input": "rgb(10 14 28)", + "--bg-card": "rgb(20 28 48 / 0.66)", + "--bg-card-hover": "rgb(26 36 64 / 0.72)", + "--bg-muted": "rgb(28 39 68 / 0.72)", + "--bg-elevated": "rgb(40 58 92 / 0.78)", + "--bg-overlay": "rgb(20 28 48 / 0.86)", + "--fg": "#e4e8f0", + "--fg-muted": "#8892a8", + "--fg-subtle": "#5a6580", + "--border": "rebeccapurple", + "--danger": "#ef4444", + "--afk-text": "#dce2f0", + "--accent": "rgb(200 40 120)", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 85, + "backdropAvailable": true, + "dataTheme": "with-css", + "dataGlass": "0", + "colorScheme": "dark", + "css": ":root { --accent: #00ff00; --accent-text: red !important; --z-menu: 5; }\n.card { color: red; }", + "surfaces": { + "windowOpacity": 0.85, + "cardOpacity": 0.99, + "hoverOpacity": 1, + "mutedOpacity": 1, + "elevatedOpacity": 1, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "10 14 28", + "--bg-opacity": "0.85", + "--bg-solid": "rgb(10 14 28)", + "--bg-input": "rgb(10 14 28)", + "--bg-card": "rgb(20 28 48 / 0.99)", + "--bg-card-hover": "rgb(26 36 64 / 1)", + "--bg-muted": "rgb(28 39 68 / 1)", + "--bg-elevated": "rgb(40 58 92 / 1)", + "--bg-overlay": "rgb(20 28 48 / 1)", + "--fg": "#e4e8f0", + "--fg-muted": "#8892a8", + "--fg-subtle": "#5a6580", + "--border": "rebeccapurple", + "--danger": "#ef4444", + "--afk-text": "#dce2f0", + "--accent": "rgb(200 40 120)", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 85, + "backdropAvailable": false, + "dataTheme": "with-css", + "dataGlass": "0", + "colorScheme": "dark", + "css": ":root { --accent: #00ff00; --accent-text: red !important; --z-menu: 5; }\n.card { color: red; }", + "surfaces": { + "windowOpacity": 0.85, + "cardOpacity": 0.99, + "hoverOpacity": 1, + "mutedOpacity": 1, + "elevatedOpacity": 1, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "10 14 28", + "--bg-opacity": "0.85", + "--bg-solid": "rgb(10 14 28)", + "--bg-input": "rgb(10 14 28)", + "--bg-card": "rgb(20 28 48 / 0.99)", + "--bg-card-hover": "rgb(26 36 64 / 1)", + "--bg-muted": "rgb(28 39 68 / 1)", + "--bg-elevated": "rgb(40 58 92 / 1)", + "--bg-overlay": "rgb(20 28 48 / 1)", + "--fg": "#e4e8f0", + "--fg-muted": "#8892a8", + "--fg-subtle": "#5a6580", + "--border": "rebeccapurple", + "--danger": "#ef4444", + "--afk-text": "#dce2f0", + "--accent": "rgb(200 40 120)", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 100, + "backdropAvailable": true, + "dataTheme": "with-css", + "dataGlass": "0", + "colorScheme": "dark", + "css": ":root { --accent: #00ff00; --accent-text: red !important; --z-menu: 5; }\n.card { color: red; }", + "surfaces": { + "windowOpacity": 1, + "cardOpacity": 1, + "hoverOpacity": 1, + "mutedOpacity": 1, + "elevatedOpacity": 1, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "10 14 28", + "--bg-opacity": "1", + "--bg-solid": "rgb(10 14 28)", + "--bg-input": "rgb(10 14 28)", + "--bg-card": "rgb(20 28 48 / 1)", + "--bg-card-hover": "rgb(26 36 64 / 1)", + "--bg-muted": "rgb(28 39 68 / 1)", + "--bg-elevated": "rgb(40 58 92 / 1)", + "--bg-overlay": "rgb(20 28 48 / 1)", + "--fg": "#e4e8f0", + "--fg-muted": "#8892a8", + "--fg-subtle": "#5a6580", + "--border": "rebeccapurple", + "--danger": "#ef4444", + "--afk-text": "#dce2f0", + "--accent": "rgb(200 40 120)", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + }, + { + "opacity": 100, + "backdropAvailable": false, + "dataTheme": "with-css", + "dataGlass": "0", + "colorScheme": "dark", + "css": ":root { --accent: #00ff00; --accent-text: red !important; --z-menu: 5; }\n.card { color: red; }", + "surfaces": { + "windowOpacity": 1, + "cardOpacity": 1, + "hoverOpacity": 1, + "mutedOpacity": 1, + "elevatedOpacity": 1, + "overlayOpacity": 1, + "isLiquid": false + }, + "vars": { + "--bg-rgb": "10 14 28", + "--bg-opacity": "1", + "--bg-solid": "rgb(10 14 28)", + "--bg-input": "rgb(10 14 28)", + "--bg-card": "rgb(20 28 48 / 1)", + "--bg-card-hover": "rgb(26 36 64 / 1)", + "--bg-muted": "rgb(28 39 68 / 1)", + "--bg-elevated": "rgb(40 58 92 / 1)", + "--bg-overlay": "rgb(20 28 48 / 1)", + "--fg": "#e4e8f0", + "--fg-muted": "#8892a8", + "--fg-subtle": "#5a6580", + "--border": "rebeccapurple", + "--danger": "#ef4444", + "--afk-text": "#dce2f0", + "--accent": "rgb(200 40 120)", + "--accent-fg": "#ffffff", + "--success": "#22c55e", + "--warning": "#eab308", + "--radius-sm": "4px", + "--radius-md": "8px", + "--radius-lg": "12px", + "--elevation-low": "0 2px 8px rgb(0 0 0 / 0.18)", + "--elevation-medium": "0 8px 24px rgb(0 0 0 / 0.32)", + "--elevation-high": "0 18px 48px rgb(0 0 0 / 0.45)", + "--density-scale": "1", + "--font-ui": "Inter, var(--font-stack-base)", + "--font-display": "Inter, var(--font-ui)", + "--bg-image": "none", + "--card-bg-image": "none", + "--border-width": "1px", + "--border-style": "solid", + "--avatar-radius": "var(--avatar-radius-default, 6px)", + "--focus-ring-style": "solid", + "--overlay-blur": "4px", + "--line-height": "1.5", + "--letter-spacing": "0", + "--label-transform": "none", + "--font-smoothing": "antialiased", + "--font-smoothing-mac": "grayscale", + "--motion-scale": "1" + } + } + ] +} diff --git a/crates/accshift-theme/tests/dump.rs b/crates/accshift-theme/tests/dump.rs new file mode 100644 index 0000000..98c2eb4 --- /dev/null +++ b/crates/accshift-theme/tests/dump.rs @@ -0,0 +1,283 @@ +//! Compares the Rust engine with what the real TypeScript wrote on `` +//! for every case in `tests/data` (see `tools/variables.dump.ts`). + +use std::fs; +use std::path::PathBuf; + +use accshift_theme::{ + theme_application, CustomThemePayload, ResolvedTheme, ThemeRegistry, TokenKey, UnsupportedCss, +}; +use serde_json::Value; + +struct Dump { + file: String, + value: Value, +} + +fn dumps() -> Vec { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/data"); + let mut dumps: Vec = fs::read_dir(&dir) + .expect("tests/data exists; run the dump script") + .map(|entry| entry.expect("readable entry").path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "json")) + .map(|path| Dump { + file: path.file_name().unwrap().to_string_lossy().into_owned(), + value: serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(), + }) + .collect(); + dumps.sort_by(|a, b| a.file.cmp(&b.file)); + dumps +} + +/// Every custom payload of the dump, registered together as the script did. +fn registry(dumps: &[Dump]) -> ThemeRegistry { + let payloads: Vec = dumps + .iter() + .filter(|dump| !dump.value["payload"].is_null()) + .map(|dump| serde_json::from_value(dump.value["payload"].clone()).unwrap()) + .collect(); + let mut registry = ThemeRegistry::new(); + registry.apply_custom_theme_payloads(&payloads); + registry +} + +/// Variables whose value is a colour once resolved. +const COLOR_VARS: &[&str] = &[ + "--bg-solid", + "--bg-input", + "--bg-card", + "--bg-card-hover", + "--bg-muted", + "--bg-elevated", + "--bg-overlay", + "--fg", + "--fg-muted", + "--fg-subtle", + "--border", + "--danger", + "--afk-text", + "--accent", + "--accent-fg", + "--success", + "--warning", + "--bg", + "--accent-text", +]; +const LENGTH_VARS: &[&str] = &[ + "--radius-sm", + "--radius-md", + "--radius-lg", + "--border-width", + "--overlay-blur", + "--letter-spacing", + "--avatar-radius", + "--grid-card-width", + "--grid-card-min-height", + "--grid-card-padding", + "--grid-card-radius", + "--grid-card-avatar-size", +]; +const NUMBER_VARS: &[&str] = &[ + "--bg-opacity", + "--density-scale", + "--line-height", + "--motion-scale", + "--z-menu", +]; +const DURATION_VARS: &[&str] = &[ + "--motion-card-entrance", + "--motion-page-entrance", + "--motion-avatar-fade", + "--motion-avatar-defer", +]; +const SHADOW_VARS: &[&str] = &["--elevation-low", "--elevation-medium", "--elevation-high"]; + +#[test] +fn every_dumped_case_matches_the_typescript() { + let dumps = dumps(); + assert!( + dumps.len() >= 11, + "expected the 6 built-ins and 5 custom themes" + ); + let registry = registry(&dumps); + + let mut cases = 0; + let mut matched = 0; + let mut unsupported: Vec = Vec::new(); + for dump in &dumps { + let id = dump.value["id"].as_str().unwrap(); + let theme = registry.theme(Some(id)); + assert_eq!(theme.id, id, "{}", dump.file); + assert_eq!( + theme.is_custom, + dump.value["isCustom"].as_bool().unwrap(), + "{id}" + ); + assert_eq!( + theme.liquid, + dump.value["liquid"].as_bool().unwrap(), + "{id}" + ); + for (name, expected) in dump.value["tokens"].as_object().unwrap() { + let key = TokenKey::from_name(name).unwrap(); + assert_eq!( + theme.tokens.get(key), + expected.as_str().unwrap(), + "{id} token {name}" + ); + } + + for case in dump.value["cases"].as_array().unwrap() { + let opacity = case["opacity"].as_f64().unwrap(); + let backdrop = case["backdropAvailable"].as_bool().unwrap(); + let label = format!("{id} {opacity} {backdrop}"); + let applied = theme_application(&theme, opacity, backdrop); + cases += 1; + + assert_eq!( + applied.data_theme, + case["dataTheme"].as_str().unwrap(), + "{label}" + ); + assert_eq!( + applied.data_glass, + case["dataGlass"].as_str().unwrap(), + "{label}" + ); + assert_eq!( + applied.color_scheme.as_str(), + case["colorScheme"].as_str().unwrap(), + "{label}" + ); + assert_eq!(applied.css.as_deref(), case["css"].as_str(), "{label}"); + + let surfaces = &case["surfaces"]; + let s = applied.surfaces; + for (field, actual) in [ + ("windowOpacity", s.window_opacity), + ("cardOpacity", s.card_opacity), + ("hoverOpacity", s.hover_opacity), + ("mutedOpacity", s.muted_opacity), + ("elevatedOpacity", s.elevated_opacity), + ("overlayOpacity", s.overlay_opacity), + ] { + assert_eq!(actual, surfaces[field].as_f64().unwrap(), "{label} {field}"); + } + assert_eq!( + s.is_liquid, + surfaces["isLiquid"].as_bool().unwrap(), + "{label}" + ); + + let expected_vars = case["vars"].as_object().unwrap(); + let expected_names: Vec<&str> = expected_vars.keys().map(String::as_str).collect(); + let actual_names: Vec<&str> = applied.vars.iter().map(|(name, _)| *name).collect(); + assert_eq!(actual_names, expected_names, "{label}: names and order"); + for (name, value) in &applied.vars { + assert_eq!( + value, + expected_vars[*name].as_str().unwrap(), + "{label} {name}" + ); + matched += 1; + } + + // Everything the app paints with resolves to a concrete value. + let resolved = ResolvedTheme::new(&applied); + for name in COLOR_VARS { + assert!( + resolved.color(name).is_some(), + "{label} colour {name}: {:?}", + resolved.substitute(name) + ); + } + for name in LENGTH_VARS { + assert!( + resolved.length(name).is_some(), + "{label} length {name}: {:?}", + resolved.substitute(name) + ); + } + for name in NUMBER_VARS { + assert!( + resolved.number(name).is_some(), + "{label} number {name}: {:?}", + resolved.substitute(name) + ); + } + for name in DURATION_VARS { + assert!( + resolved.duration_ms(name).is_some(), + "{label} duration {name}" + ); + } + for name in SHADOW_VARS { + assert!(resolved.shadow(name).is_some(), "{label} shadow {name}"); + } + for item in resolved.unsupported_css() { + if !unsupported.contains(item) { + unsupported.push(item.clone()); + } + } + } + } + println!( + "dump: {matched} variables matched across {cases} cases, {} unsupported constructs: {unsupported:?}", + unsupported.len() + ); + assert_eq!(cases, dumps.len() * 8); +} + +#[test] +fn custom_css_overrides_follow_the_cascade() { + let dumps = dumps(); + let registry = registry(&dumps); + let theme = registry.theme(Some("with-css")); + let resolved = ResolvedTheme::new(&theme_application(&theme, 100.0, true)); + // The inline style beats the theme's own `--accent: #00ff00`. + assert_eq!(resolved.raw("--accent"), Some("rgb(200 40 120)")); + assert_eq!( + resolved.color("--accent").map(|c| c.to_u8()), + Some([200, 40, 120, 255]) + ); + // `!important` in the theme CSS beats app.css and the inline style alike. + assert_eq!( + resolved.color("--accent-text").map(|c| c.to_u8()), + Some([255, 0, 0, 255]) + ); + // A plain override of a variable no theme writes wins over app.css. + assert_eq!(resolved.number("--z-menu"), Some(5.0)); + assert_eq!( + resolved.color("--border").map(|c| c.to_u8()), + Some([102, 51, 153, 255]) + ); + assert_eq!( + resolved.unsupported_css(), + &[UnsupportedCss::Rule(".card".into())] + ); +} + +#[test] +fn resolved_values_follow_the_theme() { + let registry = ThemeRegistry::new(); + let midnight = registry.theme(Some("midnight")); + let resolved = ResolvedTheme::new(&theme_application(&midnight, 50.0, true)); + // rgb(20 28 48 / 0.66) + let card = resolved.color("--bg-card").unwrap(); + assert_eq!(card.to_u8(), [20, 28, 48, 168]); + assert!((card.a - 0.66).abs() < 1e-6); + // rgb(var(--bg-rgb) / var(--bg-opacity)) from app.css, fed by the theme. + let bg = resolved.color("--bg").unwrap(); + assert_eq!(bg.to_u8(), [10, 14, 28, 128]); + + let liquid = registry.theme(Some("liquid-glass")); + let resolved = ResolvedTheme::new(&theme_application(&liquid, 100.0, true)); + assert_eq!( + resolved.color("--bg-overlay").map(|c| c.to_u8()), + Some([22, 24, 30, 240]) + ); + assert_eq!( + resolved.color("--bg-card").map(|c| c.to_u8()), + Some([255, 255, 255, 33]) + ); +} diff --git a/crates/accshift-theme/tests/ported.rs b/crates/accshift-theme/tests/ported.rs new file mode 100644 index 0000000..df4555b --- /dev/null +++ b/crates/accshift-theme/tests/ported.rs @@ -0,0 +1,930 @@ +//! The TypeScript tests of `src/lib/theme/*.test.ts` for the logic this crate +//! ports: schema, surface opacities and themes. Contrast, safety and token +//! tests live next to their modules. + +use std::collections::BTreeMap; + +use accshift_theme::schema::{IssueLevel, ThemeIssueCode}; +use accshift_theme::tokens::is_essential_token; +use accshift_theme::{ + duplicate_theme_document, parse_theme_json, parse_theme_value, resolve_theme_surface_opacities, + resolve_theme_tokens, root_tokens, serialize_theme_document, theme_application, + validate_theme_document, ColorScheme, CustomThemePayload, RootTokens, ThemeDocument, + ThemeParseError, ThemeRegistry, ThemeTokens, TokenKey, MAX_THEME_CSS_LENGTH, + THEME_CONTRACT_VERSION, THEME_TOKEN_SPECS, +}; +use serde_json::json; + +fn labelled_root(scheme: &str) -> ThemeTokens { + ThemeTokens::from_fn(|key| format!("{scheme}-{}", key.name())) +} + +fn roots() -> RootTokens { + RootTokens { + dark: labelled_root("dark"), + light: labelled_root("light"), + } +} + +fn doc(id: &str) -> ThemeDocument { + ThemeDocument { + schema_version: THEME_CONTRACT_VERSION, + id: id.to_owned(), + name: "Test".to_owned(), + author: None, + version: None, + color_scheme: ColorScheme::Dark, + extends: None, + glass: None, + tokens: BTreeMap::new(), + css: None, + } +} + +fn tokens(pairs: &[(TokenKey, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(key, value)| (*key, (*value).to_owned())) + .collect() +} + +fn registry_of<'a>( + documents: &'a [ThemeDocument], +) -> impl Fn(&str) -> Option<&'a ThemeDocument> + 'a { + move |id| documents.iter().find(|document| document.id == id) +} + +mod parse { + use super::*; + + #[test] + fn refuses_what_is_not_a_theme_file() { + assert_eq!( + parse_theme_json("{nope").error, + Some(ThemeParseError::InvalidJson) + ); + assert_eq!( + parse_theme_json("[]").error, + Some(ThemeParseError::InvalidShape) + ); + assert_eq!( + parse_theme_value(&json!({ "name": "No id", "tokens": {} })).error, + Some(ThemeParseError::InvalidId) + ); + assert_eq!( + parse_theme_value(&json!({ "id": "../etc", "name": "Escape", "tokens": {} })).error, + Some(ThemeParseError::InvalidId) + ); + assert_eq!( + parse_theme_value(&json!({ "id": "ok", "tokens": {} })).error, + Some(ThemeParseError::InvalidName) + ); + } + + #[test] + fn refuses_a_file_written_for_a_contract_it_cannot_understand() { + let result = parse_theme_value(&json!({ + "schemaVersion": THEME_CONTRACT_VERSION + 1, + "id": "future", + "name": "Future", + "colorScheme": "dark", + "tokens": { "bgCard": "#000000" }, + })); + assert_eq!(result.error, Some(ThemeParseError::UnsupportedVersion)); + assert!(result.document.is_none()); + assert_eq!( + result.declared_version, + i64::from(THEME_CONTRACT_VERSION + 1) + ); + } + + #[test] + fn migrates_a_version_1_file_by_pointing_it_at_the_built_in_of_its_scheme() { + let result = parse_theme_value(&json!({ + "id": "legacy", + "name": "Legacy", + "colorScheme": "light", + "tokens": { "bgCard": "#ffffff", "fg": "#000000" }, + })); + assert_eq!(result.error, None); + assert_eq!(result.migrated_from, Some(1)); + let document = result.document.unwrap(); + assert_eq!(document.extends.as_deref(), Some("light")); + assert_eq!(document.schema_version, THEME_CONTRACT_VERSION); + assert_eq!(document.tokens[&TokenKey::BgCard], "#ffffff"); + } + + #[test] + fn drops_values_it_cannot_use_instead_of_refusing_the_file() { + let result = parse_theme_value(&json!({ + "schemaVersion": 2, + "id": "sloppy", + "name": "Sloppy", + "colorScheme": "dark", + "tokens": { + "bgCard": "#101010", + "bgMuted": "not-a-colour", + "density": "roomy", + "elevationLow": "url(https://example.com/x.png)", + "somethingElse": "#ffffff", + }, + })); + assert_eq!( + result.document.unwrap().tokens, + tokens(&[(TokenKey::BgCard, "#101010")]) + ); + let mut rejected = result.rejected_tokens; + rejected.sort(); + assert_eq!( + rejected, + ["bgMuted", "density", "elevationLow", "somethingElse"] + ); + } + + #[test] + fn takes_the_version_3_structure_tokens_and_refuses_what_they_must_not_carry() { + let result = parse_theme_value(&json!({ + "schemaVersion": 3, + "id": "structured", + "name": "Structured", + "colorScheme": "light", + "tokens": { + "bgImage": "linear-gradient(180deg, #ffffff 0%, #c0c0c0 100%)", + "borderStyle": "ridge", + "avatarShape": "square", + "letterSpacing": "-0.02em", + "lineHeight": "1.2", + "motionScale": "0", + "cardBgImage": "linear-gradient(#fff, url(https://example.com/x.png))", + "fontSmoothing": "subpixel", + "focusRing": "ridge", + }, + })); + assert_eq!( + result.document.unwrap().tokens, + tokens(&[ + ( + TokenKey::BgImage, + "linear-gradient(180deg, #ffffff 0%, #c0c0c0 100%)" + ), + (TokenKey::BorderStyle, "ridge"), + (TokenKey::AvatarShape, "square"), + (TokenKey::LetterSpacing, "-0.02em"), + (TokenKey::LineHeight, "1.2"), + (TokenKey::MotionScale, "0"), + ]) + ); + let mut rejected = result.rejected_tokens; + rejected.sort(); + assert_eq!(rejected, ["cardBgImage", "focusRing", "fontSmoothing"]); + } + + #[test] + fn keeps_custom_css_but_drops_what_would_let_a_theme_reach_out() { + let clean = parse_theme_value(&json!({ + "schemaVersion": 2, "id": "styled", "name": "Styled", "colorScheme": "dark", + "tokens": {}, "css": ".account-card { letter-spacing: 0.02em; }", + })); + assert!(clean + .document + .unwrap() + .css + .unwrap() + .contains("letter-spacing")); + assert_eq!(clean.rejected_css, None); + + for css in [ + "@import \"https://example.com/x.css\";", + ".card { background: url(https://example.com/x.png); }", + ".card { color: red; }", + ".card { background: \\75\\72\\6c(https://example.com/x.png); }", + ".card { background: u/**/rl(https://example.com/x.png); }", + "@\\69mport url(https://example.com/x.css);", + ".card { background: u\\rl(https://example.com/x.png); }", + ".card { background: \\u\\r\\l(https://example.com/x.png); }", + "@imp\\ort 'https://example.com/x.css';", + "@\\IMP\\ORT 'https://example.com/x.css';", + ".card { background: U\\RL(https://example.com/x.png); }", + ".card { background: u/**/\\rl(https://example.com/x.png); }", + ".card { background: \\75 \\r\\6C(https://example.com/x.png); }", + ".card { background: image-set(\"https://example.com/x.png\" 1x); }", + ".card { background: -webkit-image-set(\"https://example.com/x.png\" 1x); }", + ".card { background: IMAGE-SET(\"https://example.com/x.png\" 1x); }", + ".card { background: image-\\73 et(\"https://example.com/x.png\" 1x); }", + ".card { background: ima\\ge-s/**/et(\"https://example.com/x.png\" 1x); }", + ".card { background: image(\"https://example.com/x.png\"); }", + ".card { background: src(\"https://example.com/x.png\"); }", + ] { + let result = parse_theme_value(&json!({ + "schemaVersion": 2, "id": "hostile", "name": "Hostile", "colorScheme": "dark", + "tokens": { "accent": "#123456" }, "css": css, + })); + let document = result.document.unwrap(); + assert_eq!(document.css, None, "{css}"); + assert_eq!(document.tokens[&TokenKey::Accent], "#123456", "{css}"); + assert!(result.rejected_css.is_some(), "{css}"); + } + + let escaped = parse_theme_value(&json!({ + "schemaVersion": 2, "id": "escaped", "name": "Escaped", "colorScheme": "dark", + "tokens": {}, + "css": ".w-1\\/2::before { content: \"\\201C\"; background-image: linear-gradient(red, blue); }", + })); + assert_eq!(escaped.rejected_css, None); + } + + #[test] + fn round_trips_through_the_exported_file_byte_for_byte() { + let source = ThemeDocument { + id: "nord".into(), + name: "Nord".into(), + author: Some("someone".into()), + version: Some("1.2.0".into()), + extends: Some("dark".into()), + glass: Some(true), + tokens: tokens(&[(TokenKey::Accent, "#88c0d0"), (TokenKey::RadiusMd, "10px")]), + css: Some(".account-card { letter-spacing: 0.02em; }".into()), + ..doc("nord") + }; + let json = serialize_theme_document(&source); + let parsed = parse_theme_json(&json).document.unwrap(); + assert_eq!(parsed, source); + assert_eq!(serialize_theme_document(&parsed), json); + assert!(json.starts_with("{\n \"schemaVersion\": 3,\n \"id\": \"nord\"")); + } +} + +mod resolve { + use super::*; + + #[test] + fn lets_the_nearest_document_win_and_fills_the_rest_from_the_root() { + let parent = ThemeDocument { + tokens: tokens(&[(TokenKey::Accent, "#111111"), (TokenKey::Fg, "#eeeeee")]), + ..doc("parent") + }; + let child = ThemeDocument { + extends: Some("parent".into()), + tokens: tokens(&[(TokenKey::Accent, "#222222")]), + ..doc("child") + }; + let documents = [parent, child.clone()]; + let resolved = resolve_theme_tokens(&child, registry_of(&documents), &roots()); + assert_eq!(resolved.tokens[TokenKey::Accent], *"#222222"); + assert_eq!(resolved.tokens[TokenKey::Fg], *"#eeeeee"); + assert_eq!(resolved.tokens[TokenKey::BgCard], *"dark-bgCard"); + assert_eq!(resolved.chain, ["child", "parent"]); + assert_eq!(resolved.broken_extends, None); + } + + #[test] + fn still_produces_a_complete_theme_when_the_file_is_empty() { + let empty = doc("test"); + let resolved = resolve_theme_tokens(&empty, registry_of(&[]), &roots()); + for key in TokenKey::ALL { + assert_eq!(resolved.tokens.get(key), format!("dark-{}", key.name())); + } + assert_eq!(resolved.filled_from_root.len(), TokenKey::ALL.len()); + } + + #[test] + fn survives_a_base_that_does_not_exist() { + let orphan = ThemeDocument { + extends: Some("ghost".into()), + tokens: tokens(&[(TokenKey::Fg, "#abcdef")]), + ..doc("orphan") + }; + let documents = [orphan.clone()]; + let resolved = resolve_theme_tokens(&orphan, registry_of(&documents), &roots()); + assert_eq!(resolved.broken_extends.as_deref(), Some("ghost")); + assert_eq!(resolved.tokens[TokenKey::Fg], *"#abcdef"); + assert_eq!(resolved.tokens[TokenKey::BgCard], *"dark-bgCard"); + } + + #[test] + fn survives_a_cycle_rather_than_spinning_on_it() { + let a = ThemeDocument { + extends: Some("b".into()), + tokens: tokens(&[(TokenKey::Fg, "#a0a0a0")]), + ..doc("a") + }; + let b = ThemeDocument { + extends: Some("a".into()), + tokens: tokens(&[(TokenKey::BgCard, "#0b0b0b")]), + ..doc("b") + }; + let documents = [a.clone(), b]; + let resolved = resolve_theme_tokens(&a, registry_of(&documents), &roots()); + assert_eq!(resolved.broken_extends.as_deref(), Some("a")); + assert_eq!(resolved.chain, ["a", "b"]); + assert_eq!(resolved.tokens[TokenKey::Fg], *"#a0a0a0"); + assert_eq!(resolved.tokens[TokenKey::BgCard], *"#0b0b0b"); + } +} + +mod validate { + use super::*; + + fn readable_tokens() -> BTreeMap { + tokens(&[ + (TokenKey::BgRgb, "9 9 11"), + (TokenKey::BgCard, "#1c1c1f"), + (TokenKey::Fg, "#fafafa"), + (TokenKey::FgMuted, "#a1a1aa"), + (TokenKey::FgSubtle, "#71717a"), + (TokenKey::AfkText, "#ffffff"), + (TokenKey::Accent, "#2563eb"), + (TokenKey::AccentFg, "#ffffff"), + (TokenKey::Success, "#22c55e"), + (TokenKey::Warning, "#eab308"), + (TokenKey::Danger, "#dc2626"), + ]) + } + + fn with_tokens(map: BTreeMap) -> ThemeDocument { + ThemeDocument { + tokens: map, + ..doc("test") + } + } + + fn issues_for(document: &ThemeDocument) -> Vec { + let mut dark = labelled_root("dark"); + for (key, value) in readable_tokens() { + dark.set(key, value); + } + let roots = RootTokens { + dark, + light: labelled_root("light"), + }; + let documents = [document.clone()]; + let resolved = resolve_theme_tokens(document, registry_of(&documents), &roots); + validate_theme_document(document, &resolved, None) + } + + #[test] + fn says_nothing_about_a_readable_palette() { + let issues = issues_for(&with_tokens(readable_tokens())); + assert!(issues + .iter() + .all(|issue| issue.code != ThemeIssueCode::Contrast)); + } + + #[test] + fn reports_text_that_cannot_be_read_on_its_own_background() { + let mut map = readable_tokens(); + map.insert(TokenKey::Fg, "#1d1d20".into()); + let issues = issues_for(&with_tokens(map)); + let issue = issues + .iter() + .find(|issue| { + issue.code == ThemeIssueCode::Contrast + && issue.token == Some(TokenKey::Fg) + && issue.against == Some(TokenKey::BgCard) + }) + .unwrap(); + assert_eq!(issue.level, IssueLevel::Error); + assert!(issue.ratio.unwrap() < 3.0); + } + + #[test] + fn measures_a_translucent_card_as_it_is_actually_painted() { + let mut map = readable_tokens(); + map.insert(TokenKey::BgRgb, "22 24 30".into()); + map.insert(TokenKey::BgCard, "#ffffff".into()); + map.insert(TokenKey::Fg, "#ffffff".into()); + let glass = with_tokens(map); + let documents = [glass.clone()]; + let resolved = resolve_theme_tokens(&glass, registry_of(&documents), &roots()); + let fg_contrast = |alpha| { + validate_theme_document(&glass, &resolved, alpha) + .into_iter() + .filter(|issue| { + issue.code == ThemeIssueCode::Contrast && issue.token == Some(TokenKey::Fg) + }) + .count() + }; + assert_ne!(fg_contrast(None), 0); + assert_eq!(fg_contrast(Some(0.13)), 0); + } + + #[test] + fn flags_a_hole_only_when_the_theme_inherits_from_nothing() { + let standalone = with_tokens(tokens(&[(TokenKey::Fg, "#fafafa")])); + let derived = ThemeDocument { + extends: Some("dark".into()), + ..standalone.clone() + }; + let has_missing = |document| { + issues_for(document) + .iter() + .any(|issue| issue.code == ThemeIssueCode::MissingToken) + }; + assert!(has_missing(&standalone)); + assert!(!has_missing(&derived)); + } + + #[test] + fn does_not_call_a_theme_incomplete_for_leaving_the_structure_alone() { + let palette = with_tokens( + THEME_TOKEN_SPECS + .iter() + .filter(|spec| is_essential_token(spec.key.name())) + .map(|spec| (spec.key, format!("dark-{}", spec.key.name()))) + .collect(), + ); + let missing: Vec<_> = issues_for(&palette) + .into_iter() + .filter(|issue| issue.code == ThemeIssueCode::MissingToken) + .collect(); + assert!(missing.is_empty(), "{missing:?}"); + } + + #[test] + fn blocks_css_that_reaches_outside_the_window_and_css_past_the_cap() { + let hostile = ThemeDocument { + css: Some("@import url(https://example.com/x.css);".into()), + ..with_tokens(readable_tokens()) + }; + let huge = ThemeDocument { + css: Some(format!("/*{}*/", "x".repeat(MAX_THEME_CSS_LENGTH))), + ..with_tokens(readable_tokens()) + }; + let unsafe_issue = issues_for(&hostile) + .into_iter() + .find(|issue| issue.code == ThemeIssueCode::UnsafeCss) + .unwrap(); + let too_long = issues_for(&huge) + .into_iter() + .find(|issue| issue.code == ThemeIssueCode::CssTooLong) + .unwrap(); + assert_eq!(unsafe_issue.level, IssueLevel::Error); + assert_eq!(unsafe_issue.construct, Some("@import")); + assert_eq!(too_long.level, IssueLevel::Error); + assert_eq!(too_long.limit, Some(MAX_THEME_CSS_LENGTH)); + } + + #[test] + fn reports_a_value_the_editor_refuses_to_store() { + let mut map = readable_tokens(); + map.insert(TokenKey::RadiusMd, "12 pixels".into()); + let issue = issues_for(&with_tokens(map)) + .into_iter() + .find(|issue| issue.code == ThemeIssueCode::InvalidValue) + .unwrap(); + assert_eq!(issue.level, IssueLevel::Error); + assert_eq!(issue.token, Some(TokenKey::RadiusMd)); + assert_eq!(issue.expected.map(|kind| kind.name()), Some("length")); + } +} + +#[test] +fn duplicate_extends_the_source_instead_of_copying_its_tokens() { + let source = ThemeDocument { + glass: Some(true), + tokens: tokens(&[(TokenKey::Fg, "#f4f4f6")]), + css: Some(".account-card { letter-spacing: 0.02em; }".into()), + ..doc("glass-dark") + }; + let copy = duplicate_theme_document(&source, "my-glass", "My Glass"); + assert_eq!(copy.extends.as_deref(), Some("glass-dark")); + assert!(copy.tokens.is_empty()); + assert_eq!(copy.css, source.css); + assert_eq!(copy.glass, Some(true)); + assert_eq!(copy.color_scheme, source.color_scheme); +} + +/// `surfaceOpacities.test.ts`, captured from the TypeScript before the four +/// surface rules became a table. +mod surfaces { + use super::*; + + const GOLDEN: &[(&str, f64, bool, [f64; 6], bool)] = &[ + ( + "dark", + 0.0, + true, + [0.0, 0.66, 0.72, 0.72, 0.78, 0.86], + false, + ), + ( + "dark", + 25.0, + true, + [0.25, 0.66, 0.72, 0.72, 0.78, 0.86], + false, + ), + ( + "dark", + 42.0, + false, + [0.42, 0.66, 0.72, 0.72, 0.78, 0.86], + false, + ), + ( + "dark", + 55.0, + true, + [0.55, 0.6900000000000001, 0.75, 0.73, 0.78, 0.86], + false, + ), + ( + "dark", + 80.0, + false, + [0.8, 0.9400000000000001, 1.0, 0.98, 1.0, 1.0], + false, + ), + ("dark", 100.0, true, [1.0, 1.0, 1.0, 1.0, 1.0, 1.0], false), + ( + "light", + 55.0, + false, + [0.55, 0.6900000000000001, 0.75, 0.73, 0.78, 0.86], + false, + ), + ( + "midnight", + 80.0, + true, + [0.8, 0.9400000000000001, 1.0, 0.98, 1.0, 1.0], + false, + ), + ( + "glass-dark", + 0.0, + true, + [0.55, 0.65, 0.73, 0.6900000000000001, 0.75, 0.86], + false, + ), + ( + "glass-dark", + 42.0, + false, + [0.96, 0.72, 0.7999999999999999, 0.78, 0.85, 1.0], + false, + ), + ( + "glass-light", + 100.0, + true, + [0.55, 0.65, 0.73, 0.6900000000000001, 0.75, 0.86], + false, + ), + ( + "glass-light", + 25.0, + false, + [0.96, 0.72, 0.7999999999999999, 0.78, 0.85, 1.0], + false, + ), + ( + "liquid-glass", + 55.0, + true, + [0.18, 0.13, 0.21, 0.16, 0.34, 0.86], + true, + ), + ( + "liquid-glass", + 80.0, + false, + [0.96, 0.72, 0.7999999999999999, 0.78, 0.85, 1.0], + false, + ), + ]; + + #[test] + fn paints_every_built_in_theme_exactly_as_before_the_table() { + let registry = ThemeRegistry::new(); + for (id, percent, backdrop, expected, liquid) in GOLDEN { + let s = resolve_theme_surface_opacities(®istry.theme(Some(id)), *percent, *backdrop); + let actual = [ + s.window_opacity, + s.card_opacity, + s.hover_opacity, + s.muted_opacity, + s.elevated_opacity, + s.overlay_opacity, + ]; + assert_eq!(actual, *expected, "{id} {percent} {backdrop}"); + assert_eq!(s.is_liquid, *liquid, "{id} {percent} {backdrop}"); + } + } + + #[test] + fn clamps_a_slider_value_outside_0_to_100() { + let dark = ThemeRegistry::new().theme(Some("dark")); + assert_eq!( + resolve_theme_surface_opacities(&dark, -40.0, true).window_opacity, + 0.0 + ); + assert_eq!( + resolve_theme_surface_opacities(&dark, 400.0, true).window_opacity, + 1.0 + ); + } +} + +/// `themes.test.ts`. +mod themes { + use super::*; + use accshift_theme::{is_valid_token_value, BuiltInOverwrite}; + + fn payload(value: serde_json::Value) -> CustomThemePayload { + serde_json::from_value(value).unwrap() + } + + #[test] + fn liquid_glass_uses_tuned_translucent_values_with_a_backdrop() { + let registry = ThemeRegistry::new(); + let values = + resolve_theme_surface_opacities(®istry.theme(Some("liquid-glass")), 50.0, true); + assert!(values.is_liquid); + assert_eq!(values.window_opacity, 0.18); + assert_eq!(values.card_opacity, 0.13); + let fallback = + resolve_theme_surface_opacities(®istry.theme(Some("liquid-glass")), 50.0, false); + assert!(!fallback.is_liquid); + assert_eq!(fallback.window_opacity, 0.96); + assert_eq!(fallback.card_opacity, 0.72); + assert_eq!(fallback.overlay_opacity, 1.0); + let dark = resolve_theme_surface_opacities(®istry.theme(Some("dark")), 42.0, false); + assert_eq!(dark.window_opacity, 0.42); + } + + #[test] + fn treats_a_theme_that_extends_liquid_glass_as_liquid() { + let registry = ThemeRegistry::new(); + let copy = registry.theme_from_document(&ThemeDocument { + extends: Some("liquid-glass".into()), + glass: Some(true), + name: "My Liquid".into(), + ..doc("my-liquid") + }); + assert!(copy.liquid); + assert!(registry.uses_liquid_glass(©)); + let values = resolve_theme_surface_opacities(©, 50.0, true); + assert!(values.is_liquid); + assert_eq!(values.window_opacity, 0.18); + assert_eq!(values.card_opacity, 0.13); + } + + #[test] + fn gives_both_roots_a_valid_value_for_every_token() { + for (scheme, root) in [ + ("dark", &root_tokens().dark), + ("light", &root_tokens().light), + ] { + for (key, value) in root.iter() { + assert!(!value.is_empty(), "{scheme}.{key}"); + assert!( + is_valid_token_value(key.name(), value), + "{scheme}.{key} = {value}" + ); + } + } + } + + #[test] + fn ships_nothing_a_user_could_not_read() { + let registry = ThemeRegistry::new(); + for theme in registry.built_in_themes() { + let resolved = + resolve_theme_tokens(&theme.document, |id| registry.document(id), root_tokens()); + let card_alpha = resolve_theme_surface_opacities(&theme, 100.0, true).card_opacity; + let errors: Vec<_> = + validate_theme_document(&theme.document, &resolved, Some(card_alpha)) + .into_iter() + .filter(|issue| issue.level == IssueLevel::Error) + .collect(); + assert!(errors.is_empty(), "{}: {errors:?}", theme.id); + } + } + + #[test] + fn keeps_the_two_json_built_ins_expressible_in_the_public_format() { + let registry = ThemeRegistry::new(); + for id in ["midnight", "glass-dark"] { + let theme = registry.theme(Some(id)); + assert_eq!(theme.id, id); + assert_eq!(theme.document.extends.as_deref(), Some("dark")); + assert_eq!( + theme.tokens[TokenKey::Accent], + root_tokens().dark[TokenKey::Accent] + ); + assert_eq!( + theme.tokens[TokenKey::Density], + root_tokens().dark[TokenKey::Density] + ); + assert_eq!( + theme.tokens.get(TokenKey::BgCard), + theme.document.tokens[&TokenKey::BgCard] + ); + } + } + + #[test] + fn keeps_a_version_1_theme_working_and_hands_it_the_tokens_added_since() { + let mut registry = ThemeRegistry::new(); + registry.apply_custom_theme_payloads(&[payload(json!({ + "id": "legacy", "name": "Legacy", "colorScheme": "dark", + "tokens": { "bgCard": "#101014", "fg": "#f0f0f0" }, + }))]); + let theme = registry.theme(Some("legacy")); + assert!(theme.is_custom); + assert_eq!(theme.tokens[TokenKey::BgCard], *"#101014"); + assert_eq!(theme.tokens[TokenKey::Fg], *"#f0f0f0"); + assert_eq!( + theme.tokens[TokenKey::Accent], + root_tokens().dark[TokenKey::Accent] + ); + assert_eq!( + theme.tokens[TokenKey::RadiusMd], + root_tokens().dark[TokenKey::RadiusMd] + ); + } + + #[test] + fn degrades_a_broken_theme_to_its_base_instead_of_breaking_the_interface() { + let mut registry = ThemeRegistry::new(); + registry.apply_custom_theme_payloads(&[ + payload(json!({ + "schemaVersion": 2, "id": "broken", "name": "Broken", "colorScheme": "dark", + "extends": "does-not-exist", "tokens": { "bgCard": "javascript:alert(1)" }, + })), + payload(json!({ "id": "", "name": "Nameless", "colorScheme": "dark", "tokens": {} })), + ]); + let theme = registry.theme(Some("broken")); + assert_eq!( + theme.tokens[TokenKey::BgCard], + root_tokens().dark[TokenKey::BgCard] + ); + for (key, value) in theme.tokens.iter() { + assert!(!value.is_empty(), "{key}"); + } + assert_eq!(registry.theme(Some("")).id, "dark"); + + registry.apply_custom_theme_payloads(&[]); + assert_eq!(registry.theme(Some("broken")).id, "dark"); + } + + #[test] + fn writes_css_the_filter_refused_back_to_the_file_when_the_editor_saves() { + let refused = ".card { background: image-set(\"a.png\" 1x); }"; + let older = || { + payload(json!({ + "schemaVersion": 3, "id": "older", "name": "Older", "colorScheme": "dark", + "tokens": {}, "css": refused, + })) + }; + let mut registry = ThemeRegistry::new(); + registry.apply_custom_theme_payloads(&[older()]); + let theme = registry.theme(Some("older")); + assert_eq!(theme.document.css, None); + + let save = |registry: &mut ThemeRegistry, keep: bool, css: Option<&str>| { + let document = ThemeDocument { + name: "Renamed".into(), + css: css.map(str::to_owned), + ..theme.document.clone() + }; + let pending = registry.prepare_save(&document, keep).unwrap(); + let css = pending.payload.css.clone(); + registry.finish_save(pending); + css + }; + assert_eq!(save(&mut registry, true, None).as_deref(), Some(refused)); + assert_eq!( + save(&mut registry, true, Some(".card { opacity: 1; }")).as_deref(), + Some(".card { opacity: 1; }") + ); + assert_eq!(save(&mut registry, true, None), None); + + registry.apply_custom_theme_payloads(&[older()]); + assert_eq!(save(&mut registry, false, None), None); + + assert_eq!( + registry.prepare_save(&doc("dark"), false), + Err(BuiltInOverwrite("dark".into())) + ); + } + + #[test] + fn writes_every_token_to_the_custom_property_its_spec_declares() { + let applied = theme_application(&ThemeRegistry::new().theme(Some("dark")), 100.0, true); + for spec in &THEME_TOKEN_SPECS { + assert!(applied.var(spec.css_var).is_some(), "{}", spec.css_var); + } + assert_eq!( + applied.var("--accent"), + Some(&root_tokens().dark[TokenKey::Accent]) + ); + assert_eq!( + applied.var("--radius-md"), + Some(&root_tokens().dark[TokenKey::RadiusMd]) + ); + assert_eq!(applied.var("--density-scale"), Some("1")); + assert_eq!( + applied.var("--font-ui"), + Some("Inter, var(--font-stack-base)") + ); + } + + #[test] + fn turns_the_structure_tokens_into_what_css_can_actually_use() { + let theme = ThemeRegistry::new().theme_from_document(&ThemeDocument { + color_scheme: ColorScheme::Light, + tokens: tokens(&[ + (TokenKey::AvatarShape, "circle"), + (TokenKey::FontSmoothing, "none"), + (TokenKey::BorderStyle, "ridge"), + (TokenKey::BorderWidth, "2px"), + (TokenKey::MotionScale, "0"), + (TokenKey::FontDisplay, "Tahoma"), + ( + TokenKey::CardBgImage, + "linear-gradient(180deg, #ffffff, #c0c0c0)", + ), + ]), + ..doc("bevelled") + }); + let applied = theme_application(&theme, 100.0, true); + assert_eq!(applied.var("--avatar-radius"), Some("50%")); + assert_eq!(applied.var("--font-smoothing"), Some("none")); + assert_eq!(applied.var("--font-smoothing-mac"), Some("auto")); + assert_eq!(applied.var("--border-style"), Some("ridge")); + assert_eq!(applied.var("--border-width"), Some("2px")); + assert_eq!(applied.var("--motion-scale"), Some("0")); + assert_eq!( + applied.var("--font-display"), + Some("Tahoma, var(--font-ui)") + ); + assert_eq!( + applied.var("--card-bg-image"), + Some("linear-gradient(180deg, #ffffff, #c0c0c0)") + ); + } + + #[test] + fn keeps_a_gradient_off_the_surfaces_a_glass_theme_reads_the_desktop_through() { + let gradient = "linear-gradient(180deg, #ffffff, #000000)"; + let theme = ThemeRegistry::new().theme_from_document(&ThemeDocument { + glass: Some(true), + tokens: tokens(&[ + (TokenKey::BgImage, gradient), + (TokenKey::CardBgImage, gradient), + ]), + ..doc("glassy") + }); + let applied = theme_application(&theme, 100.0, true); + assert_eq!(applied.var("--bg-image"), Some("none")); + assert_eq!(applied.var("--card-bg-image"), Some("none")); + } + + #[test] + fn paints_a_theme_it_has_never_seen_without_falling_over() { + let theme = ThemeRegistry::new().theme_from_document(&ThemeDocument { + schema_version: 2, + color_scheme: ColorScheme::Light, + tokens: tokens(&[(TokenKey::Accent, "#ff0088")]), + ..doc("sparse") + }); + let applied = theme_application(&theme, 100.0, true); + assert_eq!(applied.var("--accent"), Some("#ff0088")); + assert_eq!( + applied.var("--fg"), + Some(&root_tokens().light[TokenKey::Fg]) + ); + } + + #[test] + fn carries_the_themes_own_css_and_takes_it_away_with_the_theme() { + let registry = ThemeRegistry::new(); + let with_css = registry.theme_from_document(&ThemeDocument { + schema_version: 2, + css: Some(".account-card { letter-spacing: 0.02em; }".into()), + ..doc("with-css") + }); + assert!(theme_application(&with_css, 100.0, true) + .css + .unwrap() + .contains("letter-spacing")); + assert_eq!( + theme_application(®istry.theme(Some("dark")), 100.0, true).css, + None + ); + } + + #[test] + fn suggests_unique_ids() { + let mut registry = ThemeRegistry::new(); + assert_eq!(registry.suggest_theme_id(" My Théme!! "), "my-th-me"); + assert_eq!(registry.suggest_theme_id("Dark"), "dark-2"); + assert_eq!(registry.suggest_theme_id("!!!"), "theme"); + registry.apply_custom_theme_payloads(&[payload(json!({ + "schemaVersion": 3, "id": "dark-2", "name": "x", "colorScheme": "dark", "tokens": {}, + }))]); + assert_eq!(registry.suggest_theme_id("Dark"), "dark-3"); + } +} diff --git a/crates/accshift-theme/tools/variables.dump.ts b/crates/accshift-theme/tools/variables.dump.ts new file mode 100644 index 0000000..76f33ff --- /dev/null +++ b/crates/accshift-theme/tools/variables.dump.ts @@ -0,0 +1,177 @@ +// Writes what the real TypeScript theme engine sets on for every +// built-in theme (and a few custom ones) to crates/accshift-theme/tests/data. +// The Rust tests compare their own output against these files. +// +// From the repo root: +// build-safe node node_modules/vitest/vitest.mjs run --config crates/accshift-theme/tools/vitest.dump.config.mjs +import { mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: () => Promise.resolve(undefined) })); +vi.mock("@tauri-apps/api/window", () => ({ getCurrentWindow: () => ({}), Effect: {} })); + +import { + BUILT_IN_THEMES, + applyCustomThemePayloads, + applyThemeToDocument, + getThemeDefinition, + resolveThemeSurfaceOpacities, + themeUsesLiquidGlass, + type CustomThemePayload, +} from "$lib/theme/themes"; + +const OUT_DIR = fileURLToPath(new URL("../tests/data/", import.meta.url)); +const OPACITIES = [0, 50, 85, 100]; +const BACKDROPS = [true, false]; + +/** Custom themes that walk the paths the built-ins never take. */ +const CUSTOM_PAYLOADS: CustomThemePayload[] = [ + { + id: "legacy-v1", + name: "Legacy", + colorScheme: "light", + tokens: { bgCard: "#ffffff", fg: "#000000", density: "roomy" }, + }, + { + schemaVersion: 3, + id: "bevelled", + name: "Bevelled", + colorScheme: "light", + tokens: { + avatarShape: "circle", + fontSmoothing: "none", + borderStyle: "ridge", + borderWidth: "2px", + motionScale: "0", + fontDisplay: "Tahoma", + density: "compact", + letterSpacing: "-0.02em", + cardBgImage: "linear-gradient(180deg, #ffffff, #c0c0c0)", + bgImage: "radial-gradient(circle, #eeeeee 0%, #cccccc 100%)", + bgCard: "#abc", + }, + }, + { + schemaVersion: 3, + id: "my-liquid", + name: "My Liquid", + colorScheme: "dark", + extends: "liquid-glass", + glass: true, + tokens: { accent: "#ff8800" }, + }, + { + schemaVersion: 3, + id: "glassy", + name: "Glassy", + colorScheme: "dark", + glass: true, + tokens: { + bgImage: "linear-gradient(180deg, #ffffff, #000000)", + cardBgImage: "linear-gradient(180deg, #ffffff, #000000)", + density: "comfortable", + }, + }, + { + schemaVersion: 2, + id: "with-css", + name: "With CSS", + colorScheme: "dark", + extends: "midnight", + tokens: { accent: "rgb(200 40 120)", border: "rebeccapurple" }, + css: ":root { --accent: #00ff00; --accent-text: red !important; --z-menu: 5; }\n.card { color: red; }", + }, +]; + +function stubDocument() { + const vars: Array<[string, string]> = []; + const head: Array<{ id: string; textContent: string; remove(): void }> = []; + const documentElement = { + dataset: {} as Record, + style: { + colorScheme: "", + setProperty(name: string, value: string) { + const existing = vars.find(([key]) => key === name); + if (existing) existing[1] = value; + else vars.push([name, value]); + }, + }, + }; + const doc = { + documentElement, + head: { + appendChild(element: (typeof head)[number]) { + head.push(element); + return element; + }, + }, + createElement() { + const element = { + id: "", + textContent: "", + remove() { + const index = head.indexOf(element); + if (index !== -1) head.splice(index, 1); + }, + }; + return element; + }, + getElementById(id: string) { + return head.find((element) => element.id === id) ?? null; + }, + }; + return { doc: doc as unknown as Document, documentElement, vars, head }; +} + +function dumpTheme(id: string, payload: CustomThemePayload | null) { + const theme = getThemeDefinition(id); + const cases = []; + for (const opacity of OPACITIES) { + for (const backdropAvailable of BACKDROPS) { + const { doc, documentElement, vars, head } = stubDocument(); + applyThemeToDocument(theme, opacity, doc, { backdropAvailable }); + cases.push({ + opacity, + backdropAvailable, + dataTheme: documentElement.dataset.theme, + dataGlass: documentElement.dataset.glass, + colorScheme: documentElement.style.colorScheme, + css: head[0]?.textContent ?? null, + surfaces: resolveThemeSurfaceOpacities(theme, opacity, { backdropAvailable }), + vars: Object.fromEntries(vars), + }); + } + } + return { + id, + payload, + isCustom: theme.isCustom === true, + liquid: themeUsesLiquidGlass(theme), + tokens: theme.tokens, + cases, + }; +} + +describe("theme variable dump", () => { + it("writes every built-in and custom case", () => { + applyCustomThemePayloads(CUSTOM_PAYLOADS); + mkdirSync(OUT_DIR, { recursive: true }); + for (const name of readdirSync(OUT_DIR)) { + if (name.endsWith(".json")) rmSync(`${OUT_DIR}${name}`); + } + let written = 0; + for (const theme of BUILT_IN_THEMES) { + const dump = dumpTheme(theme.id, null); + writeFileSync(`${OUT_DIR}builtin-${theme.id}.json`, `${JSON.stringify(dump, null, 2)}\n`); + written += 1; + } + for (const payload of CUSTOM_PAYLOADS) { + const dump = dumpTheme(payload.id, payload); + expect(dump.isCustom, payload.id).toBe(true); + writeFileSync(`${OUT_DIR}custom-${payload.id}.json`, `${JSON.stringify(dump, null, 2)}\n`); + written += 1; + } + expect(written).toBe(BUILT_IN_THEMES.length + CUSTOM_PAYLOADS.length); + }); +}); diff --git a/crates/accshift-theme/tools/vitest.dump.config.mjs b/crates/accshift-theme/tools/vitest.dump.config.mjs new file mode 100644 index 0000000..383a366 --- /dev/null +++ b/crates/accshift-theme/tools/vitest.dump.config.mjs @@ -0,0 +1,17 @@ +// Vitest config for the variable dump only. The file it runs is named +// `*.dump.ts` so the app's own `vitest run` never picks it up. +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +const root = fileURLToPath(new URL("../../..", import.meta.url)); + +export default defineConfig({ + root, + resolve: { + alias: [{ find: "$lib", replacement: `${root}src/lib` }], + }, + test: { + include: ["crates/accshift-theme/tools/*.dump.ts"], + environment: "node", + }, +}); From b3b9b5c10f5676ace304061abcee59a920ea8cbe Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:47:13 +0200 Subject: [PATCH 007/106] test(parity): reference frames from the Tauri app --- crates/accshift-egui/parity/README.md | 88 + crates/accshift-egui/parity/data/demo.json | 1585 +++ crates/accshift-egui/parity/data/dev.json | 984 ++ crates/accshift-egui/parity/data/empty.json | 377 + crates/accshift-egui/parity/data/huge.json | 10402 ++++++++++++++++ crates/accshift-egui/parity/data/slow.json | 984 ++ crates/accshift-egui/parity/diff.mjs | 337 + crates/accshift-egui/parity/dump-data.mjs | 190 + crates/accshift-egui/parity/harness.mjs | 419 + crates/accshift-egui/parity/ref.mjs | 377 + .../parity/scenes/spike-main.json | 50 + src-tauri/Cargo.toml | 3 + src-tauri/src/app_runtime.rs | 182 +- src-tauri/src/boot.rs | 11 +- src-tauri/src/lib.rs | 20 +- src-tauri/src/tauri_context.rs | 50 +- src-tauri/src/telemetry_runtime.rs | 8 +- 17 files changed, 16042 insertions(+), 25 deletions(-) create mode 100644 crates/accshift-egui/parity/README.md create mode 100644 crates/accshift-egui/parity/data/demo.json create mode 100644 crates/accshift-egui/parity/data/dev.json create mode 100644 crates/accshift-egui/parity/data/empty.json create mode 100644 crates/accshift-egui/parity/data/huge.json create mode 100644 crates/accshift-egui/parity/data/slow.json create mode 100644 crates/accshift-egui/parity/diff.mjs create mode 100644 crates/accshift-egui/parity/dump-data.mjs create mode 100644 crates/accshift-egui/parity/harness.mjs create mode 100644 crates/accshift-egui/parity/ref.mjs create mode 100644 crates/accshift-egui/parity/scenes/spike-main.json diff --git a/crates/accshift-egui/parity/README.md b/crates/accshift-egui/parity/README.md new file mode 100644 index 0000000..c85dae0 --- /dev/null +++ b/crates/accshift-egui/parity/README.md @@ -0,0 +1,88 @@ +# Parity bench + +Every screen of the egui port is compared, pixel by pixel, with the same screen of the Tauri app. This folder holds the reference side and the comparison: + +- `ref.mjs` renders scenes in the real Tauri app (WebView2) on the mock frontend and writes one PNG per shot. +- `diff.mjs` compares reference frames with egui frames and writes a report. +- `dump-data.mjs` exports the mock's command results to `data/.json`, so the egui side runs on the same data. +- `harness.mjs` is the shared plumbing: vite dev server, app launch, CDP client, window and data-dir checks. +- `scenes/*.json` are the scenes, `data/*.json` the dumped mock data. + +Node 22+ and ffmpeg on `PATH`. No npm dependency. + +## Run it + +```sh +cargo build -p accshift-gui --features parity-capture +node crates/accshift-egui/parity/ref.mjs crates/accshift-egui/parity/scenes/*.json --out +node crates/accshift-egui/parity/diff.mjs --batch --out +node crates/accshift-egui/parity/dump-data.mjs # every scenario, or name some +``` + +`ref.mjs` options: `--exe ` (default: the debug `accshift-gui` of the cargo target dir), `--scratch ` (default `/.scratch`), `--window hidden|cloak`. It exits non-zero if a shot has the wrong size, if the window was ever seen, or if anything changed in the real app directories or the `accshift://` registration. + +## The reference app + +A debug `accshift-gui` built with the `parity-capture` feature. That build: + +- carries the marker string `accshift-parity-capture-build`; the harness refuses any binary without it, and copies the binary into the scratch folder before launching it; +- never shows its main window (with `--window cloak`: shown without activation as a tool window at -32000,-32000, DWM-cloaked), and prints a `parity-window` line with `IsWindowVisible`, the cloak state, foreground state and window rect every 250 ms; `ref.mjs` turns those into `hidden=true|false`; +- refuses to start without `ACCSHIFT_PARITY_DIR`, and puts every app directory and the WebView2 profile under it; +- registers no single-instance plugin, never rewrites `accshift://`, skips the startup snapshot upgrade and keyring sweep, and runs telemetry with no consent, so nothing is sent. + +The frontend comes from `vite dev` on port 1420 (the `devUrl` compiled into the debug app) with `VITE_MOCK=dev`, the non-frozen mock, so each scene picks its own scenario. CDP is opened on a free port through `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS`, which also turns off Chromium's native occlusion tracking so the unseen window keeps painting. + +## Scene format + +`scenes/.json`, the contract between `ref.mjs` and the egui runner (`accshift-egui --scene`): + +```json +{ + "id": "main-grid", + "scenario": "demo", + "os": "windows", + "viewport": { "width": 860, "height": 440, "scale": 1.5 }, + "theme": "glass-dark", + "locale": "en", + "seed": { "SETTINGS": { "viewMode": "grid" } }, + "steps": [ + { "wait": 600 }, + { "move": [425, 220] }, + { "click": [315, 133] }, + { "dblclick": [260, 17] }, + { "rclick": [315, 133] }, + { "key": "Ctrl+K" }, + { "text": "ra" }, + { "scroll": [430, 300, -120] }, + { "shot": "hover", "alias": "egui-0-hover" } + ] +} +``` + +- Coordinates are CSS pixels of the viewport (1 CSS px = 1 egui point). Output: `//.png`, viewport times scale. +- `scenario` is a key of `SCENARIOS` in `src/lib/mock/scenarios.ts`; `os` sets the mock's runtime OS. +- `theme` and `locale` patch `client.settings` (`themeId`, `language`). A `seed` key is a store id (`client.view-mode`) or the suffix of its `CLIENT_STORE_` constant in `src/lib/storage/storeIds.ts` (`SETTINGS`); an object value is merged over the scenario's own store, anything else replaces it. +- `key` takes `Ctrl`, `Shift`, `Alt` and `Meta` prefixes joined with `+`, then a character or a key name (`Enter`, `Escape`, `Backspace`, `Tab`, `Delete`, `Home`, `End`, arrow keys, `Space`). `text` types character by character. `scroll` is `[x, y, deltaY]`. +- A shot waits for nothing: put a `wait` before it, long enough for transitions to end. +- `alias` on a shot is optional: the file name, without extension, of the same shot in an older capture set (the spike's `--capture` PAMs). `diff.mjs --batch` uses it when `//` does not exist. + +Before each scene, the page is reloaded into its mock state, and runs once every image is decoded and no avatar is blurred. + +## What both sides must reproduce + +- Clock: `Date.now()` and an argument-less `new Date()` start at the mock's `NOW` (1_791_000_000 s) when the document starts, and advance with `performance.now()`. +- Glass themes: a transparent window has nothing behind it, so the page paints a stand-in desktop as the `html` background, the one of the README recorder (`WALLPAPER` in `.agents/scripts/record-demo.mjs`). The desktop is the viewport plus 36 CSS px on each side, with the window at 36,36; the page sees it shifted by -36,-36. `src/backdrop.rs` draws the same thing. Layers, top first: + - `radial-gradient(48% 62% at 16% 20%, rgba(109,40,217,.42), transparent 72%)` + - `radial-gradient(46% 60% at 86% 84%, rgba(29,78,216,.40), transparent 72%)` + - `radial-gradient(34% 44% at 72% 10%, rgba(190,24,93,.22), transparent 72%)` + - `radial-gradient(38% 48% at 28% 96%, rgba(8,145,178,.22), transparent 72%)` + - `linear-gradient(160deg, #10121f, #0a0c16)` +- `prefers-reduced-motion` is emulated as `no-preference`; the mock forces `animations: "on"` anyway. + +## Diff metrics + +Both images are decoded to RGBA by ffmpeg (PNG or PAM) and composited over black. Per shot: mean absolute error over RGB (0 to 255), PSNR over RGB in dB (99 when identical), percent of pixels whose largest channel delta exceeds 8 and 24, the worst 16x16 tile with its mean error, a heat map (`-heat.png`, largest channel delta times 4 on a black, red, yellow, white ramp) and a side-by-side (`-side.png`, reference on top). Batch mode writes `report.json` and `report.md`, one row per shot. + +## Mock data + +`data/.json`, format 1: `spec` (label, runtime OS, switch delay, Steam path, API key flag, current account per platform), `commands` (per command, a list of `{ args, result }` or `{ args, error }`), `skipped` (commands with state that are modelled from `spec` instead: the switch and the PIN session). Platform commands are called once per platform id, per-account commands once per account. Avatar URLs are repo-relative paths under `src/lib/mock/avatars/`, cache timestamps are exactly `NOW * 1000`. The full description sits at the top of `dump-data.mjs`. diff --git a/crates/accshift-egui/parity/data/demo.json b/crates/accshift-egui/parity/data/demo.json new file mode 100644 index 0000000..7117bee --- /dev/null +++ b/crates/accshift-egui/parity/data/demo.json @@ -0,0 +1,1585 @@ +{ + "format": 1, + "scenario": "demo", + "now": 1791000000, + "spec": { + "label": "README recording dataset (frozen)", + "runtimeOs": "windows", + "switchDelayMs": 900, + "steamPath": "C:\\Program Files (x86)\\Steam", + "hasSteamApiKey": true, + "currentSteamAccount": "76561198000000001", + "currentRiotProfile": "riot-1", + "currentRobloxAccount": "1000000001" + }, + "commands": { + "cs2_bridge_get_settings": [ + { + "args": {}, + "result": { + "enabled": false, + "url": "", + "apiKey": "" + } + } + ], + "descriptor_install_file": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "descriptor_preview_file": [ + { + "args": {}, + "error": "a mock session adds no platform" + } + ], + "descriptor_remove": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "descriptor_select_file": [ + { + "args": {}, + "error": "cancelled" + } + ], + "detect_streaming_software": [ + { + "args": {}, + "result": false + } + ], + "get_boot_payload": [ + { + "args": {}, + "result": { + "migration": "skipped", + "runtimeOs": "windows", + "storageSnapshot": { + "manifest": { + "schemaVersion": 1, + "stores": {} + }, + "stores": { + "client.settings": { + "language": "en", + "animations": "on", + "streamerMode": "off", + "inactivityBlurSeconds": 0, + "pinEnabled": false, + "themeId": "glass-dark", + "enabledPlatforms": ["steam", "riot", "roblox"] + }, + "client.folders": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "account", + "id": "76561198000000017" + } + ] + } + }, + "client.personas": [], + "client.account-card-notes": {}, + "client.account-card-colors": { + "76561198000000001": "#8b5cf6", + "76561198000000005": "#f97316", + "76561198000000009": "#ec4899" + }, + "client.account-default-game": {}, + "client.folder-card-colors": {}, + "client.view-mode": "grid", + "cache.steam.profiles": { + "76561198000000001": { + "url": "src/lib/mock/avatars/1.svg", + "displayName": "main", + "timestamp": 1791000000000 + }, + "76561198000000002": { + "url": "src/lib/mock/avatars/2.svg", + "displayName": "bro's account", + "timestamp": 1791000000000 + }, + "76561198000000003": { + "url": "src/lib/mock/avatars/3.svg", + "displayName": "alt", + "timestamp": 1791000000000 + }, + "76561198000000004": { + "url": "src/lib/mock/avatars/4.svg", + "displayName": "trading", + "timestamp": 1791000000000 + }, + "76561198000000005": { + "url": "src/lib/mock/avatars/5.svg", + "displayName": "faceit", + "timestamp": 1791000000000 + }, + "76561198000000006": { + "url": "src/lib/mock/avatars/6.svg", + "displayName": "ranked grind", + "timestamp": 1791000000000 + }, + "76561198000000007": { + "url": "src/lib/mock/avatars/7.svg", + "displayName": "chill", + "timestamp": 1791000000000 + }, + "76561198000000008": { + "url": "src/lib/mock/avatars/8.svg", + "displayName": "lan party", + "timestamp": 1791000000000 + }, + "76561198000000009": { + "url": "src/lib/mock/avatars/9.svg", + "displayName": "streams", + "timestamp": 1791000000000 + }, + "76561198000000010": { + "url": "src/lib/mock/avatars/10.svg", + "displayName": "esea", + "timestamp": 1791000000000 + }, + "76561198000000011": { + "url": "src/lib/mock/avatars/11.svg", + "displayName": "tournaments", + "timestamp": 1791000000000 + }, + "76561198000000012": { + "url": "src/lib/mock/avatars/12.svg", + "displayName": "family", + "timestamp": 1791000000000 + }, + "76561198000000013": { + "url": "src/lib/mock/avatars/13.svg", + "displayName": "practice", + "timestamp": 1791000000000 + }, + "76561198000000014": { + "url": "src/lib/mock/avatars/14.svg", + "displayName": "smurf 1", + "timestamp": 1791000000000 + }, + "76561198000000015": { + "url": "src/lib/mock/avatars/15.svg", + "displayName": "smurf 2", + "timestamp": 1791000000000 + }, + "76561198000000016": { + "url": "src/lib/mock/avatars/16.svg", + "displayName": "smurf 3", + "timestamp": 1791000000000 + }, + "76561198000000017": { + "url": "src/lib/mock/avatars/17.svg", + "displayName": "smurf 4", + "timestamp": 1791000000000 + } + }, + "cache.roblox.profiles": { + "1000000001": { + "url": "src/lib/mock/avatars/18.svg", + "timestamp": 1791000000000 + }, + "1000000002": { + "url": "src/lib/mock/avatars/19.svg", + "timestamp": 1791000000000 + }, + "1000000003": { + "url": "src/lib/mock/avatars/20.svg", + "timestamp": 1791000000000 + }, + "1000000004": { + "url": "src/lib/mock/avatars/21.svg", + "timestamp": 1791000000000 + } + }, + "cache.steam.ban-check-state": {}, + "cache.steam.ban-info-cache": {} + } + }, + "customThemes": [], + "userPlatforms": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + } + ], + "get_desktop_wallpaper": [ + { + "args": {}, + "result": null + } + ], + "get_runtime_os": [ + { + "args": {}, + "result": "windows" + } + ], + "get_storage_manifest": [ + { + "args": {}, + "result": { + "schemaVersion": 1, + "stores": {} + } + } + ], + "list_custom_themes": [ + { + "args": {}, + "result": [] + } + ], + "load_client_storage_snapshot": [ + { + "args": {}, + "result": { + "manifest": { + "schemaVersion": 1, + "stores": {} + }, + "stores": { + "client.settings": { + "language": "en", + "animations": "on", + "streamerMode": "off", + "inactivityBlurSeconds": 0, + "pinEnabled": false, + "themeId": "glass-dark", + "enabledPlatforms": ["steam", "riot", "roblox"] + }, + "client.folders": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "account", + "id": "76561198000000017" + } + ] + } + }, + "client.personas": [], + "client.account-card-notes": {}, + "client.account-card-colors": { + "76561198000000001": "#8b5cf6", + "76561198000000005": "#f97316", + "76561198000000009": "#ec4899" + }, + "client.account-default-game": {}, + "client.folder-card-colors": {}, + "client.view-mode": "grid", + "cache.steam.profiles": { + "76561198000000001": { + "url": "src/lib/mock/avatars/1.svg", + "displayName": "main", + "timestamp": 1791000000000 + }, + "76561198000000002": { + "url": "src/lib/mock/avatars/2.svg", + "displayName": "bro's account", + "timestamp": 1791000000000 + }, + "76561198000000003": { + "url": "src/lib/mock/avatars/3.svg", + "displayName": "alt", + "timestamp": 1791000000000 + }, + "76561198000000004": { + "url": "src/lib/mock/avatars/4.svg", + "displayName": "trading", + "timestamp": 1791000000000 + }, + "76561198000000005": { + "url": "src/lib/mock/avatars/5.svg", + "displayName": "faceit", + "timestamp": 1791000000000 + }, + "76561198000000006": { + "url": "src/lib/mock/avatars/6.svg", + "displayName": "ranked grind", + "timestamp": 1791000000000 + }, + "76561198000000007": { + "url": "src/lib/mock/avatars/7.svg", + "displayName": "chill", + "timestamp": 1791000000000 + }, + "76561198000000008": { + "url": "src/lib/mock/avatars/8.svg", + "displayName": "lan party", + "timestamp": 1791000000000 + }, + "76561198000000009": { + "url": "src/lib/mock/avatars/9.svg", + "displayName": "streams", + "timestamp": 1791000000000 + }, + "76561198000000010": { + "url": "src/lib/mock/avatars/10.svg", + "displayName": "esea", + "timestamp": 1791000000000 + }, + "76561198000000011": { + "url": "src/lib/mock/avatars/11.svg", + "displayName": "tournaments", + "timestamp": 1791000000000 + }, + "76561198000000012": { + "url": "src/lib/mock/avatars/12.svg", + "displayName": "family", + "timestamp": 1791000000000 + }, + "76561198000000013": { + "url": "src/lib/mock/avatars/13.svg", + "displayName": "practice", + "timestamp": 1791000000000 + }, + "76561198000000014": { + "url": "src/lib/mock/avatars/14.svg", + "displayName": "smurf 1", + "timestamp": 1791000000000 + }, + "76561198000000015": { + "url": "src/lib/mock/avatars/15.svg", + "displayName": "smurf 2", + "timestamp": 1791000000000 + }, + "76561198000000016": { + "url": "src/lib/mock/avatars/16.svg", + "displayName": "smurf 3", + "timestamp": 1791000000000 + }, + "76561198000000017": { + "url": "src/lib/mock/avatars/17.svg", + "displayName": "smurf 4", + "timestamp": 1791000000000 + } + }, + "cache.roblox.profiles": { + "1000000001": { + "url": "src/lib/mock/avatars/18.svg", + "timestamp": 1791000000000 + }, + "1000000002": { + "url": "src/lib/mock/avatars/19.svg", + "timestamp": 1791000000000 + }, + "1000000003": { + "url": "src/lib/mock/avatars/20.svg", + "timestamp": 1791000000000 + }, + "1000000004": { + "url": "src/lib/mock/avatars/21.svg", + "timestamp": 1791000000000 + } + }, + "cache.steam.ban-check-state": {}, + "cache.steam.ban-info-cache": {} + } + } + } + ], + "migrate_legacy_config": [ + { + "args": {}, + "result": "skipped" + } + ], + "open_descriptors_folder": [ + { + "args": {}, + "result": null + } + ], + "open_logs_folder": [ + { + "args": {}, + "result": null + } + ], + "open_url": [ + { + "args": {}, + "result": null + } + ], + "platform_detect_installed": [ + { + "args": {}, + "result": ["steam", "riot", "roblox"] + } + ], + "platform_dry_run": [ + { + "args": { + "platformId": "steam", + "accountId": "76561198000000001" + }, + "result": { + "platformId": "steam", + "operation": "switch", + "accountId": "76561198000000001", + "applied": false, + "roots": [], + "steps": [], + "warnings": [] + } + } + ], + "platform_get_accounts": [ + { + "args": { + "platformId": "steam" + }, + "result": [ + { + "steam_id": "76561198000000001", + "account_name": "", + "persona_name": "main", + "last_login_at": 1790992800 + }, + { + "steam_id": "76561198000000002", + "account_name": "", + "persona_name": "bro's account", + "last_login_at": 1790740800 + }, + { + "steam_id": "76561198000000003", + "account_name": "", + "persona_name": "alt", + "last_login_at": 1790222400 + }, + { + "steam_id": "76561198000000004", + "account_name": "", + "persona_name": "trading", + "last_login_at": 1789185600 + }, + { + "steam_id": "76561198000000005", + "account_name": "", + "persona_name": "faceit", + "last_login_at": 1790996400 + }, + { + "steam_id": "76561198000000006", + "account_name": "", + "persona_name": "ranked grind", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561198000000007", + "account_name": "", + "persona_name": "chill", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561198000000008", + "account_name": "", + "persona_name": "lan party", + "last_login_at": 1787544000 + }, + { + "steam_id": "76561198000000009", + "account_name": "", + "persona_name": "streams", + "last_login_at": 1790985600 + }, + { + "steam_id": "76561198000000010", + "account_name": "", + "persona_name": "esea", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561198000000011", + "account_name": "", + "persona_name": "tournaments", + "last_login_at": 1785816000 + }, + { + "steam_id": "76561198000000012", + "account_name": "", + "persona_name": "family", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561198000000013", + "account_name": "", + "persona_name": "practice", + "last_login_at": 1790978400 + }, + { + "steam_id": "76561198000000014", + "account_name": "", + "persona_name": "smurf 1", + "last_login_at": 1789963200 + }, + { + "steam_id": "76561198000000015", + "account_name": "", + "persona_name": "smurf 2", + "last_login_at": 1788667200 + }, + { + "steam_id": "76561198000000016", + "account_name": "", + "persona_name": "smurf 3", + "last_login_at": 1788148800 + }, + { + "steam_id": "76561198000000017", + "account_name": "", + "persona_name": "smurf 4", + "last_login_at": null + } + ] + }, + { + "args": { + "platformId": "riot" + }, + "result": [ + { + "id": "riot-1", + "label": "main", + "snapshot_state": "ready", + "last_used_at": 1790996400000 + }, + { + "id": "riot-2", + "label": "smurf", + "snapshot_state": "ready", + "last_used_at": 1790481600000 + }, + { + "id": "riot-3", + "label": "duo account", + "snapshot_state": "ready", + "last_used_at": 1789358400000 + } + ] + }, + { + "args": { + "platformId": "roblox" + }, + "result": [ + { + "userId": "1000000001", + "username": "Builderman_42", + "displayName": "builder", + "lastLoginAt": 1790992800000 + }, + { + "userId": "1000000002", + "username": "obby_speedrun", + "displayName": "obby runs", + "lastLoginAt": 1790740800000 + }, + { + "userId": "1000000003", + "username": "tycoon_alt", + "displayName": "tycoon alt", + "lastLoginAt": 1790049600000 + }, + { + "userId": "1000000004", + "username": "blox_trader", + "displayName": "trading", + "lastLoginAt": 1788840000000 + } + ] + } + ], + "platform_get_current_account": [ + { + "args": { + "platformId": "steam" + }, + "result": "76561198000000001" + }, + { + "args": { + "platformId": "riot" + }, + "result": "riot-1" + }, + { + "args": { + "platformId": "roblox" + }, + "result": "1000000001" + } + ], + "platform_get_path": [ + { + "args": {}, + "result": "C:\\Program Files (x86)\\Steam" + } + ], + "platform_get_startup_snapshot": [ + { + "args": { + "platformId": "steam" + }, + "result": { + "accounts": [ + { + "steam_id": "76561198000000001", + "account_name": "", + "persona_name": "main", + "last_login_at": 1790992800 + }, + { + "steam_id": "76561198000000002", + "account_name": "", + "persona_name": "bro's account", + "last_login_at": 1790740800 + }, + { + "steam_id": "76561198000000003", + "account_name": "", + "persona_name": "alt", + "last_login_at": 1790222400 + }, + { + "steam_id": "76561198000000004", + "account_name": "", + "persona_name": "trading", + "last_login_at": 1789185600 + }, + { + "steam_id": "76561198000000005", + "account_name": "", + "persona_name": "faceit", + "last_login_at": 1790996400 + }, + { + "steam_id": "76561198000000006", + "account_name": "", + "persona_name": "ranked grind", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561198000000007", + "account_name": "", + "persona_name": "chill", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561198000000008", + "account_name": "", + "persona_name": "lan party", + "last_login_at": 1787544000 + }, + { + "steam_id": "76561198000000009", + "account_name": "", + "persona_name": "streams", + "last_login_at": 1790985600 + }, + { + "steam_id": "76561198000000010", + "account_name": "", + "persona_name": "esea", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561198000000011", + "account_name": "", + "persona_name": "tournaments", + "last_login_at": 1785816000 + }, + { + "steam_id": "76561198000000012", + "account_name": "", + "persona_name": "family", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561198000000013", + "account_name": "", + "persona_name": "practice", + "last_login_at": 1790978400 + }, + { + "steam_id": "76561198000000014", + "account_name": "", + "persona_name": "smurf 1", + "last_login_at": 1789963200 + }, + { + "steam_id": "76561198000000015", + "account_name": "", + "persona_name": "smurf 2", + "last_login_at": 1788667200 + }, + { + "steam_id": "76561198000000016", + "account_name": "", + "persona_name": "smurf 3", + "last_login_at": 1788148800 + }, + { + "steam_id": "76561198000000017", + "account_name": "", + "persona_name": "smurf 4", + "last_login_at": null + } + ], + "currentAccount": "76561198000000001" + } + }, + { + "args": { + "platformId": "riot" + }, + "result": { + "profiles": [ + { + "id": "riot-1", + "label": "main", + "snapshot_state": "ready", + "last_used_at": 1790996400000 + }, + { + "id": "riot-2", + "label": "smurf", + "snapshot_state": "ready", + "last_used_at": 1790481600000 + }, + { + "id": "riot-3", + "label": "duo account", + "snapshot_state": "ready", + "last_used_at": 1789358400000 + } + ], + "currentProfile": "riot-1" + } + }, + { + "args": { + "platformId": "roblox" + }, + "result": { + "accounts": [ + { + "userId": "1000000001", + "username": "Builderman_42", + "displayName": "builder", + "lastLoginAt": 1790992800000 + }, + { + "userId": "1000000002", + "username": "obby_speedrun", + "displayName": "obby runs", + "lastLoginAt": 1790740800000 + }, + { + "userId": "1000000003", + "username": "tycoon_alt", + "displayName": "tycoon alt", + "lastLoginAt": 1790049600000 + }, + { + "userId": "1000000004", + "username": "blox_trader", + "displayName": "trading", + "lastLoginAt": 1788840000000 + } + ], + "currentAccount": "1000000001" + } + } + ], + "platform_set_account_label": [ + { + "args": {}, + "result": null + } + ], + "reload_user_platforms": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "roblox_check_sessions": [ + { + "args": {}, + "result": [] + } + ], + "roblox_get_profile_info": [ + { + "args": { + "userId": "1000000001" + }, + "result": { + "avatarUrl": "src/lib/mock/avatars/18.svg" + } + }, + { + "args": { + "userId": "1000000002" + }, + "result": { + "avatarUrl": "src/lib/mock/avatars/19.svg" + } + }, + { + "args": { + "userId": "1000000003" + }, + "result": { + "avatarUrl": "src/lib/mock/avatars/20.svg" + } + }, + { + "args": { + "userId": "1000000004" + }, + "result": { + "avatarUrl": "src/lib/mock/avatars/21.svg" + } + } + ], + "save_client_storage_store": [ + { + "args": {}, + "result": null + } + ], + "steam_get_account_games": [ + { + "args": {}, + "result": [] + } + ], + "steam_get_copyable_games": [ + { + "args": {}, + "result": [] + } + ], + "steam_get_player_bans": [ + { + "args": { + "steamIds": [ + "76561198000000001", + "76561198000000002", + "76561198000000003", + "76561198000000004", + "76561198000000005", + "76561198000000006", + "76561198000000007", + "76561198000000008", + "76561198000000009", + "76561198000000010", + "76561198000000011", + "76561198000000012", + "76561198000000013", + "76561198000000014", + "76561198000000015", + "76561198000000016", + "76561198000000017" + ] + }, + "result": [ + { + "steam_id": "76561198000000001", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000002", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000003", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000004", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000005", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000006", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000007", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000008", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000009", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000010", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000011", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000012", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000013", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000014", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000015", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000016", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198000000017", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + } + ] + } + ], + "steam_get_profile_info": [ + { + "args": { + "accountId": "76561198000000001" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/1.svg", + "display_name": "main", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000002" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/2.svg", + "display_name": "bro's account", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000003" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/3.svg", + "display_name": "alt", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000004" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/4.svg", + "display_name": "trading", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000005" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/5.svg", + "display_name": "faceit", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000006" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/6.svg", + "display_name": "ranked grind", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000007" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/7.svg", + "display_name": "chill", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000008" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/8.svg", + "display_name": "lan party", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000009" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/9.svg", + "display_name": "streams", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000010" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/10.svg", + "display_name": "esea", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000011" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/11.svg", + "display_name": "tournaments", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000012" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/12.svg", + "display_name": "family", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000013" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/13.svg", + "display_name": "practice", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000014" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/14.svg", + "display_name": "smurf 1", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000015" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/15.svg", + "display_name": "smurf 2", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000016" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/16.svg", + "display_name": "smurf 3", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198000000017" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/17.svg", + "display_name": "smurf 4", + "vac_banned": false, + "trade_ban_state": "none" + } + } + ], + "steam_get_profile_infos": [ + { + "args": { + "accountIds": [ + "76561198000000001", + "76561198000000002", + "76561198000000003", + "76561198000000004", + "76561198000000005", + "76561198000000006", + "76561198000000007", + "76561198000000008", + "76561198000000009", + "76561198000000010", + "76561198000000011", + "76561198000000012", + "76561198000000013", + "76561198000000014", + "76561198000000015", + "76561198000000016", + "76561198000000017" + ] + }, + "result": { + "76561198000000001": { + "avatar_url": "src/lib/mock/avatars/1.svg", + "display_name": "main", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000002": { + "avatar_url": "src/lib/mock/avatars/2.svg", + "display_name": "bro's account", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000003": { + "avatar_url": "src/lib/mock/avatars/3.svg", + "display_name": "alt", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000004": { + "avatar_url": "src/lib/mock/avatars/4.svg", + "display_name": "trading", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000005": { + "avatar_url": "src/lib/mock/avatars/5.svg", + "display_name": "faceit", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000006": { + "avatar_url": "src/lib/mock/avatars/6.svg", + "display_name": "ranked grind", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000007": { + "avatar_url": "src/lib/mock/avatars/7.svg", + "display_name": "chill", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000008": { + "avatar_url": "src/lib/mock/avatars/8.svg", + "display_name": "lan party", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000009": { + "avatar_url": "src/lib/mock/avatars/9.svg", + "display_name": "streams", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000010": { + "avatar_url": "src/lib/mock/avatars/10.svg", + "display_name": "esea", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000011": { + "avatar_url": "src/lib/mock/avatars/11.svg", + "display_name": "tournaments", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000012": { + "avatar_url": "src/lib/mock/avatars/12.svg", + "display_name": "family", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000013": { + "avatar_url": "src/lib/mock/avatars/13.svg", + "display_name": "practice", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000014": { + "avatar_url": "src/lib/mock/avatars/14.svg", + "display_name": "smurf 1", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000015": { + "avatar_url": "src/lib/mock/avatars/15.svg", + "display_name": "smurf 2", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000016": { + "avatar_url": "src/lib/mock/avatars/16.svg", + "display_name": "smurf 3", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198000000017": { + "avatar_url": "src/lib/mock/avatars/17.svg", + "display_name": "smurf 4", + "vac_banned": false, + "trade_ban_state": "none" + } + } + } + ], + "steam_has_api_key": [ + { + "args": {}, + "result": true + } + ], + "steam_open_api_key_page": [ + { + "args": {}, + "result": null + } + ], + "steam_open_userdata": [ + { + "args": {}, + "result": null + } + ], + "telemetry_get_state": [ + { + "args": {}, + "result": { + "mode_a_enabled": false, + "mode_b_enabled": false, + "install_id_set": false, + "forget_pending": false, + "onboarding_completed": true + } + } + ] + }, + "skipped": { + "pin_lock": "PIN session state", + "pin_unlock": "PIN session state, reads the live settings store", + "platform_switch_account": "changes the current account after switchDelayMs; modelled from spec" + } +} diff --git a/crates/accshift-egui/parity/data/dev.json b/crates/accshift-egui/parity/data/dev.json new file mode 100644 index 0000000..e80c98b --- /dev/null +++ b/crates/accshift-egui/parity/data/dev.json @@ -0,0 +1,984 @@ +{ + "format": 1, + "scenario": "dev", + "now": 1791000000, + "spec": { + "label": "agent playground — edge cases, safe to change", + "runtimeOs": "windows", + "switchDelayMs": 900, + "steamPath": "C:\\Program Files (x86)\\Steam", + "hasSteamApiKey": true, + "currentSteamAccount": "76561198100000001", + "currentRiotProfile": "riot-1", + "currentRobloxAccount": "" + }, + "commands": { + "cs2_bridge_get_settings": [ + { + "args": {}, + "result": { + "enabled": false, + "url": "", + "apiKey": "" + } + } + ], + "descriptor_install_file": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "descriptor_preview_file": [ + { + "args": {}, + "error": "a mock session adds no platform" + } + ], + "descriptor_remove": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "descriptor_select_file": [ + { + "args": {}, + "error": "cancelled" + } + ], + "detect_streaming_software": [ + { + "args": {}, + "result": false + } + ], + "get_boot_payload": [ + { + "args": {}, + "result": { + "migration": "skipped", + "runtimeOs": "windows", + "storageSnapshot": { + "manifest": { + "schemaVersion": 1, + "stores": {} + }, + "stores": { + "client.settings": { + "language": "en", + "animations": "on", + "streamerMode": "off", + "inactivityBlurSeconds": 0, + "pinEnabled": false, + "enabledPlatforms": ["steam", "riot"], + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": true + } + }, + "client.folders": { + "version": 1, + "folders": [ + { + "id": "dev-folder-outer", + "name": "Outer folder", + "parentId": null, + "platform": "steam" + }, + { + "id": "dev-folder-inner", + "name": "Inner folder", + "parentId": "dev-folder-outer", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198100000001" + }, + { + "type": "account", + "id": "76561198100000002" + }, + { + "type": "account", + "id": "76561198100000003" + }, + { + "type": "account", + "id": "76561198100000004" + }, + { + "type": "account", + "id": "76561198100000005" + }, + { + "type": "account", + "id": "76561198100000006" + }, + { + "type": "folder", + "id": "dev-folder-outer" + } + ], + "dev-folder-outer": [ + { + "type": "account", + "id": "76561198100000007" + }, + { + "type": "folder", + "id": "dev-folder-inner" + } + ], + "dev-folder-inner": [ + { + "type": "account", + "id": "76561198100000008" + } + ] + } + }, + "client.personas": [], + "client.account-card-notes": { + "76561198100000001": "short note", + "76561198100000005": "a note that runs long enough to test wrapping and truncation on an inline card note" + }, + "client.account-card-colors": { + "76561198100000001": "#3b82f6", + "76561198100000003": "#f43f5e", + "76561198100000005": "#10b981" + }, + "client.account-default-game": {}, + "client.folder-card-colors": {}, + "client.view-mode": "grid", + "cache.steam.profiles": { + "76561198100000001": { + "url": "src/lib/mock/avatars/1.svg", + "displayName": "main", + "timestamp": 1791000000000 + }, + "76561198100000003": { + "url": "src/lib/mock/avatars/3.svg", + "displayName": "vac banned", + "timestamp": 1791000000000 + }, + "76561198100000004": { + "url": "src/lib/mock/avatars/4.svg", + "displayName": "trade banned", + "timestamp": 1791000000000 + }, + "76561198100000005": { + "url": "src/lib/mock/avatars/5.svg", + "displayName": "a persona name long enough to overflow every card layout", + "timestamp": 1791000000000 + }, + "76561198100000006": { + "url": "src/lib/mock/avatars/6.svg", + "displayName": "never logged in", + "timestamp": 1791000000000 + }, + "76561198100000007": { + "url": "src/lib/mock/avatars/7.svg", + "displayName": "nested account", + "timestamp": 1791000000000 + }, + "76561198100000008": { + "url": "src/lib/mock/avatars/2.svg", + "displayName": "deeply nested", + "timestamp": 1791000000000 + } + }, + "cache.roblox.profiles": {}, + "cache.steam.ban-check-state": {}, + "cache.steam.ban-info-cache": {} + } + }, + "customThemes": [], + "userPlatforms": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + } + ], + "get_desktop_wallpaper": [ + { + "args": {}, + "result": null + } + ], + "get_runtime_os": [ + { + "args": {}, + "result": "windows" + } + ], + "get_storage_manifest": [ + { + "args": {}, + "result": { + "schemaVersion": 1, + "stores": {} + } + } + ], + "list_custom_themes": [ + { + "args": {}, + "result": [] + } + ], + "load_client_storage_snapshot": [ + { + "args": {}, + "result": { + "manifest": { + "schemaVersion": 1, + "stores": {} + }, + "stores": { + "client.settings": { + "language": "en", + "animations": "on", + "streamerMode": "off", + "inactivityBlurSeconds": 0, + "pinEnabled": false, + "enabledPlatforms": ["steam", "riot"], + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": true + } + }, + "client.folders": { + "version": 1, + "folders": [ + { + "id": "dev-folder-outer", + "name": "Outer folder", + "parentId": null, + "platform": "steam" + }, + { + "id": "dev-folder-inner", + "name": "Inner folder", + "parentId": "dev-folder-outer", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198100000001" + }, + { + "type": "account", + "id": "76561198100000002" + }, + { + "type": "account", + "id": "76561198100000003" + }, + { + "type": "account", + "id": "76561198100000004" + }, + { + "type": "account", + "id": "76561198100000005" + }, + { + "type": "account", + "id": "76561198100000006" + }, + { + "type": "folder", + "id": "dev-folder-outer" + } + ], + "dev-folder-outer": [ + { + "type": "account", + "id": "76561198100000007" + }, + { + "type": "folder", + "id": "dev-folder-inner" + } + ], + "dev-folder-inner": [ + { + "type": "account", + "id": "76561198100000008" + } + ] + } + }, + "client.personas": [], + "client.account-card-notes": { + "76561198100000001": "short note", + "76561198100000005": "a note that runs long enough to test wrapping and truncation on an inline card note" + }, + "client.account-card-colors": { + "76561198100000001": "#3b82f6", + "76561198100000003": "#f43f5e", + "76561198100000005": "#10b981" + }, + "client.account-default-game": {}, + "client.folder-card-colors": {}, + "client.view-mode": "grid", + "cache.steam.profiles": { + "76561198100000001": { + "url": "src/lib/mock/avatars/1.svg", + "displayName": "main", + "timestamp": 1791000000000 + }, + "76561198100000003": { + "url": "src/lib/mock/avatars/3.svg", + "displayName": "vac banned", + "timestamp": 1791000000000 + }, + "76561198100000004": { + "url": "src/lib/mock/avatars/4.svg", + "displayName": "trade banned", + "timestamp": 1791000000000 + }, + "76561198100000005": { + "url": "src/lib/mock/avatars/5.svg", + "displayName": "a persona name long enough to overflow every card layout", + "timestamp": 1791000000000 + }, + "76561198100000006": { + "url": "src/lib/mock/avatars/6.svg", + "displayName": "never logged in", + "timestamp": 1791000000000 + }, + "76561198100000007": { + "url": "src/lib/mock/avatars/7.svg", + "displayName": "nested account", + "timestamp": 1791000000000 + }, + "76561198100000008": { + "url": "src/lib/mock/avatars/2.svg", + "displayName": "deeply nested", + "timestamp": 1791000000000 + } + }, + "cache.roblox.profiles": {}, + "cache.steam.ban-check-state": {}, + "cache.steam.ban-info-cache": {} + } + } + } + ], + "migrate_legacy_config": [ + { + "args": {}, + "result": "skipped" + } + ], + "open_descriptors_folder": [ + { + "args": {}, + "result": null + } + ], + "open_logs_folder": [ + { + "args": {}, + "result": null + } + ], + "open_url": [ + { + "args": {}, + "result": null + } + ], + "platform_detect_installed": [ + { + "args": {}, + "result": ["steam", "riot"] + } + ], + "platform_dry_run": [ + { + "args": { + "platformId": "steam", + "accountId": "76561198100000001" + }, + "result": { + "platformId": "steam", + "operation": "switch", + "accountId": "76561198100000001", + "applied": false, + "roots": [], + "steps": [], + "warnings": [] + } + } + ], + "platform_get_accounts": [ + { + "args": { + "platformId": "steam" + }, + "result": [ + { + "steam_id": "76561198100000001", + "account_name": "mainacct", + "persona_name": "main", + "last_login_at": 1790996400 + }, + { + "steam_id": "76561198100000002", + "account_name": "no_avatar_here", + "persona_name": "no avatar", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561198100000003", + "account_name": "banned_one", + "persona_name": "vac banned", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561198100000004", + "account_name": "trade_locked", + "persona_name": "trade banned", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561198100000005", + "account_name": "overflow_test_account_name", + "persona_name": "a persona name long enough to overflow every card layout", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561198100000006", + "account_name": "never_used", + "persona_name": "never logged in", + "last_login_at": null + }, + { + "steam_id": "76561198100000007", + "account_name": "nested_one", + "persona_name": "nested account", + "last_login_at": 1788408000 + }, + { + "steam_id": "76561198100000008", + "account_name": "nested_two", + "persona_name": "deeply nested", + "last_login_at": 1787198400 + } + ] + }, + { + "args": { + "platformId": "riot" + }, + "result": [ + { + "id": "riot-1", + "label": "ready", + "snapshot_state": "ready", + "last_used_at": 1790996400000 + }, + { + "id": "riot-2", + "label": "capturing", + "snapshot_state": "capturing", + "last_used_at": null + }, + { + "id": "riot-3", + "label": "awaiting", + "snapshot_state": "awaiting_capture", + "last_used_at": null + }, + { + "id": "riot-4", + "label": "setup pending", + "snapshot_state": "setup_pending", + "last_used_at": null + } + ] + }, + { + "args": { + "platformId": "roblox" + }, + "result": [] + } + ], + "platform_get_current_account": [ + { + "args": { + "platformId": "steam" + }, + "result": "76561198100000001" + }, + { + "args": { + "platformId": "riot" + }, + "result": "riot-1" + }, + { + "args": { + "platformId": "roblox" + }, + "result": "" + } + ], + "platform_get_path": [ + { + "args": {}, + "result": "C:\\Program Files (x86)\\Steam" + } + ], + "platform_get_startup_snapshot": [ + { + "args": { + "platformId": "steam" + }, + "result": { + "accounts": [ + { + "steam_id": "76561198100000001", + "account_name": "mainacct", + "persona_name": "main", + "last_login_at": 1790996400 + }, + { + "steam_id": "76561198100000002", + "account_name": "no_avatar_here", + "persona_name": "no avatar", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561198100000003", + "account_name": "banned_one", + "persona_name": "vac banned", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561198100000004", + "account_name": "trade_locked", + "persona_name": "trade banned", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561198100000005", + "account_name": "overflow_test_account_name", + "persona_name": "a persona name long enough to overflow every card layout", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561198100000006", + "account_name": "never_used", + "persona_name": "never logged in", + "last_login_at": null + }, + { + "steam_id": "76561198100000007", + "account_name": "nested_one", + "persona_name": "nested account", + "last_login_at": 1788408000 + }, + { + "steam_id": "76561198100000008", + "account_name": "nested_two", + "persona_name": "deeply nested", + "last_login_at": 1787198400 + } + ], + "currentAccount": "76561198100000001" + } + }, + { + "args": { + "platformId": "riot" + }, + "result": { + "profiles": [ + { + "id": "riot-1", + "label": "ready", + "snapshot_state": "ready", + "last_used_at": 1790996400000 + }, + { + "id": "riot-2", + "label": "capturing", + "snapshot_state": "capturing", + "last_used_at": null + }, + { + "id": "riot-3", + "label": "awaiting", + "snapshot_state": "awaiting_capture", + "last_used_at": null + }, + { + "id": "riot-4", + "label": "setup pending", + "snapshot_state": "setup_pending", + "last_used_at": null + } + ], + "currentProfile": "riot-1" + } + }, + { + "args": { + "platformId": "roblox" + }, + "result": { + "accounts": [], + "currentAccount": "" + } + } + ], + "platform_set_account_label": [ + { + "args": {}, + "result": null + } + ], + "reload_user_platforms": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "roblox_check_sessions": [ + { + "args": {}, + "result": [] + } + ], + "roblox_get_profile_info": [], + "save_client_storage_store": [ + { + "args": {}, + "result": null + } + ], + "steam_get_account_games": [ + { + "args": {}, + "result": [] + } + ], + "steam_get_copyable_games": [ + { + "args": {}, + "result": [] + } + ], + "steam_get_player_bans": [ + { + "args": { + "steamIds": [ + "76561198100000001", + "76561198100000002", + "76561198100000003", + "76561198100000004", + "76561198100000005", + "76561198100000006", + "76561198100000007", + "76561198100000008" + ] + }, + "result": [ + { + "steam_id": "76561198100000001", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198100000002", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198100000003", + "community_banned": false, + "vac_banned": true, + "number_of_vac_bans": 1, + "days_since_last_ban": 412, + "number_of_game_bans": 1, + "economy_ban": "none" + }, + { + "steam_id": "76561198100000004", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "banned" + }, + { + "steam_id": "76561198100000005", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198100000006", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198100000007", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198100000008", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + } + ] + } + ], + "steam_get_profile_info": [ + { + "args": { + "accountId": "76561198100000001" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/1.svg", + "display_name": "main", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198100000002" + }, + "result": { + "avatar_url": null, + "display_name": "no avatar", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198100000003" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/3.svg", + "display_name": "vac banned", + "vac_banned": true, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198100000004" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/4.svg", + "display_name": "trade banned", + "vac_banned": false, + "trade_ban_state": "banned" + } + }, + { + "args": { + "accountId": "76561198100000005" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/5.svg", + "display_name": "a persona name long enough to overflow every card layout", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198100000006" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/6.svg", + "display_name": "never logged in", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198100000007" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/7.svg", + "display_name": "nested account", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198100000008" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/2.svg", + "display_name": "deeply nested", + "vac_banned": false, + "trade_ban_state": "none" + } + } + ], + "steam_get_profile_infos": [ + { + "args": { + "accountIds": [ + "76561198100000001", + "76561198100000002", + "76561198100000003", + "76561198100000004", + "76561198100000005", + "76561198100000006", + "76561198100000007", + "76561198100000008" + ] + }, + "result": { + "76561198100000001": { + "avatar_url": "src/lib/mock/avatars/1.svg", + "display_name": "main", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198100000002": { + "avatar_url": null, + "display_name": "no avatar", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198100000003": { + "avatar_url": "src/lib/mock/avatars/3.svg", + "display_name": "vac banned", + "vac_banned": true, + "trade_ban_state": "none" + }, + "76561198100000004": { + "avatar_url": "src/lib/mock/avatars/4.svg", + "display_name": "trade banned", + "vac_banned": false, + "trade_ban_state": "banned" + }, + "76561198100000005": { + "avatar_url": "src/lib/mock/avatars/5.svg", + "display_name": "a persona name long enough to overflow every card layout", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198100000006": { + "avatar_url": "src/lib/mock/avatars/6.svg", + "display_name": "never logged in", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198100000007": { + "avatar_url": "src/lib/mock/avatars/7.svg", + "display_name": "nested account", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198100000008": { + "avatar_url": "src/lib/mock/avatars/2.svg", + "display_name": "deeply nested", + "vac_banned": false, + "trade_ban_state": "none" + } + } + } + ], + "steam_has_api_key": [ + { + "args": {}, + "result": true + } + ], + "steam_open_api_key_page": [ + { + "args": {}, + "result": null + } + ], + "steam_open_userdata": [ + { + "args": {}, + "result": null + } + ], + "telemetry_get_state": [ + { + "args": {}, + "result": { + "mode_a_enabled": false, + "mode_b_enabled": false, + "install_id_set": false, + "forget_pending": false, + "onboarding_completed": true + } + } + ] + }, + "skipped": { + "pin_lock": "PIN session state", + "pin_unlock": "PIN session state, reads the live settings store", + "platform_switch_account": "changes the current account after switchDelayMs; modelled from spec" + } +} diff --git a/crates/accshift-egui/parity/data/empty.json b/crates/accshift-egui/parity/data/empty.json new file mode 100644 index 0000000..22ffda8 --- /dev/null +++ b/crates/accshift-egui/parity/data/empty.json @@ -0,0 +1,377 @@ +{ + "format": 1, + "scenario": "empty", + "now": 1791000000, + "spec": { + "label": "no account anywhere — empty states and first-run UI", + "runtimeOs": "windows", + "switchDelayMs": 900, + "steamPath": "", + "hasSteamApiKey": false, + "currentSteamAccount": "", + "currentRiotProfile": "", + "currentRobloxAccount": "" + }, + "commands": { + "cs2_bridge_get_settings": [ + { + "args": {}, + "result": { + "enabled": false, + "url": "", + "apiKey": "" + } + } + ], + "descriptor_install_file": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "descriptor_preview_file": [ + { + "args": {}, + "error": "a mock session adds no platform" + } + ], + "descriptor_remove": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "descriptor_select_file": [ + { + "args": {}, + "error": "cancelled" + } + ], + "detect_streaming_software": [ + { + "args": {}, + "result": false + } + ], + "get_boot_payload": [ + { + "args": {}, + "result": { + "migration": "skipped", + "runtimeOs": "windows", + "storageSnapshot": { + "manifest": { + "schemaVersion": 1, + "stores": {} + }, + "stores": { + "client.settings": { + "language": "en", + "animations": "on", + "streamerMode": "off", + "inactivityBlurSeconds": 0, + "pinEnabled": false, + "enabledPlatforms": ["steam"] + }, + "client.folders": { + "version": 1, + "folders": [], + "itemOrder": {} + }, + "client.personas": [], + "client.account-card-notes": {}, + "client.account-card-colors": {}, + "client.account-default-game": {}, + "client.folder-card-colors": {}, + "client.view-mode": "grid", + "cache.steam.profiles": {}, + "cache.roblox.profiles": {}, + "cache.steam.ban-check-state": {}, + "cache.steam.ban-info-cache": {} + } + }, + "customThemes": [], + "userPlatforms": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + } + ], + "get_desktop_wallpaper": [ + { + "args": {}, + "result": null + } + ], + "get_runtime_os": [ + { + "args": {}, + "result": "windows" + } + ], + "get_storage_manifest": [ + { + "args": {}, + "result": { + "schemaVersion": 1, + "stores": {} + } + } + ], + "list_custom_themes": [ + { + "args": {}, + "result": [] + } + ], + "load_client_storage_snapshot": [ + { + "args": {}, + "result": { + "manifest": { + "schemaVersion": 1, + "stores": {} + }, + "stores": { + "client.settings": { + "language": "en", + "animations": "on", + "streamerMode": "off", + "inactivityBlurSeconds": 0, + "pinEnabled": false, + "enabledPlatforms": ["steam"] + }, + "client.folders": { + "version": 1, + "folders": [], + "itemOrder": {} + }, + "client.personas": [], + "client.account-card-notes": {}, + "client.account-card-colors": {}, + "client.account-default-game": {}, + "client.folder-card-colors": {}, + "client.view-mode": "grid", + "cache.steam.profiles": {}, + "cache.roblox.profiles": {}, + "cache.steam.ban-check-state": {}, + "cache.steam.ban-info-cache": {} + } + } + } + ], + "migrate_legacy_config": [ + { + "args": {}, + "result": "skipped" + } + ], + "open_descriptors_folder": [ + { + "args": {}, + "result": null + } + ], + "open_logs_folder": [ + { + "args": {}, + "result": null + } + ], + "open_url": [ + { + "args": {}, + "result": null + } + ], + "platform_detect_installed": [ + { + "args": {}, + "result": ["steam"] + } + ], + "platform_dry_run": [], + "platform_get_accounts": [ + { + "args": { + "platformId": "steam" + }, + "result": [] + }, + { + "args": { + "platformId": "riot" + }, + "result": [] + }, + { + "args": { + "platformId": "roblox" + }, + "result": [] + } + ], + "platform_get_current_account": [ + { + "args": { + "platformId": "steam" + }, + "result": "" + }, + { + "args": { + "platformId": "riot" + }, + "result": "" + }, + { + "args": { + "platformId": "roblox" + }, + "result": "" + } + ], + "platform_get_path": [ + { + "args": {}, + "result": "" + } + ], + "platform_get_startup_snapshot": [ + { + "args": { + "platformId": "steam" + }, + "result": { + "accounts": [], + "currentAccount": "" + } + }, + { + "args": { + "platformId": "riot" + }, + "result": { + "profiles": [], + "currentProfile": "" + } + }, + { + "args": { + "platformId": "roblox" + }, + "result": { + "accounts": [], + "currentAccount": "" + } + } + ], + "platform_set_account_label": [ + { + "args": {}, + "result": null + } + ], + "reload_user_platforms": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "roblox_check_sessions": [ + { + "args": {}, + "result": [] + } + ], + "roblox_get_profile_info": [], + "save_client_storage_store": [ + { + "args": {}, + "result": null + } + ], + "steam_get_account_games": [ + { + "args": {}, + "result": [] + } + ], + "steam_get_copyable_games": [ + { + "args": {}, + "result": [] + } + ], + "steam_get_player_bans": [ + { + "args": { + "steamIds": [] + }, + "result": [] + } + ], + "steam_get_profile_info": [], + "steam_get_profile_infos": [ + { + "args": { + "accountIds": [] + }, + "result": {} + } + ], + "steam_has_api_key": [ + { + "args": {}, + "result": false + } + ], + "steam_open_api_key_page": [ + { + "args": {}, + "result": null + } + ], + "steam_open_userdata": [ + { + "args": {}, + "result": null + } + ], + "telemetry_get_state": [ + { + "args": {}, + "result": { + "mode_a_enabled": false, + "mode_b_enabled": false, + "install_id_set": false, + "forget_pending": false, + "onboarding_completed": true + } + } + ] + }, + "skipped": { + "pin_lock": "PIN session state", + "pin_unlock": "PIN session state, reads the live settings store", + "platform_switch_account": "changes the current account after switchDelayMs; modelled from spec" + } +} diff --git a/crates/accshift-egui/parity/data/huge.json b/crates/accshift-egui/parity/data/huge.json new file mode 100644 index 0000000..3dd95ab --- /dev/null +++ b/crates/accshift-egui/parity/data/huge.json @@ -0,0 +1,10402 @@ +{ + "format": 1, + "scenario": "huge", + "now": 1791000000, + "spec": { + "label": "200 accounts — scroll, grid padding, virtualization", + "runtimeOs": "windows", + "switchDelayMs": 900, + "steamPath": "C:\\Program Files (x86)\\Steam", + "hasSteamApiKey": true, + "currentSteamAccount": "76561199000000000", + "currentRiotProfile": "", + "currentRobloxAccount": "" + }, + "commands": { + "cs2_bridge_get_settings": [ + { + "args": {}, + "result": { + "enabled": false, + "url": "", + "apiKey": "" + } + } + ], + "descriptor_install_file": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "descriptor_preview_file": [ + { + "args": {}, + "error": "a mock session adds no platform" + } + ], + "descriptor_remove": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "descriptor_select_file": [ + { + "args": {}, + "error": "cancelled" + } + ], + "detect_streaming_software": [ + { + "args": {}, + "result": false + } + ], + "get_boot_payload": [ + { + "args": {}, + "result": { + "migration": "skipped", + "runtimeOs": "windows", + "storageSnapshot": { + "manifest": { + "schemaVersion": 1, + "stores": {} + }, + "stores": { + "client.settings": { + "language": "en", + "animations": "on", + "streamerMode": "off", + "inactivityBlurSeconds": 0, + "pinEnabled": false, + "enabledPlatforms": ["steam"] + }, + "client.folders": { + "version": 1, + "folders": [], + "itemOrder": {} + }, + "client.personas": [], + "client.account-card-notes": {}, + "client.account-card-colors": {}, + "client.account-default-game": {}, + "client.folder-card-colors": {}, + "client.view-mode": "grid", + "cache.steam.profiles": { + "76561199000000000": { + "url": "src/lib/mock/avatars/1.svg", + "displayName": "account 1", + "timestamp": 1791000000000 + }, + "76561199000000001": { + "url": "src/lib/mock/avatars/2.svg", + "displayName": "account 2", + "timestamp": 1791000000000 + }, + "76561199000000002": { + "url": "src/lib/mock/avatars/3.svg", + "displayName": "account 3", + "timestamp": 1791000000000 + }, + "76561199000000003": { + "url": "src/lib/mock/avatars/4.svg", + "displayName": "account 4", + "timestamp": 1791000000000 + }, + "76561199000000004": { + "url": "src/lib/mock/avatars/5.svg", + "displayName": "account 5", + "timestamp": 1791000000000 + }, + "76561199000000005": { + "url": "src/lib/mock/avatars/6.svg", + "displayName": "account 6", + "timestamp": 1791000000000 + }, + "76561199000000006": { + "url": "src/lib/mock/avatars/7.svg", + "displayName": "account 7", + "timestamp": 1791000000000 + }, + "76561199000000007": { + "url": "src/lib/mock/avatars/8.svg", + "displayName": "account 8", + "timestamp": 1791000000000 + }, + "76561199000000008": { + "url": "src/lib/mock/avatars/9.svg", + "displayName": "account 9", + "timestamp": 1791000000000 + }, + "76561199000000009": { + "url": "src/lib/mock/avatars/10.svg", + "displayName": "account 10", + "timestamp": 1791000000000 + }, + "76561199000000010": { + "url": "src/lib/mock/avatars/11.svg", + "displayName": "account 11", + "timestamp": 1791000000000 + }, + "76561199000000011": { + "url": "src/lib/mock/avatars/12.svg", + "displayName": "account 12", + "timestamp": 1791000000000 + }, + "76561199000000012": { + "url": "src/lib/mock/avatars/13.svg", + "displayName": "account 13", + "timestamp": 1791000000000 + }, + "76561199000000013": { + "url": "src/lib/mock/avatars/14.svg", + "displayName": "account 14", + "timestamp": 1791000000000 + }, + "76561199000000014": { + "url": "src/lib/mock/avatars/15.svg", + "displayName": "account 15", + "timestamp": 1791000000000 + }, + "76561199000000015": { + "url": "src/lib/mock/avatars/16.svg", + "displayName": "account 16", + "timestamp": 1791000000000 + }, + "76561199000000016": { + "url": "src/lib/mock/avatars/17.svg", + "displayName": "account 17", + "timestamp": 1791000000000 + }, + "76561199000000017": { + "url": "src/lib/mock/avatars/18.svg", + "displayName": "account 18", + "timestamp": 1791000000000 + }, + "76561199000000018": { + "url": "src/lib/mock/avatars/19.svg", + "displayName": "account 19", + "timestamp": 1791000000000 + }, + "76561199000000019": { + "url": "src/lib/mock/avatars/20.svg", + "displayName": "account 20", + "timestamp": 1791000000000 + }, + "76561199000000020": { + "url": "src/lib/mock/avatars/21.svg", + "displayName": "account 21", + "timestamp": 1791000000000 + }, + "76561199000000021": { + "url": "src/lib/mock/avatars/1.svg", + "displayName": "account 22", + "timestamp": 1791000000000 + }, + "76561199000000022": { + "url": "src/lib/mock/avatars/2.svg", + "displayName": "account 23", + "timestamp": 1791000000000 + }, + "76561199000000023": { + "url": "src/lib/mock/avatars/3.svg", + "displayName": "account 24", + "timestamp": 1791000000000 + }, + "76561199000000024": { + "url": "src/lib/mock/avatars/4.svg", + "displayName": "account 25", + "timestamp": 1791000000000 + }, + "76561199000000025": { + "url": "src/lib/mock/avatars/5.svg", + "displayName": "account 26", + "timestamp": 1791000000000 + }, + "76561199000000026": { + "url": "src/lib/mock/avatars/6.svg", + "displayName": "account 27", + "timestamp": 1791000000000 + }, + "76561199000000027": { + "url": "src/lib/mock/avatars/7.svg", + "displayName": "account 28", + "timestamp": 1791000000000 + }, + "76561199000000028": { + "url": "src/lib/mock/avatars/8.svg", + "displayName": "account 29", + "timestamp": 1791000000000 + }, + "76561199000000029": { + "url": "src/lib/mock/avatars/9.svg", + "displayName": "account 30", + "timestamp": 1791000000000 + }, + "76561199000000030": { + "url": "src/lib/mock/avatars/10.svg", + "displayName": "account 31", + "timestamp": 1791000000000 + }, + "76561199000000031": { + "url": "src/lib/mock/avatars/11.svg", + "displayName": "account 32", + "timestamp": 1791000000000 + }, + "76561199000000032": { + "url": "src/lib/mock/avatars/12.svg", + "displayName": "account 33", + "timestamp": 1791000000000 + }, + "76561199000000033": { + "url": "src/lib/mock/avatars/13.svg", + "displayName": "account 34", + "timestamp": 1791000000000 + }, + "76561199000000034": { + "url": "src/lib/mock/avatars/14.svg", + "displayName": "account 35", + "timestamp": 1791000000000 + }, + "76561199000000035": { + "url": "src/lib/mock/avatars/15.svg", + "displayName": "account 36", + "timestamp": 1791000000000 + }, + "76561199000000036": { + "url": "src/lib/mock/avatars/16.svg", + "displayName": "account 37", + "timestamp": 1791000000000 + }, + "76561199000000037": { + "url": "src/lib/mock/avatars/17.svg", + "displayName": "account 38", + "timestamp": 1791000000000 + }, + "76561199000000038": { + "url": "src/lib/mock/avatars/18.svg", + "displayName": "account 39", + "timestamp": 1791000000000 + }, + "76561199000000039": { + "url": "src/lib/mock/avatars/19.svg", + "displayName": "account 40", + "timestamp": 1791000000000 + }, + "76561199000000040": { + "url": "src/lib/mock/avatars/20.svg", + "displayName": "account 41", + "timestamp": 1791000000000 + }, + "76561199000000041": { + "url": "src/lib/mock/avatars/21.svg", + "displayName": "account 42", + "timestamp": 1791000000000 + }, + "76561199000000042": { + "url": "src/lib/mock/avatars/1.svg", + "displayName": "account 43", + "timestamp": 1791000000000 + }, + "76561199000000043": { + "url": "src/lib/mock/avatars/2.svg", + "displayName": "account 44", + "timestamp": 1791000000000 + }, + "76561199000000044": { + "url": "src/lib/mock/avatars/3.svg", + "displayName": "account 45", + "timestamp": 1791000000000 + }, + "76561199000000045": { + "url": "src/lib/mock/avatars/4.svg", + "displayName": "account 46", + "timestamp": 1791000000000 + }, + "76561199000000046": { + "url": "src/lib/mock/avatars/5.svg", + "displayName": "account 47", + "timestamp": 1791000000000 + }, + "76561199000000047": { + "url": "src/lib/mock/avatars/6.svg", + "displayName": "account 48", + "timestamp": 1791000000000 + }, + "76561199000000048": { + "url": "src/lib/mock/avatars/7.svg", + "displayName": "account 49", + "timestamp": 1791000000000 + }, + "76561199000000049": { + "url": "src/lib/mock/avatars/8.svg", + "displayName": "account 50", + "timestamp": 1791000000000 + }, + "76561199000000050": { + "url": "src/lib/mock/avatars/9.svg", + "displayName": "account 51", + "timestamp": 1791000000000 + }, + "76561199000000051": { + "url": "src/lib/mock/avatars/10.svg", + "displayName": "account 52", + "timestamp": 1791000000000 + }, + "76561199000000052": { + "url": "src/lib/mock/avatars/11.svg", + "displayName": "account 53", + "timestamp": 1791000000000 + }, + "76561199000000053": { + "url": "src/lib/mock/avatars/12.svg", + "displayName": "account 54", + "timestamp": 1791000000000 + }, + "76561199000000054": { + "url": "src/lib/mock/avatars/13.svg", + "displayName": "account 55", + "timestamp": 1791000000000 + }, + "76561199000000055": { + "url": "src/lib/mock/avatars/14.svg", + "displayName": "account 56", + "timestamp": 1791000000000 + }, + "76561199000000056": { + "url": "src/lib/mock/avatars/15.svg", + "displayName": "account 57", + "timestamp": 1791000000000 + }, + "76561199000000057": { + "url": "src/lib/mock/avatars/16.svg", + "displayName": "account 58", + "timestamp": 1791000000000 + }, + "76561199000000058": { + "url": "src/lib/mock/avatars/17.svg", + "displayName": "account 59", + "timestamp": 1791000000000 + }, + "76561199000000059": { + "url": "src/lib/mock/avatars/18.svg", + "displayName": "account 60", + "timestamp": 1791000000000 + }, + "76561199000000060": { + "url": "src/lib/mock/avatars/19.svg", + "displayName": "account 61", + "timestamp": 1791000000000 + }, + "76561199000000061": { + "url": "src/lib/mock/avatars/20.svg", + "displayName": "account 62", + "timestamp": 1791000000000 + }, + "76561199000000062": { + "url": "src/lib/mock/avatars/21.svg", + "displayName": "account 63", + "timestamp": 1791000000000 + }, + "76561199000000063": { + "url": "src/lib/mock/avatars/1.svg", + "displayName": "account 64", + "timestamp": 1791000000000 + }, + "76561199000000064": { + "url": "src/lib/mock/avatars/2.svg", + "displayName": "account 65", + "timestamp": 1791000000000 + }, + "76561199000000065": { + "url": "src/lib/mock/avatars/3.svg", + "displayName": "account 66", + "timestamp": 1791000000000 + }, + "76561199000000066": { + "url": "src/lib/mock/avatars/4.svg", + "displayName": "account 67", + "timestamp": 1791000000000 + }, + "76561199000000067": { + "url": "src/lib/mock/avatars/5.svg", + "displayName": "account 68", + "timestamp": 1791000000000 + }, + "76561199000000068": { + "url": "src/lib/mock/avatars/6.svg", + "displayName": "account 69", + "timestamp": 1791000000000 + }, + "76561199000000069": { + "url": "src/lib/mock/avatars/7.svg", + "displayName": "account 70", + "timestamp": 1791000000000 + }, + "76561199000000070": { + "url": "src/lib/mock/avatars/8.svg", + "displayName": "account 71", + "timestamp": 1791000000000 + }, + "76561199000000071": { + "url": "src/lib/mock/avatars/9.svg", + "displayName": "account 72", + "timestamp": 1791000000000 + }, + "76561199000000072": { + "url": "src/lib/mock/avatars/10.svg", + "displayName": "account 73", + "timestamp": 1791000000000 + }, + "76561199000000073": { + "url": "src/lib/mock/avatars/11.svg", + "displayName": "account 74", + "timestamp": 1791000000000 + }, + "76561199000000074": { + "url": "src/lib/mock/avatars/12.svg", + "displayName": "account 75", + "timestamp": 1791000000000 + }, + "76561199000000075": { + "url": "src/lib/mock/avatars/13.svg", + "displayName": "account 76", + "timestamp": 1791000000000 + }, + "76561199000000076": { + "url": "src/lib/mock/avatars/14.svg", + "displayName": "account 77", + "timestamp": 1791000000000 + }, + "76561199000000077": { + "url": "src/lib/mock/avatars/15.svg", + "displayName": "account 78", + "timestamp": 1791000000000 + }, + "76561199000000078": { + "url": "src/lib/mock/avatars/16.svg", + "displayName": "account 79", + "timestamp": 1791000000000 + }, + "76561199000000079": { + "url": "src/lib/mock/avatars/17.svg", + "displayName": "account 80", + "timestamp": 1791000000000 + }, + "76561199000000080": { + "url": "src/lib/mock/avatars/18.svg", + "displayName": "account 81", + "timestamp": 1791000000000 + }, + "76561199000000081": { + "url": "src/lib/mock/avatars/19.svg", + "displayName": "account 82", + "timestamp": 1791000000000 + }, + "76561199000000082": { + "url": "src/lib/mock/avatars/20.svg", + "displayName": "account 83", + "timestamp": 1791000000000 + }, + "76561199000000083": { + "url": "src/lib/mock/avatars/21.svg", + "displayName": "account 84", + "timestamp": 1791000000000 + }, + "76561199000000084": { + "url": "src/lib/mock/avatars/1.svg", + "displayName": "account 85", + "timestamp": 1791000000000 + }, + "76561199000000085": { + "url": "src/lib/mock/avatars/2.svg", + "displayName": "account 86", + "timestamp": 1791000000000 + }, + "76561199000000086": { + "url": "src/lib/mock/avatars/3.svg", + "displayName": "account 87", + "timestamp": 1791000000000 + }, + "76561199000000087": { + "url": "src/lib/mock/avatars/4.svg", + "displayName": "account 88", + "timestamp": 1791000000000 + }, + "76561199000000088": { + "url": "src/lib/mock/avatars/5.svg", + "displayName": "account 89", + "timestamp": 1791000000000 + }, + "76561199000000089": { + "url": "src/lib/mock/avatars/6.svg", + "displayName": "account 90", + "timestamp": 1791000000000 + }, + "76561199000000090": { + "url": "src/lib/mock/avatars/7.svg", + "displayName": "account 91", + "timestamp": 1791000000000 + }, + "76561199000000091": { + "url": "src/lib/mock/avatars/8.svg", + "displayName": "account 92", + "timestamp": 1791000000000 + }, + "76561199000000092": { + "url": "src/lib/mock/avatars/9.svg", + "displayName": "account 93", + "timestamp": 1791000000000 + }, + "76561199000000093": { + "url": "src/lib/mock/avatars/10.svg", + "displayName": "account 94", + "timestamp": 1791000000000 + }, + "76561199000000094": { + "url": "src/lib/mock/avatars/11.svg", + "displayName": "account 95", + "timestamp": 1791000000000 + }, + "76561199000000095": { + "url": "src/lib/mock/avatars/12.svg", + "displayName": "account 96", + "timestamp": 1791000000000 + }, + "76561199000000096": { + "url": "src/lib/mock/avatars/13.svg", + "displayName": "account 97", + "timestamp": 1791000000000 + }, + "76561199000000097": { + "url": "src/lib/mock/avatars/14.svg", + "displayName": "account 98", + "timestamp": 1791000000000 + }, + "76561199000000098": { + "url": "src/lib/mock/avatars/15.svg", + "displayName": "account 99", + "timestamp": 1791000000000 + }, + "76561199000000099": { + "url": "src/lib/mock/avatars/16.svg", + "displayName": "account 100", + "timestamp": 1791000000000 + }, + "76561199000000100": { + "url": "src/lib/mock/avatars/17.svg", + "displayName": "account 101", + "timestamp": 1791000000000 + }, + "76561199000000101": { + "url": "src/lib/mock/avatars/18.svg", + "displayName": "account 102", + "timestamp": 1791000000000 + }, + "76561199000000102": { + "url": "src/lib/mock/avatars/19.svg", + "displayName": "account 103", + "timestamp": 1791000000000 + }, + "76561199000000103": { + "url": "src/lib/mock/avatars/20.svg", + "displayName": "account 104", + "timestamp": 1791000000000 + }, + "76561199000000104": { + "url": "src/lib/mock/avatars/21.svg", + "displayName": "account 105", + "timestamp": 1791000000000 + }, + "76561199000000105": { + "url": "src/lib/mock/avatars/1.svg", + "displayName": "account 106", + "timestamp": 1791000000000 + }, + "76561199000000106": { + "url": "src/lib/mock/avatars/2.svg", + "displayName": "account 107", + "timestamp": 1791000000000 + }, + "76561199000000107": { + "url": "src/lib/mock/avatars/3.svg", + "displayName": "account 108", + "timestamp": 1791000000000 + }, + "76561199000000108": { + "url": "src/lib/mock/avatars/4.svg", + "displayName": "account 109", + "timestamp": 1791000000000 + }, + "76561199000000109": { + "url": "src/lib/mock/avatars/5.svg", + "displayName": "account 110", + "timestamp": 1791000000000 + }, + "76561199000000110": { + "url": "src/lib/mock/avatars/6.svg", + "displayName": "account 111", + "timestamp": 1791000000000 + }, + "76561199000000111": { + "url": "src/lib/mock/avatars/7.svg", + "displayName": "account 112", + "timestamp": 1791000000000 + }, + "76561199000000112": { + "url": "src/lib/mock/avatars/8.svg", + "displayName": "account 113", + "timestamp": 1791000000000 + }, + "76561199000000113": { + "url": "src/lib/mock/avatars/9.svg", + "displayName": "account 114", + "timestamp": 1791000000000 + }, + "76561199000000114": { + "url": "src/lib/mock/avatars/10.svg", + "displayName": "account 115", + "timestamp": 1791000000000 + }, + "76561199000000115": { + "url": "src/lib/mock/avatars/11.svg", + "displayName": "account 116", + "timestamp": 1791000000000 + }, + "76561199000000116": { + "url": "src/lib/mock/avatars/12.svg", + "displayName": "account 117", + "timestamp": 1791000000000 + }, + "76561199000000117": { + "url": "src/lib/mock/avatars/13.svg", + "displayName": "account 118", + "timestamp": 1791000000000 + }, + "76561199000000118": { + "url": "src/lib/mock/avatars/14.svg", + "displayName": "account 119", + "timestamp": 1791000000000 + }, + "76561199000000119": { + "url": "src/lib/mock/avatars/15.svg", + "displayName": "account 120", + "timestamp": 1791000000000 + }, + "76561199000000120": { + "url": "src/lib/mock/avatars/16.svg", + "displayName": "account 121", + "timestamp": 1791000000000 + }, + "76561199000000121": { + "url": "src/lib/mock/avatars/17.svg", + "displayName": "account 122", + "timestamp": 1791000000000 + }, + "76561199000000122": { + "url": "src/lib/mock/avatars/18.svg", + "displayName": "account 123", + "timestamp": 1791000000000 + }, + "76561199000000123": { + "url": "src/lib/mock/avatars/19.svg", + "displayName": "account 124", + "timestamp": 1791000000000 + }, + "76561199000000124": { + "url": "src/lib/mock/avatars/20.svg", + "displayName": "account 125", + "timestamp": 1791000000000 + }, + "76561199000000125": { + "url": "src/lib/mock/avatars/21.svg", + "displayName": "account 126", + "timestamp": 1791000000000 + }, + "76561199000000126": { + "url": "src/lib/mock/avatars/1.svg", + "displayName": "account 127", + "timestamp": 1791000000000 + }, + "76561199000000127": { + "url": "src/lib/mock/avatars/2.svg", + "displayName": "account 128", + "timestamp": 1791000000000 + }, + "76561199000000128": { + "url": "src/lib/mock/avatars/3.svg", + "displayName": "account 129", + "timestamp": 1791000000000 + }, + "76561199000000129": { + "url": "src/lib/mock/avatars/4.svg", + "displayName": "account 130", + "timestamp": 1791000000000 + }, + "76561199000000130": { + "url": "src/lib/mock/avatars/5.svg", + "displayName": "account 131", + "timestamp": 1791000000000 + }, + "76561199000000131": { + "url": "src/lib/mock/avatars/6.svg", + "displayName": "account 132", + "timestamp": 1791000000000 + }, + "76561199000000132": { + "url": "src/lib/mock/avatars/7.svg", + "displayName": "account 133", + "timestamp": 1791000000000 + }, + "76561199000000133": { + "url": "src/lib/mock/avatars/8.svg", + "displayName": "account 134", + "timestamp": 1791000000000 + }, + "76561199000000134": { + "url": "src/lib/mock/avatars/9.svg", + "displayName": "account 135", + "timestamp": 1791000000000 + }, + "76561199000000135": { + "url": "src/lib/mock/avatars/10.svg", + "displayName": "account 136", + "timestamp": 1791000000000 + }, + "76561199000000136": { + "url": "src/lib/mock/avatars/11.svg", + "displayName": "account 137", + "timestamp": 1791000000000 + }, + "76561199000000137": { + "url": "src/lib/mock/avatars/12.svg", + "displayName": "account 138", + "timestamp": 1791000000000 + }, + "76561199000000138": { + "url": "src/lib/mock/avatars/13.svg", + "displayName": "account 139", + "timestamp": 1791000000000 + }, + "76561199000000139": { + "url": "src/lib/mock/avatars/14.svg", + "displayName": "account 140", + "timestamp": 1791000000000 + }, + "76561199000000140": { + "url": "src/lib/mock/avatars/15.svg", + "displayName": "account 141", + "timestamp": 1791000000000 + }, + "76561199000000141": { + "url": "src/lib/mock/avatars/16.svg", + "displayName": "account 142", + "timestamp": 1791000000000 + }, + "76561199000000142": { + "url": "src/lib/mock/avatars/17.svg", + "displayName": "account 143", + "timestamp": 1791000000000 + }, + "76561199000000143": { + "url": "src/lib/mock/avatars/18.svg", + "displayName": "account 144", + "timestamp": 1791000000000 + }, + "76561199000000144": { + "url": "src/lib/mock/avatars/19.svg", + "displayName": "account 145", + "timestamp": 1791000000000 + }, + "76561199000000145": { + "url": "src/lib/mock/avatars/20.svg", + "displayName": "account 146", + "timestamp": 1791000000000 + }, + "76561199000000146": { + "url": "src/lib/mock/avatars/21.svg", + "displayName": "account 147", + "timestamp": 1791000000000 + }, + "76561199000000147": { + "url": "src/lib/mock/avatars/1.svg", + "displayName": "account 148", + "timestamp": 1791000000000 + }, + "76561199000000148": { + "url": "src/lib/mock/avatars/2.svg", + "displayName": "account 149", + "timestamp": 1791000000000 + }, + "76561199000000149": { + "url": "src/lib/mock/avatars/3.svg", + "displayName": "account 150", + "timestamp": 1791000000000 + }, + "76561199000000150": { + "url": "src/lib/mock/avatars/4.svg", + "displayName": "account 151", + "timestamp": 1791000000000 + }, + "76561199000000151": { + "url": "src/lib/mock/avatars/5.svg", + "displayName": "account 152", + "timestamp": 1791000000000 + }, + "76561199000000152": { + "url": "src/lib/mock/avatars/6.svg", + "displayName": "account 153", + "timestamp": 1791000000000 + }, + "76561199000000153": { + "url": "src/lib/mock/avatars/7.svg", + "displayName": "account 154", + "timestamp": 1791000000000 + }, + "76561199000000154": { + "url": "src/lib/mock/avatars/8.svg", + "displayName": "account 155", + "timestamp": 1791000000000 + }, + "76561199000000155": { + "url": "src/lib/mock/avatars/9.svg", + "displayName": "account 156", + "timestamp": 1791000000000 + }, + "76561199000000156": { + "url": "src/lib/mock/avatars/10.svg", + "displayName": "account 157", + "timestamp": 1791000000000 + }, + "76561199000000157": { + "url": "src/lib/mock/avatars/11.svg", + "displayName": "account 158", + "timestamp": 1791000000000 + }, + "76561199000000158": { + "url": "src/lib/mock/avatars/12.svg", + "displayName": "account 159", + "timestamp": 1791000000000 + }, + "76561199000000159": { + "url": "src/lib/mock/avatars/13.svg", + "displayName": "account 160", + "timestamp": 1791000000000 + }, + "76561199000000160": { + "url": "src/lib/mock/avatars/14.svg", + "displayName": "account 161", + "timestamp": 1791000000000 + }, + "76561199000000161": { + "url": "src/lib/mock/avatars/15.svg", + "displayName": "account 162", + "timestamp": 1791000000000 + }, + "76561199000000162": { + "url": "src/lib/mock/avatars/16.svg", + "displayName": "account 163", + "timestamp": 1791000000000 + }, + "76561199000000163": { + "url": "src/lib/mock/avatars/17.svg", + "displayName": "account 164", + "timestamp": 1791000000000 + }, + "76561199000000164": { + "url": "src/lib/mock/avatars/18.svg", + "displayName": "account 165", + "timestamp": 1791000000000 + }, + "76561199000000165": { + "url": "src/lib/mock/avatars/19.svg", + "displayName": "account 166", + "timestamp": 1791000000000 + }, + "76561199000000166": { + "url": "src/lib/mock/avatars/20.svg", + "displayName": "account 167", + "timestamp": 1791000000000 + }, + "76561199000000167": { + "url": "src/lib/mock/avatars/21.svg", + "displayName": "account 168", + "timestamp": 1791000000000 + }, + "76561199000000168": { + "url": "src/lib/mock/avatars/1.svg", + "displayName": "account 169", + "timestamp": 1791000000000 + }, + "76561199000000169": { + "url": "src/lib/mock/avatars/2.svg", + "displayName": "account 170", + "timestamp": 1791000000000 + }, + "76561199000000170": { + "url": "src/lib/mock/avatars/3.svg", + "displayName": "account 171", + "timestamp": 1791000000000 + }, + "76561199000000171": { + "url": "src/lib/mock/avatars/4.svg", + "displayName": "account 172", + "timestamp": 1791000000000 + }, + "76561199000000172": { + "url": "src/lib/mock/avatars/5.svg", + "displayName": "account 173", + "timestamp": 1791000000000 + }, + "76561199000000173": { + "url": "src/lib/mock/avatars/6.svg", + "displayName": "account 174", + "timestamp": 1791000000000 + }, + "76561199000000174": { + "url": "src/lib/mock/avatars/7.svg", + "displayName": "account 175", + "timestamp": 1791000000000 + }, + "76561199000000175": { + "url": "src/lib/mock/avatars/8.svg", + "displayName": "account 176", + "timestamp": 1791000000000 + }, + "76561199000000176": { + "url": "src/lib/mock/avatars/9.svg", + "displayName": "account 177", + "timestamp": 1791000000000 + }, + "76561199000000177": { + "url": "src/lib/mock/avatars/10.svg", + "displayName": "account 178", + "timestamp": 1791000000000 + }, + "76561199000000178": { + "url": "src/lib/mock/avatars/11.svg", + "displayName": "account 179", + "timestamp": 1791000000000 + }, + "76561199000000179": { + "url": "src/lib/mock/avatars/12.svg", + "displayName": "account 180", + "timestamp": 1791000000000 + }, + "76561199000000180": { + "url": "src/lib/mock/avatars/13.svg", + "displayName": "account 181", + "timestamp": 1791000000000 + }, + "76561199000000181": { + "url": "src/lib/mock/avatars/14.svg", + "displayName": "account 182", + "timestamp": 1791000000000 + }, + "76561199000000182": { + "url": "src/lib/mock/avatars/15.svg", + "displayName": "account 183", + "timestamp": 1791000000000 + }, + "76561199000000183": { + "url": "src/lib/mock/avatars/16.svg", + "displayName": "account 184", + "timestamp": 1791000000000 + }, + "76561199000000184": { + "url": "src/lib/mock/avatars/17.svg", + "displayName": "account 185", + "timestamp": 1791000000000 + }, + "76561199000000185": { + "url": "src/lib/mock/avatars/18.svg", + "displayName": "account 186", + "timestamp": 1791000000000 + }, + "76561199000000186": { + "url": "src/lib/mock/avatars/19.svg", + "displayName": "account 187", + "timestamp": 1791000000000 + }, + "76561199000000187": { + "url": "src/lib/mock/avatars/20.svg", + "displayName": "account 188", + "timestamp": 1791000000000 + }, + "76561199000000188": { + "url": "src/lib/mock/avatars/21.svg", + "displayName": "account 189", + "timestamp": 1791000000000 + }, + "76561199000000189": { + "url": "src/lib/mock/avatars/1.svg", + "displayName": "account 190", + "timestamp": 1791000000000 + }, + "76561199000000190": { + "url": "src/lib/mock/avatars/2.svg", + "displayName": "account 191", + "timestamp": 1791000000000 + }, + "76561199000000191": { + "url": "src/lib/mock/avatars/3.svg", + "displayName": "account 192", + "timestamp": 1791000000000 + }, + "76561199000000192": { + "url": "src/lib/mock/avatars/4.svg", + "displayName": "account 193", + "timestamp": 1791000000000 + }, + "76561199000000193": { + "url": "src/lib/mock/avatars/5.svg", + "displayName": "account 194", + "timestamp": 1791000000000 + }, + "76561199000000194": { + "url": "src/lib/mock/avatars/6.svg", + "displayName": "account 195", + "timestamp": 1791000000000 + }, + "76561199000000195": { + "url": "src/lib/mock/avatars/7.svg", + "displayName": "account 196", + "timestamp": 1791000000000 + }, + "76561199000000196": { + "url": "src/lib/mock/avatars/8.svg", + "displayName": "account 197", + "timestamp": 1791000000000 + }, + "76561199000000197": { + "url": "src/lib/mock/avatars/9.svg", + "displayName": "account 198", + "timestamp": 1791000000000 + }, + "76561199000000198": { + "url": "src/lib/mock/avatars/10.svg", + "displayName": "account 199", + "timestamp": 1791000000000 + }, + "76561199000000199": { + "url": "src/lib/mock/avatars/11.svg", + "displayName": "account 200", + "timestamp": 1791000000000 + } + }, + "cache.roblox.profiles": {}, + "cache.steam.ban-check-state": {}, + "cache.steam.ban-info-cache": {} + } + }, + "customThemes": [], + "userPlatforms": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + } + ], + "get_desktop_wallpaper": [ + { + "args": {}, + "result": null + } + ], + "get_runtime_os": [ + { + "args": {}, + "result": "windows" + } + ], + "get_storage_manifest": [ + { + "args": {}, + "result": { + "schemaVersion": 1, + "stores": {} + } + } + ], + "list_custom_themes": [ + { + "args": {}, + "result": [] + } + ], + "load_client_storage_snapshot": [ + { + "args": {}, + "result": { + "manifest": { + "schemaVersion": 1, + "stores": {} + }, + "stores": { + "client.settings": { + "language": "en", + "animations": "on", + "streamerMode": "off", + "inactivityBlurSeconds": 0, + "pinEnabled": false, + "enabledPlatforms": ["steam"] + }, + "client.folders": { + "version": 1, + "folders": [], + "itemOrder": {} + }, + "client.personas": [], + "client.account-card-notes": {}, + "client.account-card-colors": {}, + "client.account-default-game": {}, + "client.folder-card-colors": {}, + "client.view-mode": "grid", + "cache.steam.profiles": { + "76561199000000000": { + "url": "src/lib/mock/avatars/1.svg", + "displayName": "account 1", + "timestamp": 1791000000000 + }, + "76561199000000001": { + "url": "src/lib/mock/avatars/2.svg", + "displayName": "account 2", + "timestamp": 1791000000000 + }, + "76561199000000002": { + "url": "src/lib/mock/avatars/3.svg", + "displayName": "account 3", + "timestamp": 1791000000000 + }, + "76561199000000003": { + "url": "src/lib/mock/avatars/4.svg", + "displayName": "account 4", + "timestamp": 1791000000000 + }, + "76561199000000004": { + "url": "src/lib/mock/avatars/5.svg", + "displayName": "account 5", + "timestamp": 1791000000000 + }, + "76561199000000005": { + "url": "src/lib/mock/avatars/6.svg", + "displayName": "account 6", + "timestamp": 1791000000000 + }, + "76561199000000006": { + "url": "src/lib/mock/avatars/7.svg", + "displayName": "account 7", + "timestamp": 1791000000000 + }, + "76561199000000007": { + "url": "src/lib/mock/avatars/8.svg", + "displayName": "account 8", + "timestamp": 1791000000000 + }, + "76561199000000008": { + "url": "src/lib/mock/avatars/9.svg", + "displayName": "account 9", + "timestamp": 1791000000000 + }, + "76561199000000009": { + "url": "src/lib/mock/avatars/10.svg", + "displayName": "account 10", + "timestamp": 1791000000000 + }, + "76561199000000010": { + "url": "src/lib/mock/avatars/11.svg", + "displayName": "account 11", + "timestamp": 1791000000000 + }, + "76561199000000011": { + "url": "src/lib/mock/avatars/12.svg", + "displayName": "account 12", + "timestamp": 1791000000000 + }, + "76561199000000012": { + "url": "src/lib/mock/avatars/13.svg", + "displayName": "account 13", + "timestamp": 1791000000000 + }, + "76561199000000013": { + "url": "src/lib/mock/avatars/14.svg", + "displayName": "account 14", + "timestamp": 1791000000000 + }, + "76561199000000014": { + "url": "src/lib/mock/avatars/15.svg", + "displayName": "account 15", + "timestamp": 1791000000000 + }, + "76561199000000015": { + "url": "src/lib/mock/avatars/16.svg", + "displayName": "account 16", + "timestamp": 1791000000000 + }, + "76561199000000016": { + "url": "src/lib/mock/avatars/17.svg", + "displayName": "account 17", + "timestamp": 1791000000000 + }, + "76561199000000017": { + "url": "src/lib/mock/avatars/18.svg", + "displayName": "account 18", + "timestamp": 1791000000000 + }, + "76561199000000018": { + "url": "src/lib/mock/avatars/19.svg", + "displayName": "account 19", + "timestamp": 1791000000000 + }, + "76561199000000019": { + "url": "src/lib/mock/avatars/20.svg", + "displayName": "account 20", + "timestamp": 1791000000000 + }, + "76561199000000020": { + "url": "src/lib/mock/avatars/21.svg", + "displayName": "account 21", + "timestamp": 1791000000000 + }, + "76561199000000021": { + "url": "src/lib/mock/avatars/1.svg", + "displayName": "account 22", + "timestamp": 1791000000000 + }, + "76561199000000022": { + "url": "src/lib/mock/avatars/2.svg", + "displayName": "account 23", + "timestamp": 1791000000000 + }, + "76561199000000023": { + "url": "src/lib/mock/avatars/3.svg", + "displayName": "account 24", + "timestamp": 1791000000000 + }, + "76561199000000024": { + "url": "src/lib/mock/avatars/4.svg", + "displayName": "account 25", + "timestamp": 1791000000000 + }, + "76561199000000025": { + "url": "src/lib/mock/avatars/5.svg", + "displayName": "account 26", + "timestamp": 1791000000000 + }, + "76561199000000026": { + "url": "src/lib/mock/avatars/6.svg", + "displayName": "account 27", + "timestamp": 1791000000000 + }, + "76561199000000027": { + "url": "src/lib/mock/avatars/7.svg", + "displayName": "account 28", + "timestamp": 1791000000000 + }, + "76561199000000028": { + "url": "src/lib/mock/avatars/8.svg", + "displayName": "account 29", + "timestamp": 1791000000000 + }, + "76561199000000029": { + "url": "src/lib/mock/avatars/9.svg", + "displayName": "account 30", + "timestamp": 1791000000000 + }, + "76561199000000030": { + "url": "src/lib/mock/avatars/10.svg", + "displayName": "account 31", + "timestamp": 1791000000000 + }, + "76561199000000031": { + "url": "src/lib/mock/avatars/11.svg", + "displayName": "account 32", + "timestamp": 1791000000000 + }, + "76561199000000032": { + "url": "src/lib/mock/avatars/12.svg", + "displayName": "account 33", + "timestamp": 1791000000000 + }, + "76561199000000033": { + "url": "src/lib/mock/avatars/13.svg", + "displayName": "account 34", + "timestamp": 1791000000000 + }, + "76561199000000034": { + "url": "src/lib/mock/avatars/14.svg", + "displayName": "account 35", + "timestamp": 1791000000000 + }, + "76561199000000035": { + "url": "src/lib/mock/avatars/15.svg", + "displayName": "account 36", + "timestamp": 1791000000000 + }, + "76561199000000036": { + "url": "src/lib/mock/avatars/16.svg", + "displayName": "account 37", + "timestamp": 1791000000000 + }, + "76561199000000037": { + "url": "src/lib/mock/avatars/17.svg", + "displayName": "account 38", + "timestamp": 1791000000000 + }, + "76561199000000038": { + "url": "src/lib/mock/avatars/18.svg", + "displayName": "account 39", + "timestamp": 1791000000000 + }, + "76561199000000039": { + "url": "src/lib/mock/avatars/19.svg", + "displayName": "account 40", + "timestamp": 1791000000000 + }, + "76561199000000040": { + "url": "src/lib/mock/avatars/20.svg", + "displayName": "account 41", + "timestamp": 1791000000000 + }, + "76561199000000041": { + "url": "src/lib/mock/avatars/21.svg", + "displayName": "account 42", + "timestamp": 1791000000000 + }, + "76561199000000042": { + "url": "src/lib/mock/avatars/1.svg", + "displayName": "account 43", + "timestamp": 1791000000000 + }, + "76561199000000043": { + "url": "src/lib/mock/avatars/2.svg", + "displayName": "account 44", + "timestamp": 1791000000000 + }, + "76561199000000044": { + "url": "src/lib/mock/avatars/3.svg", + "displayName": "account 45", + "timestamp": 1791000000000 + }, + "76561199000000045": { + "url": "src/lib/mock/avatars/4.svg", + "displayName": "account 46", + "timestamp": 1791000000000 + }, + "76561199000000046": { + "url": "src/lib/mock/avatars/5.svg", + "displayName": "account 47", + "timestamp": 1791000000000 + }, + "76561199000000047": { + "url": "src/lib/mock/avatars/6.svg", + "displayName": "account 48", + "timestamp": 1791000000000 + }, + "76561199000000048": { + "url": "src/lib/mock/avatars/7.svg", + "displayName": "account 49", + "timestamp": 1791000000000 + }, + "76561199000000049": { + "url": "src/lib/mock/avatars/8.svg", + "displayName": "account 50", + "timestamp": 1791000000000 + }, + "76561199000000050": { + "url": "src/lib/mock/avatars/9.svg", + "displayName": "account 51", + "timestamp": 1791000000000 + }, + "76561199000000051": { + "url": "src/lib/mock/avatars/10.svg", + "displayName": "account 52", + "timestamp": 1791000000000 + }, + "76561199000000052": { + "url": "src/lib/mock/avatars/11.svg", + "displayName": "account 53", + "timestamp": 1791000000000 + }, + "76561199000000053": { + "url": "src/lib/mock/avatars/12.svg", + "displayName": "account 54", + "timestamp": 1791000000000 + }, + "76561199000000054": { + "url": "src/lib/mock/avatars/13.svg", + "displayName": "account 55", + "timestamp": 1791000000000 + }, + "76561199000000055": { + "url": "src/lib/mock/avatars/14.svg", + "displayName": "account 56", + "timestamp": 1791000000000 + }, + "76561199000000056": { + "url": "src/lib/mock/avatars/15.svg", + "displayName": "account 57", + "timestamp": 1791000000000 + }, + "76561199000000057": { + "url": "src/lib/mock/avatars/16.svg", + "displayName": "account 58", + "timestamp": 1791000000000 + }, + "76561199000000058": { + "url": "src/lib/mock/avatars/17.svg", + "displayName": "account 59", + "timestamp": 1791000000000 + }, + "76561199000000059": { + "url": "src/lib/mock/avatars/18.svg", + "displayName": "account 60", + "timestamp": 1791000000000 + }, + "76561199000000060": { + "url": "src/lib/mock/avatars/19.svg", + "displayName": "account 61", + "timestamp": 1791000000000 + }, + "76561199000000061": { + "url": "src/lib/mock/avatars/20.svg", + "displayName": "account 62", + "timestamp": 1791000000000 + }, + "76561199000000062": { + "url": "src/lib/mock/avatars/21.svg", + "displayName": "account 63", + "timestamp": 1791000000000 + }, + "76561199000000063": { + "url": "src/lib/mock/avatars/1.svg", + "displayName": "account 64", + "timestamp": 1791000000000 + }, + "76561199000000064": { + "url": "src/lib/mock/avatars/2.svg", + "displayName": "account 65", + "timestamp": 1791000000000 + }, + "76561199000000065": { + "url": "src/lib/mock/avatars/3.svg", + "displayName": "account 66", + "timestamp": 1791000000000 + }, + "76561199000000066": { + "url": "src/lib/mock/avatars/4.svg", + "displayName": "account 67", + "timestamp": 1791000000000 + }, + "76561199000000067": { + "url": "src/lib/mock/avatars/5.svg", + "displayName": "account 68", + "timestamp": 1791000000000 + }, + "76561199000000068": { + "url": "src/lib/mock/avatars/6.svg", + "displayName": "account 69", + "timestamp": 1791000000000 + }, + "76561199000000069": { + "url": "src/lib/mock/avatars/7.svg", + "displayName": "account 70", + "timestamp": 1791000000000 + }, + "76561199000000070": { + "url": "src/lib/mock/avatars/8.svg", + "displayName": "account 71", + "timestamp": 1791000000000 + }, + "76561199000000071": { + "url": "src/lib/mock/avatars/9.svg", + "displayName": "account 72", + "timestamp": 1791000000000 + }, + "76561199000000072": { + "url": "src/lib/mock/avatars/10.svg", + "displayName": "account 73", + "timestamp": 1791000000000 + }, + "76561199000000073": { + "url": "src/lib/mock/avatars/11.svg", + "displayName": "account 74", + "timestamp": 1791000000000 + }, + "76561199000000074": { + "url": "src/lib/mock/avatars/12.svg", + "displayName": "account 75", + "timestamp": 1791000000000 + }, + "76561199000000075": { + "url": "src/lib/mock/avatars/13.svg", + "displayName": "account 76", + "timestamp": 1791000000000 + }, + "76561199000000076": { + "url": "src/lib/mock/avatars/14.svg", + "displayName": "account 77", + "timestamp": 1791000000000 + }, + "76561199000000077": { + "url": "src/lib/mock/avatars/15.svg", + "displayName": "account 78", + "timestamp": 1791000000000 + }, + "76561199000000078": { + "url": "src/lib/mock/avatars/16.svg", + "displayName": "account 79", + "timestamp": 1791000000000 + }, + "76561199000000079": { + "url": "src/lib/mock/avatars/17.svg", + "displayName": "account 80", + "timestamp": 1791000000000 + }, + "76561199000000080": { + "url": "src/lib/mock/avatars/18.svg", + "displayName": "account 81", + "timestamp": 1791000000000 + }, + "76561199000000081": { + "url": "src/lib/mock/avatars/19.svg", + "displayName": "account 82", + "timestamp": 1791000000000 + }, + "76561199000000082": { + "url": "src/lib/mock/avatars/20.svg", + "displayName": "account 83", + "timestamp": 1791000000000 + }, + "76561199000000083": { + "url": "src/lib/mock/avatars/21.svg", + "displayName": "account 84", + "timestamp": 1791000000000 + }, + "76561199000000084": { + "url": "src/lib/mock/avatars/1.svg", + "displayName": "account 85", + "timestamp": 1791000000000 + }, + "76561199000000085": { + "url": "src/lib/mock/avatars/2.svg", + "displayName": "account 86", + "timestamp": 1791000000000 + }, + "76561199000000086": { + "url": "src/lib/mock/avatars/3.svg", + "displayName": "account 87", + "timestamp": 1791000000000 + }, + "76561199000000087": { + "url": "src/lib/mock/avatars/4.svg", + "displayName": "account 88", + "timestamp": 1791000000000 + }, + "76561199000000088": { + "url": "src/lib/mock/avatars/5.svg", + "displayName": "account 89", + "timestamp": 1791000000000 + }, + "76561199000000089": { + "url": "src/lib/mock/avatars/6.svg", + "displayName": "account 90", + "timestamp": 1791000000000 + }, + "76561199000000090": { + "url": "src/lib/mock/avatars/7.svg", + "displayName": "account 91", + "timestamp": 1791000000000 + }, + "76561199000000091": { + "url": "src/lib/mock/avatars/8.svg", + "displayName": "account 92", + "timestamp": 1791000000000 + }, + "76561199000000092": { + "url": "src/lib/mock/avatars/9.svg", + "displayName": "account 93", + "timestamp": 1791000000000 + }, + "76561199000000093": { + "url": "src/lib/mock/avatars/10.svg", + "displayName": "account 94", + "timestamp": 1791000000000 + }, + "76561199000000094": { + "url": "src/lib/mock/avatars/11.svg", + "displayName": "account 95", + "timestamp": 1791000000000 + }, + "76561199000000095": { + "url": "src/lib/mock/avatars/12.svg", + "displayName": "account 96", + "timestamp": 1791000000000 + }, + "76561199000000096": { + "url": "src/lib/mock/avatars/13.svg", + "displayName": "account 97", + "timestamp": 1791000000000 + }, + "76561199000000097": { + "url": "src/lib/mock/avatars/14.svg", + "displayName": "account 98", + "timestamp": 1791000000000 + }, + "76561199000000098": { + "url": "src/lib/mock/avatars/15.svg", + "displayName": "account 99", + "timestamp": 1791000000000 + }, + "76561199000000099": { + "url": "src/lib/mock/avatars/16.svg", + "displayName": "account 100", + "timestamp": 1791000000000 + }, + "76561199000000100": { + "url": "src/lib/mock/avatars/17.svg", + "displayName": "account 101", + "timestamp": 1791000000000 + }, + "76561199000000101": { + "url": "src/lib/mock/avatars/18.svg", + "displayName": "account 102", + "timestamp": 1791000000000 + }, + "76561199000000102": { + "url": "src/lib/mock/avatars/19.svg", + "displayName": "account 103", + "timestamp": 1791000000000 + }, + "76561199000000103": { + "url": "src/lib/mock/avatars/20.svg", + "displayName": "account 104", + "timestamp": 1791000000000 + }, + "76561199000000104": { + "url": "src/lib/mock/avatars/21.svg", + "displayName": "account 105", + "timestamp": 1791000000000 + }, + "76561199000000105": { + "url": "src/lib/mock/avatars/1.svg", + "displayName": "account 106", + "timestamp": 1791000000000 + }, + "76561199000000106": { + "url": "src/lib/mock/avatars/2.svg", + "displayName": "account 107", + "timestamp": 1791000000000 + }, + "76561199000000107": { + "url": "src/lib/mock/avatars/3.svg", + "displayName": "account 108", + "timestamp": 1791000000000 + }, + "76561199000000108": { + "url": "src/lib/mock/avatars/4.svg", + "displayName": "account 109", + "timestamp": 1791000000000 + }, + "76561199000000109": { + "url": "src/lib/mock/avatars/5.svg", + "displayName": "account 110", + "timestamp": 1791000000000 + }, + "76561199000000110": { + "url": "src/lib/mock/avatars/6.svg", + "displayName": "account 111", + "timestamp": 1791000000000 + }, + "76561199000000111": { + "url": "src/lib/mock/avatars/7.svg", + "displayName": "account 112", + "timestamp": 1791000000000 + }, + "76561199000000112": { + "url": "src/lib/mock/avatars/8.svg", + "displayName": "account 113", + "timestamp": 1791000000000 + }, + "76561199000000113": { + "url": "src/lib/mock/avatars/9.svg", + "displayName": "account 114", + "timestamp": 1791000000000 + }, + "76561199000000114": { + "url": "src/lib/mock/avatars/10.svg", + "displayName": "account 115", + "timestamp": 1791000000000 + }, + "76561199000000115": { + "url": "src/lib/mock/avatars/11.svg", + "displayName": "account 116", + "timestamp": 1791000000000 + }, + "76561199000000116": { + "url": "src/lib/mock/avatars/12.svg", + "displayName": "account 117", + "timestamp": 1791000000000 + }, + "76561199000000117": { + "url": "src/lib/mock/avatars/13.svg", + "displayName": "account 118", + "timestamp": 1791000000000 + }, + "76561199000000118": { + "url": "src/lib/mock/avatars/14.svg", + "displayName": "account 119", + "timestamp": 1791000000000 + }, + "76561199000000119": { + "url": "src/lib/mock/avatars/15.svg", + "displayName": "account 120", + "timestamp": 1791000000000 + }, + "76561199000000120": { + "url": "src/lib/mock/avatars/16.svg", + "displayName": "account 121", + "timestamp": 1791000000000 + }, + "76561199000000121": { + "url": "src/lib/mock/avatars/17.svg", + "displayName": "account 122", + "timestamp": 1791000000000 + }, + "76561199000000122": { + "url": "src/lib/mock/avatars/18.svg", + "displayName": "account 123", + "timestamp": 1791000000000 + }, + "76561199000000123": { + "url": "src/lib/mock/avatars/19.svg", + "displayName": "account 124", + "timestamp": 1791000000000 + }, + "76561199000000124": { + "url": "src/lib/mock/avatars/20.svg", + "displayName": "account 125", + "timestamp": 1791000000000 + }, + "76561199000000125": { + "url": "src/lib/mock/avatars/21.svg", + "displayName": "account 126", + "timestamp": 1791000000000 + }, + "76561199000000126": { + "url": "src/lib/mock/avatars/1.svg", + "displayName": "account 127", + "timestamp": 1791000000000 + }, + "76561199000000127": { + "url": "src/lib/mock/avatars/2.svg", + "displayName": "account 128", + "timestamp": 1791000000000 + }, + "76561199000000128": { + "url": "src/lib/mock/avatars/3.svg", + "displayName": "account 129", + "timestamp": 1791000000000 + }, + "76561199000000129": { + "url": "src/lib/mock/avatars/4.svg", + "displayName": "account 130", + "timestamp": 1791000000000 + }, + "76561199000000130": { + "url": "src/lib/mock/avatars/5.svg", + "displayName": "account 131", + "timestamp": 1791000000000 + }, + "76561199000000131": { + "url": "src/lib/mock/avatars/6.svg", + "displayName": "account 132", + "timestamp": 1791000000000 + }, + "76561199000000132": { + "url": "src/lib/mock/avatars/7.svg", + "displayName": "account 133", + "timestamp": 1791000000000 + }, + "76561199000000133": { + "url": "src/lib/mock/avatars/8.svg", + "displayName": "account 134", + "timestamp": 1791000000000 + }, + "76561199000000134": { + "url": "src/lib/mock/avatars/9.svg", + "displayName": "account 135", + "timestamp": 1791000000000 + }, + "76561199000000135": { + "url": "src/lib/mock/avatars/10.svg", + "displayName": "account 136", + "timestamp": 1791000000000 + }, + "76561199000000136": { + "url": "src/lib/mock/avatars/11.svg", + "displayName": "account 137", + "timestamp": 1791000000000 + }, + "76561199000000137": { + "url": "src/lib/mock/avatars/12.svg", + "displayName": "account 138", + "timestamp": 1791000000000 + }, + "76561199000000138": { + "url": "src/lib/mock/avatars/13.svg", + "displayName": "account 139", + "timestamp": 1791000000000 + }, + "76561199000000139": { + "url": "src/lib/mock/avatars/14.svg", + "displayName": "account 140", + "timestamp": 1791000000000 + }, + "76561199000000140": { + "url": "src/lib/mock/avatars/15.svg", + "displayName": "account 141", + "timestamp": 1791000000000 + }, + "76561199000000141": { + "url": "src/lib/mock/avatars/16.svg", + "displayName": "account 142", + "timestamp": 1791000000000 + }, + "76561199000000142": { + "url": "src/lib/mock/avatars/17.svg", + "displayName": "account 143", + "timestamp": 1791000000000 + }, + "76561199000000143": { + "url": "src/lib/mock/avatars/18.svg", + "displayName": "account 144", + "timestamp": 1791000000000 + }, + "76561199000000144": { + "url": "src/lib/mock/avatars/19.svg", + "displayName": "account 145", + "timestamp": 1791000000000 + }, + "76561199000000145": { + "url": "src/lib/mock/avatars/20.svg", + "displayName": "account 146", + "timestamp": 1791000000000 + }, + "76561199000000146": { + "url": "src/lib/mock/avatars/21.svg", + "displayName": "account 147", + "timestamp": 1791000000000 + }, + "76561199000000147": { + "url": "src/lib/mock/avatars/1.svg", + "displayName": "account 148", + "timestamp": 1791000000000 + }, + "76561199000000148": { + "url": "src/lib/mock/avatars/2.svg", + "displayName": "account 149", + "timestamp": 1791000000000 + }, + "76561199000000149": { + "url": "src/lib/mock/avatars/3.svg", + "displayName": "account 150", + "timestamp": 1791000000000 + }, + "76561199000000150": { + "url": "src/lib/mock/avatars/4.svg", + "displayName": "account 151", + "timestamp": 1791000000000 + }, + "76561199000000151": { + "url": "src/lib/mock/avatars/5.svg", + "displayName": "account 152", + "timestamp": 1791000000000 + }, + "76561199000000152": { + "url": "src/lib/mock/avatars/6.svg", + "displayName": "account 153", + "timestamp": 1791000000000 + }, + "76561199000000153": { + "url": "src/lib/mock/avatars/7.svg", + "displayName": "account 154", + "timestamp": 1791000000000 + }, + "76561199000000154": { + "url": "src/lib/mock/avatars/8.svg", + "displayName": "account 155", + "timestamp": 1791000000000 + }, + "76561199000000155": { + "url": "src/lib/mock/avatars/9.svg", + "displayName": "account 156", + "timestamp": 1791000000000 + }, + "76561199000000156": { + "url": "src/lib/mock/avatars/10.svg", + "displayName": "account 157", + "timestamp": 1791000000000 + }, + "76561199000000157": { + "url": "src/lib/mock/avatars/11.svg", + "displayName": "account 158", + "timestamp": 1791000000000 + }, + "76561199000000158": { + "url": "src/lib/mock/avatars/12.svg", + "displayName": "account 159", + "timestamp": 1791000000000 + }, + "76561199000000159": { + "url": "src/lib/mock/avatars/13.svg", + "displayName": "account 160", + "timestamp": 1791000000000 + }, + "76561199000000160": { + "url": "src/lib/mock/avatars/14.svg", + "displayName": "account 161", + "timestamp": 1791000000000 + }, + "76561199000000161": { + "url": "src/lib/mock/avatars/15.svg", + "displayName": "account 162", + "timestamp": 1791000000000 + }, + "76561199000000162": { + "url": "src/lib/mock/avatars/16.svg", + "displayName": "account 163", + "timestamp": 1791000000000 + }, + "76561199000000163": { + "url": "src/lib/mock/avatars/17.svg", + "displayName": "account 164", + "timestamp": 1791000000000 + }, + "76561199000000164": { + "url": "src/lib/mock/avatars/18.svg", + "displayName": "account 165", + "timestamp": 1791000000000 + }, + "76561199000000165": { + "url": "src/lib/mock/avatars/19.svg", + "displayName": "account 166", + "timestamp": 1791000000000 + }, + "76561199000000166": { + "url": "src/lib/mock/avatars/20.svg", + "displayName": "account 167", + "timestamp": 1791000000000 + }, + "76561199000000167": { + "url": "src/lib/mock/avatars/21.svg", + "displayName": "account 168", + "timestamp": 1791000000000 + }, + "76561199000000168": { + "url": "src/lib/mock/avatars/1.svg", + "displayName": "account 169", + "timestamp": 1791000000000 + }, + "76561199000000169": { + "url": "src/lib/mock/avatars/2.svg", + "displayName": "account 170", + "timestamp": 1791000000000 + }, + "76561199000000170": { + "url": "src/lib/mock/avatars/3.svg", + "displayName": "account 171", + "timestamp": 1791000000000 + }, + "76561199000000171": { + "url": "src/lib/mock/avatars/4.svg", + "displayName": "account 172", + "timestamp": 1791000000000 + }, + "76561199000000172": { + "url": "src/lib/mock/avatars/5.svg", + "displayName": "account 173", + "timestamp": 1791000000000 + }, + "76561199000000173": { + "url": "src/lib/mock/avatars/6.svg", + "displayName": "account 174", + "timestamp": 1791000000000 + }, + "76561199000000174": { + "url": "src/lib/mock/avatars/7.svg", + "displayName": "account 175", + "timestamp": 1791000000000 + }, + "76561199000000175": { + "url": "src/lib/mock/avatars/8.svg", + "displayName": "account 176", + "timestamp": 1791000000000 + }, + "76561199000000176": { + "url": "src/lib/mock/avatars/9.svg", + "displayName": "account 177", + "timestamp": 1791000000000 + }, + "76561199000000177": { + "url": "src/lib/mock/avatars/10.svg", + "displayName": "account 178", + "timestamp": 1791000000000 + }, + "76561199000000178": { + "url": "src/lib/mock/avatars/11.svg", + "displayName": "account 179", + "timestamp": 1791000000000 + }, + "76561199000000179": { + "url": "src/lib/mock/avatars/12.svg", + "displayName": "account 180", + "timestamp": 1791000000000 + }, + "76561199000000180": { + "url": "src/lib/mock/avatars/13.svg", + "displayName": "account 181", + "timestamp": 1791000000000 + }, + "76561199000000181": { + "url": "src/lib/mock/avatars/14.svg", + "displayName": "account 182", + "timestamp": 1791000000000 + }, + "76561199000000182": { + "url": "src/lib/mock/avatars/15.svg", + "displayName": "account 183", + "timestamp": 1791000000000 + }, + "76561199000000183": { + "url": "src/lib/mock/avatars/16.svg", + "displayName": "account 184", + "timestamp": 1791000000000 + }, + "76561199000000184": { + "url": "src/lib/mock/avatars/17.svg", + "displayName": "account 185", + "timestamp": 1791000000000 + }, + "76561199000000185": { + "url": "src/lib/mock/avatars/18.svg", + "displayName": "account 186", + "timestamp": 1791000000000 + }, + "76561199000000186": { + "url": "src/lib/mock/avatars/19.svg", + "displayName": "account 187", + "timestamp": 1791000000000 + }, + "76561199000000187": { + "url": "src/lib/mock/avatars/20.svg", + "displayName": "account 188", + "timestamp": 1791000000000 + }, + "76561199000000188": { + "url": "src/lib/mock/avatars/21.svg", + "displayName": "account 189", + "timestamp": 1791000000000 + }, + "76561199000000189": { + "url": "src/lib/mock/avatars/1.svg", + "displayName": "account 190", + "timestamp": 1791000000000 + }, + "76561199000000190": { + "url": "src/lib/mock/avatars/2.svg", + "displayName": "account 191", + "timestamp": 1791000000000 + }, + "76561199000000191": { + "url": "src/lib/mock/avatars/3.svg", + "displayName": "account 192", + "timestamp": 1791000000000 + }, + "76561199000000192": { + "url": "src/lib/mock/avatars/4.svg", + "displayName": "account 193", + "timestamp": 1791000000000 + }, + "76561199000000193": { + "url": "src/lib/mock/avatars/5.svg", + "displayName": "account 194", + "timestamp": 1791000000000 + }, + "76561199000000194": { + "url": "src/lib/mock/avatars/6.svg", + "displayName": "account 195", + "timestamp": 1791000000000 + }, + "76561199000000195": { + "url": "src/lib/mock/avatars/7.svg", + "displayName": "account 196", + "timestamp": 1791000000000 + }, + "76561199000000196": { + "url": "src/lib/mock/avatars/8.svg", + "displayName": "account 197", + "timestamp": 1791000000000 + }, + "76561199000000197": { + "url": "src/lib/mock/avatars/9.svg", + "displayName": "account 198", + "timestamp": 1791000000000 + }, + "76561199000000198": { + "url": "src/lib/mock/avatars/10.svg", + "displayName": "account 199", + "timestamp": 1791000000000 + }, + "76561199000000199": { + "url": "src/lib/mock/avatars/11.svg", + "displayName": "account 200", + "timestamp": 1791000000000 + } + }, + "cache.roblox.profiles": {}, + "cache.steam.ban-check-state": {}, + "cache.steam.ban-info-cache": {} + } + } + } + ], + "migrate_legacy_config": [ + { + "args": {}, + "result": "skipped" + } + ], + "open_descriptors_folder": [ + { + "args": {}, + "result": null + } + ], + "open_logs_folder": [ + { + "args": {}, + "result": null + } + ], + "open_url": [ + { + "args": {}, + "result": null + } + ], + "platform_detect_installed": [ + { + "args": {}, + "result": ["steam"] + } + ], + "platform_dry_run": [ + { + "args": { + "platformId": "steam", + "accountId": "76561199000000000" + }, + "result": { + "platformId": "steam", + "operation": "switch", + "accountId": "76561199000000000", + "applied": false, + "roots": [], + "steps": [], + "warnings": [] + } + } + ], + "platform_get_accounts": [ + { + "args": { + "platformId": "steam" + }, + "result": [ + { + "steam_id": "76561199000000000", + "account_name": "filler_1", + "persona_name": "account 1", + "last_login_at": 1790996400 + }, + { + "steam_id": "76561199000000001", + "account_name": "filler_2", + "persona_name": "account 2", + "last_login_at": 1790913600 + }, + { + "steam_id": "76561199000000002", + "account_name": "filler_3", + "persona_name": "account 3", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561199000000003", + "account_name": "filler_4", + "persona_name": "account 4", + "last_login_at": 1790740800 + }, + { + "steam_id": "76561199000000004", + "account_name": "filler_5", + "persona_name": "account 5", + "last_login_at": 1790654400 + }, + { + "steam_id": "76561199000000005", + "account_name": "filler_6", + "persona_name": "account 6", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561199000000006", + "account_name": "filler_7", + "persona_name": "account 7", + "last_login_at": 1790481600 + }, + { + "steam_id": "76561199000000007", + "account_name": "filler_8", + "persona_name": "account 8", + "last_login_at": 1790395200 + }, + { + "steam_id": "76561199000000008", + "account_name": "filler_9", + "persona_name": "account 9", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561199000000009", + "account_name": "filler_10", + "persona_name": "account 10", + "last_login_at": 1790222400 + }, + { + "steam_id": "76561199000000010", + "account_name": "filler_11", + "persona_name": "account 11", + "last_login_at": 1790136000 + }, + { + "steam_id": "76561199000000011", + "account_name": "filler_12", + "persona_name": "account 12", + "last_login_at": 1790049600 + }, + { + "steam_id": "76561199000000012", + "account_name": "filler_13", + "persona_name": "account 13", + "last_login_at": 1789963200 + }, + { + "steam_id": "76561199000000013", + "account_name": "filler_14", + "persona_name": "account 14", + "last_login_at": 1789876800 + }, + { + "steam_id": "76561199000000014", + "account_name": "filler_15", + "persona_name": "account 15", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561199000000015", + "account_name": "filler_16", + "persona_name": "account 16", + "last_login_at": 1789704000 + }, + { + "steam_id": "76561199000000016", + "account_name": "filler_17", + "persona_name": "account 17", + "last_login_at": 1789617600 + }, + { + "steam_id": "76561199000000017", + "account_name": "filler_18", + "persona_name": "account 18", + "last_login_at": 1789531200 + }, + { + "steam_id": "76561199000000018", + "account_name": "filler_19", + "persona_name": "account 19", + "last_login_at": 1789444800 + }, + { + "steam_id": "76561199000000019", + "account_name": "filler_20", + "persona_name": "account 20", + "last_login_at": 1789358400 + }, + { + "steam_id": "76561199000000020", + "account_name": "filler_21", + "persona_name": "account 21", + "last_login_at": 1789272000 + }, + { + "steam_id": "76561199000000021", + "account_name": "filler_22", + "persona_name": "account 22", + "last_login_at": 1789185600 + }, + { + "steam_id": "76561199000000022", + "account_name": "filler_23", + "persona_name": "account 23", + "last_login_at": 1789099200 + }, + { + "steam_id": "76561199000000023", + "account_name": "filler_24", + "persona_name": "account 24", + "last_login_at": 1789012800 + }, + { + "steam_id": "76561199000000024", + "account_name": "filler_25", + "persona_name": "account 25", + "last_login_at": 1788926400 + }, + { + "steam_id": "76561199000000025", + "account_name": "filler_26", + "persona_name": "account 26", + "last_login_at": 1788840000 + }, + { + "steam_id": "76561199000000026", + "account_name": "filler_27", + "persona_name": "account 27", + "last_login_at": 1788753600 + }, + { + "steam_id": "76561199000000027", + "account_name": "filler_28", + "persona_name": "account 28", + "last_login_at": 1788667200 + }, + { + "steam_id": "76561199000000028", + "account_name": "filler_29", + "persona_name": "account 29", + "last_login_at": 1788580800 + }, + { + "steam_id": "76561199000000029", + "account_name": "filler_30", + "persona_name": "account 30", + "last_login_at": 1788494400 + }, + { + "steam_id": "76561199000000030", + "account_name": "filler_31", + "persona_name": "account 31", + "last_login_at": 1788408000 + }, + { + "steam_id": "76561199000000031", + "account_name": "filler_32", + "persona_name": "account 32", + "last_login_at": 1788321600 + }, + { + "steam_id": "76561199000000032", + "account_name": "filler_33", + "persona_name": "account 33", + "last_login_at": 1788235200 + }, + { + "steam_id": "76561199000000033", + "account_name": "filler_34", + "persona_name": "account 34", + "last_login_at": 1788148800 + }, + { + "steam_id": "76561199000000034", + "account_name": "filler_35", + "persona_name": "account 35", + "last_login_at": 1788062400 + }, + { + "steam_id": "76561199000000035", + "account_name": "filler_36", + "persona_name": "account 36", + "last_login_at": 1787976000 + }, + { + "steam_id": "76561199000000036", + "account_name": "filler_37", + "persona_name": "account 37", + "last_login_at": 1787889600 + }, + { + "steam_id": "76561199000000037", + "account_name": "filler_38", + "persona_name": "account 38", + "last_login_at": 1787803200 + }, + { + "steam_id": "76561199000000038", + "account_name": "filler_39", + "persona_name": "account 39", + "last_login_at": 1787716800 + }, + { + "steam_id": "76561199000000039", + "account_name": "filler_40", + "persona_name": "account 40", + "last_login_at": 1787630400 + }, + { + "steam_id": "76561199000000040", + "account_name": "filler_41", + "persona_name": "account 41", + "last_login_at": 1787544000 + }, + { + "steam_id": "76561199000000041", + "account_name": "filler_42", + "persona_name": "account 42", + "last_login_at": 1787457600 + }, + { + "steam_id": "76561199000000042", + "account_name": "filler_43", + "persona_name": "account 43", + "last_login_at": 1787371200 + }, + { + "steam_id": "76561199000000043", + "account_name": "filler_44", + "persona_name": "account 44", + "last_login_at": 1787284800 + }, + { + "steam_id": "76561199000000044", + "account_name": "filler_45", + "persona_name": "account 45", + "last_login_at": 1787198400 + }, + { + "steam_id": "76561199000000045", + "account_name": "filler_46", + "persona_name": "account 46", + "last_login_at": 1791000000 + }, + { + "steam_id": "76561199000000046", + "account_name": "filler_47", + "persona_name": "account 47", + "last_login_at": 1790913600 + }, + { + "steam_id": "76561199000000047", + "account_name": "filler_48", + "persona_name": "account 48", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561199000000048", + "account_name": "filler_49", + "persona_name": "account 49", + "last_login_at": 1790740800 + }, + { + "steam_id": "76561199000000049", + "account_name": "filler_50", + "persona_name": "account 50", + "last_login_at": 1790654400 + }, + { + "steam_id": "76561199000000050", + "account_name": "filler_51", + "persona_name": "account 51", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561199000000051", + "account_name": "filler_52", + "persona_name": "account 52", + "last_login_at": 1790481600 + }, + { + "steam_id": "76561199000000052", + "account_name": "filler_53", + "persona_name": "account 53", + "last_login_at": 1790395200 + }, + { + "steam_id": "76561199000000053", + "account_name": "filler_54", + "persona_name": "account 54", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561199000000054", + "account_name": "filler_55", + "persona_name": "account 55", + "last_login_at": 1790222400 + }, + { + "steam_id": "76561199000000055", + "account_name": "filler_56", + "persona_name": "account 56", + "last_login_at": 1790136000 + }, + { + "steam_id": "76561199000000056", + "account_name": "filler_57", + "persona_name": "account 57", + "last_login_at": 1790049600 + }, + { + "steam_id": "76561199000000057", + "account_name": "filler_58", + "persona_name": "account 58", + "last_login_at": 1789963200 + }, + { + "steam_id": "76561199000000058", + "account_name": "filler_59", + "persona_name": "account 59", + "last_login_at": 1789876800 + }, + { + "steam_id": "76561199000000059", + "account_name": "filler_60", + "persona_name": "account 60", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561199000000060", + "account_name": "filler_61", + "persona_name": "account 61", + "last_login_at": 1789704000 + }, + { + "steam_id": "76561199000000061", + "account_name": "filler_62", + "persona_name": "account 62", + "last_login_at": 1789617600 + }, + { + "steam_id": "76561199000000062", + "account_name": "filler_63", + "persona_name": "account 63", + "last_login_at": 1789531200 + }, + { + "steam_id": "76561199000000063", + "account_name": "filler_64", + "persona_name": "account 64", + "last_login_at": 1789444800 + }, + { + "steam_id": "76561199000000064", + "account_name": "filler_65", + "persona_name": "account 65", + "last_login_at": 1789358400 + }, + { + "steam_id": "76561199000000065", + "account_name": "filler_66", + "persona_name": "account 66", + "last_login_at": 1789272000 + }, + { + "steam_id": "76561199000000066", + "account_name": "filler_67", + "persona_name": "account 67", + "last_login_at": 1789185600 + }, + { + "steam_id": "76561199000000067", + "account_name": "filler_68", + "persona_name": "account 68", + "last_login_at": 1789099200 + }, + { + "steam_id": "76561199000000068", + "account_name": "filler_69", + "persona_name": "account 69", + "last_login_at": 1789012800 + }, + { + "steam_id": "76561199000000069", + "account_name": "filler_70", + "persona_name": "account 70", + "last_login_at": 1788926400 + }, + { + "steam_id": "76561199000000070", + "account_name": "filler_71", + "persona_name": "account 71", + "last_login_at": 1788840000 + }, + { + "steam_id": "76561199000000071", + "account_name": "filler_72", + "persona_name": "account 72", + "last_login_at": 1788753600 + }, + { + "steam_id": "76561199000000072", + "account_name": "filler_73", + "persona_name": "account 73", + "last_login_at": 1788667200 + }, + { + "steam_id": "76561199000000073", + "account_name": "filler_74", + "persona_name": "account 74", + "last_login_at": 1788580800 + }, + { + "steam_id": "76561199000000074", + "account_name": "filler_75", + "persona_name": "account 75", + "last_login_at": 1788494400 + }, + { + "steam_id": "76561199000000075", + "account_name": "filler_76", + "persona_name": "account 76", + "last_login_at": 1788408000 + }, + { + "steam_id": "76561199000000076", + "account_name": "filler_77", + "persona_name": "account 77", + "last_login_at": 1788321600 + }, + { + "steam_id": "76561199000000077", + "account_name": "filler_78", + "persona_name": "account 78", + "last_login_at": 1788235200 + }, + { + "steam_id": "76561199000000078", + "account_name": "filler_79", + "persona_name": "account 79", + "last_login_at": 1788148800 + }, + { + "steam_id": "76561199000000079", + "account_name": "filler_80", + "persona_name": "account 80", + "last_login_at": 1788062400 + }, + { + "steam_id": "76561199000000080", + "account_name": "filler_81", + "persona_name": "account 81", + "last_login_at": 1787976000 + }, + { + "steam_id": "76561199000000081", + "account_name": "filler_82", + "persona_name": "account 82", + "last_login_at": 1787889600 + }, + { + "steam_id": "76561199000000082", + "account_name": "filler_83", + "persona_name": "account 83", + "last_login_at": 1787803200 + }, + { + "steam_id": "76561199000000083", + "account_name": "filler_84", + "persona_name": "account 84", + "last_login_at": 1787716800 + }, + { + "steam_id": "76561199000000084", + "account_name": "filler_85", + "persona_name": "account 85", + "last_login_at": 1787630400 + }, + { + "steam_id": "76561199000000085", + "account_name": "filler_86", + "persona_name": "account 86", + "last_login_at": 1787544000 + }, + { + "steam_id": "76561199000000086", + "account_name": "filler_87", + "persona_name": "account 87", + "last_login_at": 1787457600 + }, + { + "steam_id": "76561199000000087", + "account_name": "filler_88", + "persona_name": "account 88", + "last_login_at": 1787371200 + }, + { + "steam_id": "76561199000000088", + "account_name": "filler_89", + "persona_name": "account 89", + "last_login_at": 1787284800 + }, + { + "steam_id": "76561199000000089", + "account_name": "filler_90", + "persona_name": "account 90", + "last_login_at": 1787198400 + }, + { + "steam_id": "76561199000000090", + "account_name": "filler_91", + "persona_name": "account 91", + "last_login_at": 1791000000 + }, + { + "steam_id": "76561199000000091", + "account_name": "filler_92", + "persona_name": "account 92", + "last_login_at": 1790913600 + }, + { + "steam_id": "76561199000000092", + "account_name": "filler_93", + "persona_name": "account 93", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561199000000093", + "account_name": "filler_94", + "persona_name": "account 94", + "last_login_at": 1790740800 + }, + { + "steam_id": "76561199000000094", + "account_name": "filler_95", + "persona_name": "account 95", + "last_login_at": 1790654400 + }, + { + "steam_id": "76561199000000095", + "account_name": "filler_96", + "persona_name": "account 96", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561199000000096", + "account_name": "filler_97", + "persona_name": "account 97", + "last_login_at": 1790481600 + }, + { + "steam_id": "76561199000000097", + "account_name": "filler_98", + "persona_name": "account 98", + "last_login_at": 1790395200 + }, + { + "steam_id": "76561199000000098", + "account_name": "filler_99", + "persona_name": "account 99", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561199000000099", + "account_name": "filler_100", + "persona_name": "account 100", + "last_login_at": 1790222400 + }, + { + "steam_id": "76561199000000100", + "account_name": "filler_101", + "persona_name": "account 101", + "last_login_at": 1790136000 + }, + { + "steam_id": "76561199000000101", + "account_name": "filler_102", + "persona_name": "account 102", + "last_login_at": 1790049600 + }, + { + "steam_id": "76561199000000102", + "account_name": "filler_103", + "persona_name": "account 103", + "last_login_at": 1789963200 + }, + { + "steam_id": "76561199000000103", + "account_name": "filler_104", + "persona_name": "account 104", + "last_login_at": 1789876800 + }, + { + "steam_id": "76561199000000104", + "account_name": "filler_105", + "persona_name": "account 105", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561199000000105", + "account_name": "filler_106", + "persona_name": "account 106", + "last_login_at": 1789704000 + }, + { + "steam_id": "76561199000000106", + "account_name": "filler_107", + "persona_name": "account 107", + "last_login_at": 1789617600 + }, + { + "steam_id": "76561199000000107", + "account_name": "filler_108", + "persona_name": "account 108", + "last_login_at": 1789531200 + }, + { + "steam_id": "76561199000000108", + "account_name": "filler_109", + "persona_name": "account 109", + "last_login_at": 1789444800 + }, + { + "steam_id": "76561199000000109", + "account_name": "filler_110", + "persona_name": "account 110", + "last_login_at": 1789358400 + }, + { + "steam_id": "76561199000000110", + "account_name": "filler_111", + "persona_name": "account 111", + "last_login_at": 1789272000 + }, + { + "steam_id": "76561199000000111", + "account_name": "filler_112", + "persona_name": "account 112", + "last_login_at": 1789185600 + }, + { + "steam_id": "76561199000000112", + "account_name": "filler_113", + "persona_name": "account 113", + "last_login_at": 1789099200 + }, + { + "steam_id": "76561199000000113", + "account_name": "filler_114", + "persona_name": "account 114", + "last_login_at": 1789012800 + }, + { + "steam_id": "76561199000000114", + "account_name": "filler_115", + "persona_name": "account 115", + "last_login_at": 1788926400 + }, + { + "steam_id": "76561199000000115", + "account_name": "filler_116", + "persona_name": "account 116", + "last_login_at": 1788840000 + }, + { + "steam_id": "76561199000000116", + "account_name": "filler_117", + "persona_name": "account 117", + "last_login_at": 1788753600 + }, + { + "steam_id": "76561199000000117", + "account_name": "filler_118", + "persona_name": "account 118", + "last_login_at": 1788667200 + }, + { + "steam_id": "76561199000000118", + "account_name": "filler_119", + "persona_name": "account 119", + "last_login_at": 1788580800 + }, + { + "steam_id": "76561199000000119", + "account_name": "filler_120", + "persona_name": "account 120", + "last_login_at": 1788494400 + }, + { + "steam_id": "76561199000000120", + "account_name": "filler_121", + "persona_name": "account 121", + "last_login_at": 1788408000 + }, + { + "steam_id": "76561199000000121", + "account_name": "filler_122", + "persona_name": "account 122", + "last_login_at": 1788321600 + }, + { + "steam_id": "76561199000000122", + "account_name": "filler_123", + "persona_name": "account 123", + "last_login_at": 1788235200 + }, + { + "steam_id": "76561199000000123", + "account_name": "filler_124", + "persona_name": "account 124", + "last_login_at": 1788148800 + }, + { + "steam_id": "76561199000000124", + "account_name": "filler_125", + "persona_name": "account 125", + "last_login_at": 1788062400 + }, + { + "steam_id": "76561199000000125", + "account_name": "filler_126", + "persona_name": "account 126", + "last_login_at": 1787976000 + }, + { + "steam_id": "76561199000000126", + "account_name": "filler_127", + "persona_name": "account 127", + "last_login_at": 1787889600 + }, + { + "steam_id": "76561199000000127", + "account_name": "filler_128", + "persona_name": "account 128", + "last_login_at": 1787803200 + }, + { + "steam_id": "76561199000000128", + "account_name": "filler_129", + "persona_name": "account 129", + "last_login_at": 1787716800 + }, + { + "steam_id": "76561199000000129", + "account_name": "filler_130", + "persona_name": "account 130", + "last_login_at": 1787630400 + }, + { + "steam_id": "76561199000000130", + "account_name": "filler_131", + "persona_name": "account 131", + "last_login_at": 1787544000 + }, + { + "steam_id": "76561199000000131", + "account_name": "filler_132", + "persona_name": "account 132", + "last_login_at": 1787457600 + }, + { + "steam_id": "76561199000000132", + "account_name": "filler_133", + "persona_name": "account 133", + "last_login_at": 1787371200 + }, + { + "steam_id": "76561199000000133", + "account_name": "filler_134", + "persona_name": "account 134", + "last_login_at": 1787284800 + }, + { + "steam_id": "76561199000000134", + "account_name": "filler_135", + "persona_name": "account 135", + "last_login_at": 1787198400 + }, + { + "steam_id": "76561199000000135", + "account_name": "filler_136", + "persona_name": "account 136", + "last_login_at": 1791000000 + }, + { + "steam_id": "76561199000000136", + "account_name": "filler_137", + "persona_name": "account 137", + "last_login_at": 1790913600 + }, + { + "steam_id": "76561199000000137", + "account_name": "filler_138", + "persona_name": "account 138", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561199000000138", + "account_name": "filler_139", + "persona_name": "account 139", + "last_login_at": 1790740800 + }, + { + "steam_id": "76561199000000139", + "account_name": "filler_140", + "persona_name": "account 140", + "last_login_at": 1790654400 + }, + { + "steam_id": "76561199000000140", + "account_name": "filler_141", + "persona_name": "account 141", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561199000000141", + "account_name": "filler_142", + "persona_name": "account 142", + "last_login_at": 1790481600 + }, + { + "steam_id": "76561199000000142", + "account_name": "filler_143", + "persona_name": "account 143", + "last_login_at": 1790395200 + }, + { + "steam_id": "76561199000000143", + "account_name": "filler_144", + "persona_name": "account 144", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561199000000144", + "account_name": "filler_145", + "persona_name": "account 145", + "last_login_at": 1790222400 + }, + { + "steam_id": "76561199000000145", + "account_name": "filler_146", + "persona_name": "account 146", + "last_login_at": 1790136000 + }, + { + "steam_id": "76561199000000146", + "account_name": "filler_147", + "persona_name": "account 147", + "last_login_at": 1790049600 + }, + { + "steam_id": "76561199000000147", + "account_name": "filler_148", + "persona_name": "account 148", + "last_login_at": 1789963200 + }, + { + "steam_id": "76561199000000148", + "account_name": "filler_149", + "persona_name": "account 149", + "last_login_at": 1789876800 + }, + { + "steam_id": "76561199000000149", + "account_name": "filler_150", + "persona_name": "account 150", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561199000000150", + "account_name": "filler_151", + "persona_name": "account 151", + "last_login_at": 1789704000 + }, + { + "steam_id": "76561199000000151", + "account_name": "filler_152", + "persona_name": "account 152", + "last_login_at": 1789617600 + }, + { + "steam_id": "76561199000000152", + "account_name": "filler_153", + "persona_name": "account 153", + "last_login_at": 1789531200 + }, + { + "steam_id": "76561199000000153", + "account_name": "filler_154", + "persona_name": "account 154", + "last_login_at": 1789444800 + }, + { + "steam_id": "76561199000000154", + "account_name": "filler_155", + "persona_name": "account 155", + "last_login_at": 1789358400 + }, + { + "steam_id": "76561199000000155", + "account_name": "filler_156", + "persona_name": "account 156", + "last_login_at": 1789272000 + }, + { + "steam_id": "76561199000000156", + "account_name": "filler_157", + "persona_name": "account 157", + "last_login_at": 1789185600 + }, + { + "steam_id": "76561199000000157", + "account_name": "filler_158", + "persona_name": "account 158", + "last_login_at": 1789099200 + }, + { + "steam_id": "76561199000000158", + "account_name": "filler_159", + "persona_name": "account 159", + "last_login_at": 1789012800 + }, + { + "steam_id": "76561199000000159", + "account_name": "filler_160", + "persona_name": "account 160", + "last_login_at": 1788926400 + }, + { + "steam_id": "76561199000000160", + "account_name": "filler_161", + "persona_name": "account 161", + "last_login_at": 1788840000 + }, + { + "steam_id": "76561199000000161", + "account_name": "filler_162", + "persona_name": "account 162", + "last_login_at": 1788753600 + }, + { + "steam_id": "76561199000000162", + "account_name": "filler_163", + "persona_name": "account 163", + "last_login_at": 1788667200 + }, + { + "steam_id": "76561199000000163", + "account_name": "filler_164", + "persona_name": "account 164", + "last_login_at": 1788580800 + }, + { + "steam_id": "76561199000000164", + "account_name": "filler_165", + "persona_name": "account 165", + "last_login_at": 1788494400 + }, + { + "steam_id": "76561199000000165", + "account_name": "filler_166", + "persona_name": "account 166", + "last_login_at": 1788408000 + }, + { + "steam_id": "76561199000000166", + "account_name": "filler_167", + "persona_name": "account 167", + "last_login_at": 1788321600 + }, + { + "steam_id": "76561199000000167", + "account_name": "filler_168", + "persona_name": "account 168", + "last_login_at": 1788235200 + }, + { + "steam_id": "76561199000000168", + "account_name": "filler_169", + "persona_name": "account 169", + "last_login_at": 1788148800 + }, + { + "steam_id": "76561199000000169", + "account_name": "filler_170", + "persona_name": "account 170", + "last_login_at": 1788062400 + }, + { + "steam_id": "76561199000000170", + "account_name": "filler_171", + "persona_name": "account 171", + "last_login_at": 1787976000 + }, + { + "steam_id": "76561199000000171", + "account_name": "filler_172", + "persona_name": "account 172", + "last_login_at": 1787889600 + }, + { + "steam_id": "76561199000000172", + "account_name": "filler_173", + "persona_name": "account 173", + "last_login_at": 1787803200 + }, + { + "steam_id": "76561199000000173", + "account_name": "filler_174", + "persona_name": "account 174", + "last_login_at": 1787716800 + }, + { + "steam_id": "76561199000000174", + "account_name": "filler_175", + "persona_name": "account 175", + "last_login_at": 1787630400 + }, + { + "steam_id": "76561199000000175", + "account_name": "filler_176", + "persona_name": "account 176", + "last_login_at": 1787544000 + }, + { + "steam_id": "76561199000000176", + "account_name": "filler_177", + "persona_name": "account 177", + "last_login_at": 1787457600 + }, + { + "steam_id": "76561199000000177", + "account_name": "filler_178", + "persona_name": "account 178", + "last_login_at": 1787371200 + }, + { + "steam_id": "76561199000000178", + "account_name": "filler_179", + "persona_name": "account 179", + "last_login_at": 1787284800 + }, + { + "steam_id": "76561199000000179", + "account_name": "filler_180", + "persona_name": "account 180", + "last_login_at": 1787198400 + }, + { + "steam_id": "76561199000000180", + "account_name": "filler_181", + "persona_name": "account 181", + "last_login_at": 1791000000 + }, + { + "steam_id": "76561199000000181", + "account_name": "filler_182", + "persona_name": "account 182", + "last_login_at": 1790913600 + }, + { + "steam_id": "76561199000000182", + "account_name": "filler_183", + "persona_name": "account 183", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561199000000183", + "account_name": "filler_184", + "persona_name": "account 184", + "last_login_at": 1790740800 + }, + { + "steam_id": "76561199000000184", + "account_name": "filler_185", + "persona_name": "account 185", + "last_login_at": 1790654400 + }, + { + "steam_id": "76561199000000185", + "account_name": "filler_186", + "persona_name": "account 186", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561199000000186", + "account_name": "filler_187", + "persona_name": "account 187", + "last_login_at": 1790481600 + }, + { + "steam_id": "76561199000000187", + "account_name": "filler_188", + "persona_name": "account 188", + "last_login_at": 1790395200 + }, + { + "steam_id": "76561199000000188", + "account_name": "filler_189", + "persona_name": "account 189", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561199000000189", + "account_name": "filler_190", + "persona_name": "account 190", + "last_login_at": 1790222400 + }, + { + "steam_id": "76561199000000190", + "account_name": "filler_191", + "persona_name": "account 191", + "last_login_at": 1790136000 + }, + { + "steam_id": "76561199000000191", + "account_name": "filler_192", + "persona_name": "account 192", + "last_login_at": 1790049600 + }, + { + "steam_id": "76561199000000192", + "account_name": "filler_193", + "persona_name": "account 193", + "last_login_at": 1789963200 + }, + { + "steam_id": "76561199000000193", + "account_name": "filler_194", + "persona_name": "account 194", + "last_login_at": 1789876800 + }, + { + "steam_id": "76561199000000194", + "account_name": "filler_195", + "persona_name": "account 195", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561199000000195", + "account_name": "filler_196", + "persona_name": "account 196", + "last_login_at": 1789704000 + }, + { + "steam_id": "76561199000000196", + "account_name": "filler_197", + "persona_name": "account 197", + "last_login_at": 1789617600 + }, + { + "steam_id": "76561199000000197", + "account_name": "filler_198", + "persona_name": "account 198", + "last_login_at": 1789531200 + }, + { + "steam_id": "76561199000000198", + "account_name": "filler_199", + "persona_name": "account 199", + "last_login_at": 1789444800 + }, + { + "steam_id": "76561199000000199", + "account_name": "filler_200", + "persona_name": "account 200", + "last_login_at": 1789358400 + } + ] + }, + { + "args": { + "platformId": "riot" + }, + "result": [] + }, + { + "args": { + "platformId": "roblox" + }, + "result": [] + } + ], + "platform_get_current_account": [ + { + "args": { + "platformId": "steam" + }, + "result": "76561199000000000" + }, + { + "args": { + "platformId": "riot" + }, + "result": "" + }, + { + "args": { + "platformId": "roblox" + }, + "result": "" + } + ], + "platform_get_path": [ + { + "args": {}, + "result": "C:\\Program Files (x86)\\Steam" + } + ], + "platform_get_startup_snapshot": [ + { + "args": { + "platformId": "steam" + }, + "result": { + "accounts": [ + { + "steam_id": "76561199000000000", + "account_name": "filler_1", + "persona_name": "account 1", + "last_login_at": 1790996400 + }, + { + "steam_id": "76561199000000001", + "account_name": "filler_2", + "persona_name": "account 2", + "last_login_at": 1790913600 + }, + { + "steam_id": "76561199000000002", + "account_name": "filler_3", + "persona_name": "account 3", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561199000000003", + "account_name": "filler_4", + "persona_name": "account 4", + "last_login_at": 1790740800 + }, + { + "steam_id": "76561199000000004", + "account_name": "filler_5", + "persona_name": "account 5", + "last_login_at": 1790654400 + }, + { + "steam_id": "76561199000000005", + "account_name": "filler_6", + "persona_name": "account 6", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561199000000006", + "account_name": "filler_7", + "persona_name": "account 7", + "last_login_at": 1790481600 + }, + { + "steam_id": "76561199000000007", + "account_name": "filler_8", + "persona_name": "account 8", + "last_login_at": 1790395200 + }, + { + "steam_id": "76561199000000008", + "account_name": "filler_9", + "persona_name": "account 9", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561199000000009", + "account_name": "filler_10", + "persona_name": "account 10", + "last_login_at": 1790222400 + }, + { + "steam_id": "76561199000000010", + "account_name": "filler_11", + "persona_name": "account 11", + "last_login_at": 1790136000 + }, + { + "steam_id": "76561199000000011", + "account_name": "filler_12", + "persona_name": "account 12", + "last_login_at": 1790049600 + }, + { + "steam_id": "76561199000000012", + "account_name": "filler_13", + "persona_name": "account 13", + "last_login_at": 1789963200 + }, + { + "steam_id": "76561199000000013", + "account_name": "filler_14", + "persona_name": "account 14", + "last_login_at": 1789876800 + }, + { + "steam_id": "76561199000000014", + "account_name": "filler_15", + "persona_name": "account 15", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561199000000015", + "account_name": "filler_16", + "persona_name": "account 16", + "last_login_at": 1789704000 + }, + { + "steam_id": "76561199000000016", + "account_name": "filler_17", + "persona_name": "account 17", + "last_login_at": 1789617600 + }, + { + "steam_id": "76561199000000017", + "account_name": "filler_18", + "persona_name": "account 18", + "last_login_at": 1789531200 + }, + { + "steam_id": "76561199000000018", + "account_name": "filler_19", + "persona_name": "account 19", + "last_login_at": 1789444800 + }, + { + "steam_id": "76561199000000019", + "account_name": "filler_20", + "persona_name": "account 20", + "last_login_at": 1789358400 + }, + { + "steam_id": "76561199000000020", + "account_name": "filler_21", + "persona_name": "account 21", + "last_login_at": 1789272000 + }, + { + "steam_id": "76561199000000021", + "account_name": "filler_22", + "persona_name": "account 22", + "last_login_at": 1789185600 + }, + { + "steam_id": "76561199000000022", + "account_name": "filler_23", + "persona_name": "account 23", + "last_login_at": 1789099200 + }, + { + "steam_id": "76561199000000023", + "account_name": "filler_24", + "persona_name": "account 24", + "last_login_at": 1789012800 + }, + { + "steam_id": "76561199000000024", + "account_name": "filler_25", + "persona_name": "account 25", + "last_login_at": 1788926400 + }, + { + "steam_id": "76561199000000025", + "account_name": "filler_26", + "persona_name": "account 26", + "last_login_at": 1788840000 + }, + { + "steam_id": "76561199000000026", + "account_name": "filler_27", + "persona_name": "account 27", + "last_login_at": 1788753600 + }, + { + "steam_id": "76561199000000027", + "account_name": "filler_28", + "persona_name": "account 28", + "last_login_at": 1788667200 + }, + { + "steam_id": "76561199000000028", + "account_name": "filler_29", + "persona_name": "account 29", + "last_login_at": 1788580800 + }, + { + "steam_id": "76561199000000029", + "account_name": "filler_30", + "persona_name": "account 30", + "last_login_at": 1788494400 + }, + { + "steam_id": "76561199000000030", + "account_name": "filler_31", + "persona_name": "account 31", + "last_login_at": 1788408000 + }, + { + "steam_id": "76561199000000031", + "account_name": "filler_32", + "persona_name": "account 32", + "last_login_at": 1788321600 + }, + { + "steam_id": "76561199000000032", + "account_name": "filler_33", + "persona_name": "account 33", + "last_login_at": 1788235200 + }, + { + "steam_id": "76561199000000033", + "account_name": "filler_34", + "persona_name": "account 34", + "last_login_at": 1788148800 + }, + { + "steam_id": "76561199000000034", + "account_name": "filler_35", + "persona_name": "account 35", + "last_login_at": 1788062400 + }, + { + "steam_id": "76561199000000035", + "account_name": "filler_36", + "persona_name": "account 36", + "last_login_at": 1787976000 + }, + { + "steam_id": "76561199000000036", + "account_name": "filler_37", + "persona_name": "account 37", + "last_login_at": 1787889600 + }, + { + "steam_id": "76561199000000037", + "account_name": "filler_38", + "persona_name": "account 38", + "last_login_at": 1787803200 + }, + { + "steam_id": "76561199000000038", + "account_name": "filler_39", + "persona_name": "account 39", + "last_login_at": 1787716800 + }, + { + "steam_id": "76561199000000039", + "account_name": "filler_40", + "persona_name": "account 40", + "last_login_at": 1787630400 + }, + { + "steam_id": "76561199000000040", + "account_name": "filler_41", + "persona_name": "account 41", + "last_login_at": 1787544000 + }, + { + "steam_id": "76561199000000041", + "account_name": "filler_42", + "persona_name": "account 42", + "last_login_at": 1787457600 + }, + { + "steam_id": "76561199000000042", + "account_name": "filler_43", + "persona_name": "account 43", + "last_login_at": 1787371200 + }, + { + "steam_id": "76561199000000043", + "account_name": "filler_44", + "persona_name": "account 44", + "last_login_at": 1787284800 + }, + { + "steam_id": "76561199000000044", + "account_name": "filler_45", + "persona_name": "account 45", + "last_login_at": 1787198400 + }, + { + "steam_id": "76561199000000045", + "account_name": "filler_46", + "persona_name": "account 46", + "last_login_at": 1791000000 + }, + { + "steam_id": "76561199000000046", + "account_name": "filler_47", + "persona_name": "account 47", + "last_login_at": 1790913600 + }, + { + "steam_id": "76561199000000047", + "account_name": "filler_48", + "persona_name": "account 48", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561199000000048", + "account_name": "filler_49", + "persona_name": "account 49", + "last_login_at": 1790740800 + }, + { + "steam_id": "76561199000000049", + "account_name": "filler_50", + "persona_name": "account 50", + "last_login_at": 1790654400 + }, + { + "steam_id": "76561199000000050", + "account_name": "filler_51", + "persona_name": "account 51", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561199000000051", + "account_name": "filler_52", + "persona_name": "account 52", + "last_login_at": 1790481600 + }, + { + "steam_id": "76561199000000052", + "account_name": "filler_53", + "persona_name": "account 53", + "last_login_at": 1790395200 + }, + { + "steam_id": "76561199000000053", + "account_name": "filler_54", + "persona_name": "account 54", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561199000000054", + "account_name": "filler_55", + "persona_name": "account 55", + "last_login_at": 1790222400 + }, + { + "steam_id": "76561199000000055", + "account_name": "filler_56", + "persona_name": "account 56", + "last_login_at": 1790136000 + }, + { + "steam_id": "76561199000000056", + "account_name": "filler_57", + "persona_name": "account 57", + "last_login_at": 1790049600 + }, + { + "steam_id": "76561199000000057", + "account_name": "filler_58", + "persona_name": "account 58", + "last_login_at": 1789963200 + }, + { + "steam_id": "76561199000000058", + "account_name": "filler_59", + "persona_name": "account 59", + "last_login_at": 1789876800 + }, + { + "steam_id": "76561199000000059", + "account_name": "filler_60", + "persona_name": "account 60", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561199000000060", + "account_name": "filler_61", + "persona_name": "account 61", + "last_login_at": 1789704000 + }, + { + "steam_id": "76561199000000061", + "account_name": "filler_62", + "persona_name": "account 62", + "last_login_at": 1789617600 + }, + { + "steam_id": "76561199000000062", + "account_name": "filler_63", + "persona_name": "account 63", + "last_login_at": 1789531200 + }, + { + "steam_id": "76561199000000063", + "account_name": "filler_64", + "persona_name": "account 64", + "last_login_at": 1789444800 + }, + { + "steam_id": "76561199000000064", + "account_name": "filler_65", + "persona_name": "account 65", + "last_login_at": 1789358400 + }, + { + "steam_id": "76561199000000065", + "account_name": "filler_66", + "persona_name": "account 66", + "last_login_at": 1789272000 + }, + { + "steam_id": "76561199000000066", + "account_name": "filler_67", + "persona_name": "account 67", + "last_login_at": 1789185600 + }, + { + "steam_id": "76561199000000067", + "account_name": "filler_68", + "persona_name": "account 68", + "last_login_at": 1789099200 + }, + { + "steam_id": "76561199000000068", + "account_name": "filler_69", + "persona_name": "account 69", + "last_login_at": 1789012800 + }, + { + "steam_id": "76561199000000069", + "account_name": "filler_70", + "persona_name": "account 70", + "last_login_at": 1788926400 + }, + { + "steam_id": "76561199000000070", + "account_name": "filler_71", + "persona_name": "account 71", + "last_login_at": 1788840000 + }, + { + "steam_id": "76561199000000071", + "account_name": "filler_72", + "persona_name": "account 72", + "last_login_at": 1788753600 + }, + { + "steam_id": "76561199000000072", + "account_name": "filler_73", + "persona_name": "account 73", + "last_login_at": 1788667200 + }, + { + "steam_id": "76561199000000073", + "account_name": "filler_74", + "persona_name": "account 74", + "last_login_at": 1788580800 + }, + { + "steam_id": "76561199000000074", + "account_name": "filler_75", + "persona_name": "account 75", + "last_login_at": 1788494400 + }, + { + "steam_id": "76561199000000075", + "account_name": "filler_76", + "persona_name": "account 76", + "last_login_at": 1788408000 + }, + { + "steam_id": "76561199000000076", + "account_name": "filler_77", + "persona_name": "account 77", + "last_login_at": 1788321600 + }, + { + "steam_id": "76561199000000077", + "account_name": "filler_78", + "persona_name": "account 78", + "last_login_at": 1788235200 + }, + { + "steam_id": "76561199000000078", + "account_name": "filler_79", + "persona_name": "account 79", + "last_login_at": 1788148800 + }, + { + "steam_id": "76561199000000079", + "account_name": "filler_80", + "persona_name": "account 80", + "last_login_at": 1788062400 + }, + { + "steam_id": "76561199000000080", + "account_name": "filler_81", + "persona_name": "account 81", + "last_login_at": 1787976000 + }, + { + "steam_id": "76561199000000081", + "account_name": "filler_82", + "persona_name": "account 82", + "last_login_at": 1787889600 + }, + { + "steam_id": "76561199000000082", + "account_name": "filler_83", + "persona_name": "account 83", + "last_login_at": 1787803200 + }, + { + "steam_id": "76561199000000083", + "account_name": "filler_84", + "persona_name": "account 84", + "last_login_at": 1787716800 + }, + { + "steam_id": "76561199000000084", + "account_name": "filler_85", + "persona_name": "account 85", + "last_login_at": 1787630400 + }, + { + "steam_id": "76561199000000085", + "account_name": "filler_86", + "persona_name": "account 86", + "last_login_at": 1787544000 + }, + { + "steam_id": "76561199000000086", + "account_name": "filler_87", + "persona_name": "account 87", + "last_login_at": 1787457600 + }, + { + "steam_id": "76561199000000087", + "account_name": "filler_88", + "persona_name": "account 88", + "last_login_at": 1787371200 + }, + { + "steam_id": "76561199000000088", + "account_name": "filler_89", + "persona_name": "account 89", + "last_login_at": 1787284800 + }, + { + "steam_id": "76561199000000089", + "account_name": "filler_90", + "persona_name": "account 90", + "last_login_at": 1787198400 + }, + { + "steam_id": "76561199000000090", + "account_name": "filler_91", + "persona_name": "account 91", + "last_login_at": 1791000000 + }, + { + "steam_id": "76561199000000091", + "account_name": "filler_92", + "persona_name": "account 92", + "last_login_at": 1790913600 + }, + { + "steam_id": "76561199000000092", + "account_name": "filler_93", + "persona_name": "account 93", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561199000000093", + "account_name": "filler_94", + "persona_name": "account 94", + "last_login_at": 1790740800 + }, + { + "steam_id": "76561199000000094", + "account_name": "filler_95", + "persona_name": "account 95", + "last_login_at": 1790654400 + }, + { + "steam_id": "76561199000000095", + "account_name": "filler_96", + "persona_name": "account 96", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561199000000096", + "account_name": "filler_97", + "persona_name": "account 97", + "last_login_at": 1790481600 + }, + { + "steam_id": "76561199000000097", + "account_name": "filler_98", + "persona_name": "account 98", + "last_login_at": 1790395200 + }, + { + "steam_id": "76561199000000098", + "account_name": "filler_99", + "persona_name": "account 99", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561199000000099", + "account_name": "filler_100", + "persona_name": "account 100", + "last_login_at": 1790222400 + }, + { + "steam_id": "76561199000000100", + "account_name": "filler_101", + "persona_name": "account 101", + "last_login_at": 1790136000 + }, + { + "steam_id": "76561199000000101", + "account_name": "filler_102", + "persona_name": "account 102", + "last_login_at": 1790049600 + }, + { + "steam_id": "76561199000000102", + "account_name": "filler_103", + "persona_name": "account 103", + "last_login_at": 1789963200 + }, + { + "steam_id": "76561199000000103", + "account_name": "filler_104", + "persona_name": "account 104", + "last_login_at": 1789876800 + }, + { + "steam_id": "76561199000000104", + "account_name": "filler_105", + "persona_name": "account 105", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561199000000105", + "account_name": "filler_106", + "persona_name": "account 106", + "last_login_at": 1789704000 + }, + { + "steam_id": "76561199000000106", + "account_name": "filler_107", + "persona_name": "account 107", + "last_login_at": 1789617600 + }, + { + "steam_id": "76561199000000107", + "account_name": "filler_108", + "persona_name": "account 108", + "last_login_at": 1789531200 + }, + { + "steam_id": "76561199000000108", + "account_name": "filler_109", + "persona_name": "account 109", + "last_login_at": 1789444800 + }, + { + "steam_id": "76561199000000109", + "account_name": "filler_110", + "persona_name": "account 110", + "last_login_at": 1789358400 + }, + { + "steam_id": "76561199000000110", + "account_name": "filler_111", + "persona_name": "account 111", + "last_login_at": 1789272000 + }, + { + "steam_id": "76561199000000111", + "account_name": "filler_112", + "persona_name": "account 112", + "last_login_at": 1789185600 + }, + { + "steam_id": "76561199000000112", + "account_name": "filler_113", + "persona_name": "account 113", + "last_login_at": 1789099200 + }, + { + "steam_id": "76561199000000113", + "account_name": "filler_114", + "persona_name": "account 114", + "last_login_at": 1789012800 + }, + { + "steam_id": "76561199000000114", + "account_name": "filler_115", + "persona_name": "account 115", + "last_login_at": 1788926400 + }, + { + "steam_id": "76561199000000115", + "account_name": "filler_116", + "persona_name": "account 116", + "last_login_at": 1788840000 + }, + { + "steam_id": "76561199000000116", + "account_name": "filler_117", + "persona_name": "account 117", + "last_login_at": 1788753600 + }, + { + "steam_id": "76561199000000117", + "account_name": "filler_118", + "persona_name": "account 118", + "last_login_at": 1788667200 + }, + { + "steam_id": "76561199000000118", + "account_name": "filler_119", + "persona_name": "account 119", + "last_login_at": 1788580800 + }, + { + "steam_id": "76561199000000119", + "account_name": "filler_120", + "persona_name": "account 120", + "last_login_at": 1788494400 + }, + { + "steam_id": "76561199000000120", + "account_name": "filler_121", + "persona_name": "account 121", + "last_login_at": 1788408000 + }, + { + "steam_id": "76561199000000121", + "account_name": "filler_122", + "persona_name": "account 122", + "last_login_at": 1788321600 + }, + { + "steam_id": "76561199000000122", + "account_name": "filler_123", + "persona_name": "account 123", + "last_login_at": 1788235200 + }, + { + "steam_id": "76561199000000123", + "account_name": "filler_124", + "persona_name": "account 124", + "last_login_at": 1788148800 + }, + { + "steam_id": "76561199000000124", + "account_name": "filler_125", + "persona_name": "account 125", + "last_login_at": 1788062400 + }, + { + "steam_id": "76561199000000125", + "account_name": "filler_126", + "persona_name": "account 126", + "last_login_at": 1787976000 + }, + { + "steam_id": "76561199000000126", + "account_name": "filler_127", + "persona_name": "account 127", + "last_login_at": 1787889600 + }, + { + "steam_id": "76561199000000127", + "account_name": "filler_128", + "persona_name": "account 128", + "last_login_at": 1787803200 + }, + { + "steam_id": "76561199000000128", + "account_name": "filler_129", + "persona_name": "account 129", + "last_login_at": 1787716800 + }, + { + "steam_id": "76561199000000129", + "account_name": "filler_130", + "persona_name": "account 130", + "last_login_at": 1787630400 + }, + { + "steam_id": "76561199000000130", + "account_name": "filler_131", + "persona_name": "account 131", + "last_login_at": 1787544000 + }, + { + "steam_id": "76561199000000131", + "account_name": "filler_132", + "persona_name": "account 132", + "last_login_at": 1787457600 + }, + { + "steam_id": "76561199000000132", + "account_name": "filler_133", + "persona_name": "account 133", + "last_login_at": 1787371200 + }, + { + "steam_id": "76561199000000133", + "account_name": "filler_134", + "persona_name": "account 134", + "last_login_at": 1787284800 + }, + { + "steam_id": "76561199000000134", + "account_name": "filler_135", + "persona_name": "account 135", + "last_login_at": 1787198400 + }, + { + "steam_id": "76561199000000135", + "account_name": "filler_136", + "persona_name": "account 136", + "last_login_at": 1791000000 + }, + { + "steam_id": "76561199000000136", + "account_name": "filler_137", + "persona_name": "account 137", + "last_login_at": 1790913600 + }, + { + "steam_id": "76561199000000137", + "account_name": "filler_138", + "persona_name": "account 138", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561199000000138", + "account_name": "filler_139", + "persona_name": "account 139", + "last_login_at": 1790740800 + }, + { + "steam_id": "76561199000000139", + "account_name": "filler_140", + "persona_name": "account 140", + "last_login_at": 1790654400 + }, + { + "steam_id": "76561199000000140", + "account_name": "filler_141", + "persona_name": "account 141", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561199000000141", + "account_name": "filler_142", + "persona_name": "account 142", + "last_login_at": 1790481600 + }, + { + "steam_id": "76561199000000142", + "account_name": "filler_143", + "persona_name": "account 143", + "last_login_at": 1790395200 + }, + { + "steam_id": "76561199000000143", + "account_name": "filler_144", + "persona_name": "account 144", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561199000000144", + "account_name": "filler_145", + "persona_name": "account 145", + "last_login_at": 1790222400 + }, + { + "steam_id": "76561199000000145", + "account_name": "filler_146", + "persona_name": "account 146", + "last_login_at": 1790136000 + }, + { + "steam_id": "76561199000000146", + "account_name": "filler_147", + "persona_name": "account 147", + "last_login_at": 1790049600 + }, + { + "steam_id": "76561199000000147", + "account_name": "filler_148", + "persona_name": "account 148", + "last_login_at": 1789963200 + }, + { + "steam_id": "76561199000000148", + "account_name": "filler_149", + "persona_name": "account 149", + "last_login_at": 1789876800 + }, + { + "steam_id": "76561199000000149", + "account_name": "filler_150", + "persona_name": "account 150", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561199000000150", + "account_name": "filler_151", + "persona_name": "account 151", + "last_login_at": 1789704000 + }, + { + "steam_id": "76561199000000151", + "account_name": "filler_152", + "persona_name": "account 152", + "last_login_at": 1789617600 + }, + { + "steam_id": "76561199000000152", + "account_name": "filler_153", + "persona_name": "account 153", + "last_login_at": 1789531200 + }, + { + "steam_id": "76561199000000153", + "account_name": "filler_154", + "persona_name": "account 154", + "last_login_at": 1789444800 + }, + { + "steam_id": "76561199000000154", + "account_name": "filler_155", + "persona_name": "account 155", + "last_login_at": 1789358400 + }, + { + "steam_id": "76561199000000155", + "account_name": "filler_156", + "persona_name": "account 156", + "last_login_at": 1789272000 + }, + { + "steam_id": "76561199000000156", + "account_name": "filler_157", + "persona_name": "account 157", + "last_login_at": 1789185600 + }, + { + "steam_id": "76561199000000157", + "account_name": "filler_158", + "persona_name": "account 158", + "last_login_at": 1789099200 + }, + { + "steam_id": "76561199000000158", + "account_name": "filler_159", + "persona_name": "account 159", + "last_login_at": 1789012800 + }, + { + "steam_id": "76561199000000159", + "account_name": "filler_160", + "persona_name": "account 160", + "last_login_at": 1788926400 + }, + { + "steam_id": "76561199000000160", + "account_name": "filler_161", + "persona_name": "account 161", + "last_login_at": 1788840000 + }, + { + "steam_id": "76561199000000161", + "account_name": "filler_162", + "persona_name": "account 162", + "last_login_at": 1788753600 + }, + { + "steam_id": "76561199000000162", + "account_name": "filler_163", + "persona_name": "account 163", + "last_login_at": 1788667200 + }, + { + "steam_id": "76561199000000163", + "account_name": "filler_164", + "persona_name": "account 164", + "last_login_at": 1788580800 + }, + { + "steam_id": "76561199000000164", + "account_name": "filler_165", + "persona_name": "account 165", + "last_login_at": 1788494400 + }, + { + "steam_id": "76561199000000165", + "account_name": "filler_166", + "persona_name": "account 166", + "last_login_at": 1788408000 + }, + { + "steam_id": "76561199000000166", + "account_name": "filler_167", + "persona_name": "account 167", + "last_login_at": 1788321600 + }, + { + "steam_id": "76561199000000167", + "account_name": "filler_168", + "persona_name": "account 168", + "last_login_at": 1788235200 + }, + { + "steam_id": "76561199000000168", + "account_name": "filler_169", + "persona_name": "account 169", + "last_login_at": 1788148800 + }, + { + "steam_id": "76561199000000169", + "account_name": "filler_170", + "persona_name": "account 170", + "last_login_at": 1788062400 + }, + { + "steam_id": "76561199000000170", + "account_name": "filler_171", + "persona_name": "account 171", + "last_login_at": 1787976000 + }, + { + "steam_id": "76561199000000171", + "account_name": "filler_172", + "persona_name": "account 172", + "last_login_at": 1787889600 + }, + { + "steam_id": "76561199000000172", + "account_name": "filler_173", + "persona_name": "account 173", + "last_login_at": 1787803200 + }, + { + "steam_id": "76561199000000173", + "account_name": "filler_174", + "persona_name": "account 174", + "last_login_at": 1787716800 + }, + { + "steam_id": "76561199000000174", + "account_name": "filler_175", + "persona_name": "account 175", + "last_login_at": 1787630400 + }, + { + "steam_id": "76561199000000175", + "account_name": "filler_176", + "persona_name": "account 176", + "last_login_at": 1787544000 + }, + { + "steam_id": "76561199000000176", + "account_name": "filler_177", + "persona_name": "account 177", + "last_login_at": 1787457600 + }, + { + "steam_id": "76561199000000177", + "account_name": "filler_178", + "persona_name": "account 178", + "last_login_at": 1787371200 + }, + { + "steam_id": "76561199000000178", + "account_name": "filler_179", + "persona_name": "account 179", + "last_login_at": 1787284800 + }, + { + "steam_id": "76561199000000179", + "account_name": "filler_180", + "persona_name": "account 180", + "last_login_at": 1787198400 + }, + { + "steam_id": "76561199000000180", + "account_name": "filler_181", + "persona_name": "account 181", + "last_login_at": 1791000000 + }, + { + "steam_id": "76561199000000181", + "account_name": "filler_182", + "persona_name": "account 182", + "last_login_at": 1790913600 + }, + { + "steam_id": "76561199000000182", + "account_name": "filler_183", + "persona_name": "account 183", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561199000000183", + "account_name": "filler_184", + "persona_name": "account 184", + "last_login_at": 1790740800 + }, + { + "steam_id": "76561199000000184", + "account_name": "filler_185", + "persona_name": "account 185", + "last_login_at": 1790654400 + }, + { + "steam_id": "76561199000000185", + "account_name": "filler_186", + "persona_name": "account 186", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561199000000186", + "account_name": "filler_187", + "persona_name": "account 187", + "last_login_at": 1790481600 + }, + { + "steam_id": "76561199000000187", + "account_name": "filler_188", + "persona_name": "account 188", + "last_login_at": 1790395200 + }, + { + "steam_id": "76561199000000188", + "account_name": "filler_189", + "persona_name": "account 189", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561199000000189", + "account_name": "filler_190", + "persona_name": "account 190", + "last_login_at": 1790222400 + }, + { + "steam_id": "76561199000000190", + "account_name": "filler_191", + "persona_name": "account 191", + "last_login_at": 1790136000 + }, + { + "steam_id": "76561199000000191", + "account_name": "filler_192", + "persona_name": "account 192", + "last_login_at": 1790049600 + }, + { + "steam_id": "76561199000000192", + "account_name": "filler_193", + "persona_name": "account 193", + "last_login_at": 1789963200 + }, + { + "steam_id": "76561199000000193", + "account_name": "filler_194", + "persona_name": "account 194", + "last_login_at": 1789876800 + }, + { + "steam_id": "76561199000000194", + "account_name": "filler_195", + "persona_name": "account 195", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561199000000195", + "account_name": "filler_196", + "persona_name": "account 196", + "last_login_at": 1789704000 + }, + { + "steam_id": "76561199000000196", + "account_name": "filler_197", + "persona_name": "account 197", + "last_login_at": 1789617600 + }, + { + "steam_id": "76561199000000197", + "account_name": "filler_198", + "persona_name": "account 198", + "last_login_at": 1789531200 + }, + { + "steam_id": "76561199000000198", + "account_name": "filler_199", + "persona_name": "account 199", + "last_login_at": 1789444800 + }, + { + "steam_id": "76561199000000199", + "account_name": "filler_200", + "persona_name": "account 200", + "last_login_at": 1789358400 + } + ], + "currentAccount": "76561199000000000" + } + }, + { + "args": { + "platformId": "riot" + }, + "result": { + "profiles": [], + "currentProfile": "" + } + }, + { + "args": { + "platformId": "roblox" + }, + "result": { + "accounts": [], + "currentAccount": "" + } + } + ], + "platform_set_account_label": [ + { + "args": {}, + "result": null + } + ], + "reload_user_platforms": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "roblox_check_sessions": [ + { + "args": {}, + "result": [] + } + ], + "roblox_get_profile_info": [], + "save_client_storage_store": [ + { + "args": {}, + "result": null + } + ], + "steam_get_account_games": [ + { + "args": {}, + "result": [] + } + ], + "steam_get_copyable_games": [ + { + "args": {}, + "result": [] + } + ], + "steam_get_player_bans": [ + { + "args": { + "steamIds": [ + "76561199000000000", + "76561199000000001", + "76561199000000002", + "76561199000000003", + "76561199000000004", + "76561199000000005", + "76561199000000006", + "76561199000000007", + "76561199000000008", + "76561199000000009", + "76561199000000010", + "76561199000000011", + "76561199000000012", + "76561199000000013", + "76561199000000014", + "76561199000000015", + "76561199000000016", + "76561199000000017", + "76561199000000018", + "76561199000000019", + "76561199000000020", + "76561199000000021", + "76561199000000022", + "76561199000000023", + "76561199000000024", + "76561199000000025", + "76561199000000026", + "76561199000000027", + "76561199000000028", + "76561199000000029", + "76561199000000030", + "76561199000000031", + "76561199000000032", + "76561199000000033", + "76561199000000034", + "76561199000000035", + "76561199000000036", + "76561199000000037", + "76561199000000038", + "76561199000000039", + "76561199000000040", + "76561199000000041", + "76561199000000042", + "76561199000000043", + "76561199000000044", + "76561199000000045", + "76561199000000046", + "76561199000000047", + "76561199000000048", + "76561199000000049", + "76561199000000050", + "76561199000000051", + "76561199000000052", + "76561199000000053", + "76561199000000054", + "76561199000000055", + "76561199000000056", + "76561199000000057", + "76561199000000058", + "76561199000000059", + "76561199000000060", + "76561199000000061", + "76561199000000062", + "76561199000000063", + "76561199000000064", + "76561199000000065", + "76561199000000066", + "76561199000000067", + "76561199000000068", + "76561199000000069", + "76561199000000070", + "76561199000000071", + "76561199000000072", + "76561199000000073", + "76561199000000074", + "76561199000000075", + "76561199000000076", + "76561199000000077", + "76561199000000078", + "76561199000000079", + "76561199000000080", + "76561199000000081", + "76561199000000082", + "76561199000000083", + "76561199000000084", + "76561199000000085", + "76561199000000086", + "76561199000000087", + "76561199000000088", + "76561199000000089", + "76561199000000090", + "76561199000000091", + "76561199000000092", + "76561199000000093", + "76561199000000094", + "76561199000000095", + "76561199000000096", + "76561199000000097", + "76561199000000098", + "76561199000000099", + "76561199000000100", + "76561199000000101", + "76561199000000102", + "76561199000000103", + "76561199000000104", + "76561199000000105", + "76561199000000106", + "76561199000000107", + "76561199000000108", + "76561199000000109", + "76561199000000110", + "76561199000000111", + "76561199000000112", + "76561199000000113", + "76561199000000114", + "76561199000000115", + "76561199000000116", + "76561199000000117", + "76561199000000118", + "76561199000000119", + "76561199000000120", + "76561199000000121", + "76561199000000122", + "76561199000000123", + "76561199000000124", + "76561199000000125", + "76561199000000126", + "76561199000000127", + "76561199000000128", + "76561199000000129", + "76561199000000130", + "76561199000000131", + "76561199000000132", + "76561199000000133", + "76561199000000134", + "76561199000000135", + "76561199000000136", + "76561199000000137", + "76561199000000138", + "76561199000000139", + "76561199000000140", + "76561199000000141", + "76561199000000142", + "76561199000000143", + "76561199000000144", + "76561199000000145", + "76561199000000146", + "76561199000000147", + "76561199000000148", + "76561199000000149", + "76561199000000150", + "76561199000000151", + "76561199000000152", + "76561199000000153", + "76561199000000154", + "76561199000000155", + "76561199000000156", + "76561199000000157", + "76561199000000158", + "76561199000000159", + "76561199000000160", + "76561199000000161", + "76561199000000162", + "76561199000000163", + "76561199000000164", + "76561199000000165", + "76561199000000166", + "76561199000000167", + "76561199000000168", + "76561199000000169", + "76561199000000170", + "76561199000000171", + "76561199000000172", + "76561199000000173", + "76561199000000174", + "76561199000000175", + "76561199000000176", + "76561199000000177", + "76561199000000178", + "76561199000000179", + "76561199000000180", + "76561199000000181", + "76561199000000182", + "76561199000000183", + "76561199000000184", + "76561199000000185", + "76561199000000186", + "76561199000000187", + "76561199000000188", + "76561199000000189", + "76561199000000190", + "76561199000000191", + "76561199000000192", + "76561199000000193", + "76561199000000194", + "76561199000000195", + "76561199000000196", + "76561199000000197", + "76561199000000198", + "76561199000000199" + ] + }, + "result": [ + { + "steam_id": "76561199000000000", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000001", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000002", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000003", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000004", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000005", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000006", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000007", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000008", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000009", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000010", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000011", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000012", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000013", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000014", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000015", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000016", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000017", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000018", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000019", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000020", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000021", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000022", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000023", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000024", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000025", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000026", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000027", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000028", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000029", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000030", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000031", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000032", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000033", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000034", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000035", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000036", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000037", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000038", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000039", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000040", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000041", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000042", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000043", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000044", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000045", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000046", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000047", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000048", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000049", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000050", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000051", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000052", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000053", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000054", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000055", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000056", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000057", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000058", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000059", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000060", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000061", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000062", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000063", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000064", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000065", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000066", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000067", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000068", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000069", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000070", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000071", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000072", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000073", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000074", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000075", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000076", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000077", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000078", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000079", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000080", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000081", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000082", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000083", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000084", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000085", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000086", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000087", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000088", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000089", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000090", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000091", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000092", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000093", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000094", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000095", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000096", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000097", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000098", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000099", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000100", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000101", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000102", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000103", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000104", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000105", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000106", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000107", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000108", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000109", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000110", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000111", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000112", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000113", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000114", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000115", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000116", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000117", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000118", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000119", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000120", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000121", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000122", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000123", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000124", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000125", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000126", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000127", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000128", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000129", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000130", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000131", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000132", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000133", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000134", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000135", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000136", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000137", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000138", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000139", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000140", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000141", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000142", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000143", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000144", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000145", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000146", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000147", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000148", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000149", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000150", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000151", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000152", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000153", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000154", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000155", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000156", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000157", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000158", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000159", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000160", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000161", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000162", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000163", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000164", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000165", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000166", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000167", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000168", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000169", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000170", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000171", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000172", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000173", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000174", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000175", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000176", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000177", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000178", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000179", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000180", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000181", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000182", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000183", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000184", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000185", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000186", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000187", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000188", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000189", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000190", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000191", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000192", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000193", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000194", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000195", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000196", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000197", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000198", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561199000000199", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + } + ] + } + ], + "steam_get_profile_info": [ + { + "args": { + "accountId": "76561199000000000" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/1.svg", + "display_name": "account 1", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000001" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/2.svg", + "display_name": "account 2", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000002" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/3.svg", + "display_name": "account 3", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000003" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/4.svg", + "display_name": "account 4", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000004" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/5.svg", + "display_name": "account 5", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000005" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/6.svg", + "display_name": "account 6", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000006" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/7.svg", + "display_name": "account 7", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000007" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/8.svg", + "display_name": "account 8", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000008" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/9.svg", + "display_name": "account 9", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000009" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/10.svg", + "display_name": "account 10", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000010" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/11.svg", + "display_name": "account 11", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000011" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/12.svg", + "display_name": "account 12", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000012" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/13.svg", + "display_name": "account 13", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000013" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/14.svg", + "display_name": "account 14", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000014" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/15.svg", + "display_name": "account 15", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000015" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/16.svg", + "display_name": "account 16", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000016" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/17.svg", + "display_name": "account 17", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000017" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/18.svg", + "display_name": "account 18", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000018" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/19.svg", + "display_name": "account 19", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000019" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/20.svg", + "display_name": "account 20", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000020" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/21.svg", + "display_name": "account 21", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000021" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/1.svg", + "display_name": "account 22", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000022" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/2.svg", + "display_name": "account 23", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000023" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/3.svg", + "display_name": "account 24", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000024" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/4.svg", + "display_name": "account 25", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000025" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/5.svg", + "display_name": "account 26", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000026" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/6.svg", + "display_name": "account 27", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000027" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/7.svg", + "display_name": "account 28", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000028" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/8.svg", + "display_name": "account 29", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000029" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/9.svg", + "display_name": "account 30", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000030" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/10.svg", + "display_name": "account 31", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000031" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/11.svg", + "display_name": "account 32", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000032" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/12.svg", + "display_name": "account 33", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000033" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/13.svg", + "display_name": "account 34", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000034" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/14.svg", + "display_name": "account 35", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000035" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/15.svg", + "display_name": "account 36", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000036" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/16.svg", + "display_name": "account 37", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000037" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/17.svg", + "display_name": "account 38", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000038" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/18.svg", + "display_name": "account 39", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000039" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/19.svg", + "display_name": "account 40", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000040" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/20.svg", + "display_name": "account 41", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000041" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/21.svg", + "display_name": "account 42", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000042" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/1.svg", + "display_name": "account 43", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000043" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/2.svg", + "display_name": "account 44", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000044" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/3.svg", + "display_name": "account 45", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000045" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/4.svg", + "display_name": "account 46", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000046" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/5.svg", + "display_name": "account 47", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000047" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/6.svg", + "display_name": "account 48", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000048" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/7.svg", + "display_name": "account 49", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000049" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/8.svg", + "display_name": "account 50", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000050" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/9.svg", + "display_name": "account 51", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000051" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/10.svg", + "display_name": "account 52", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000052" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/11.svg", + "display_name": "account 53", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000053" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/12.svg", + "display_name": "account 54", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000054" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/13.svg", + "display_name": "account 55", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000055" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/14.svg", + "display_name": "account 56", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000056" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/15.svg", + "display_name": "account 57", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000057" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/16.svg", + "display_name": "account 58", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000058" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/17.svg", + "display_name": "account 59", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000059" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/18.svg", + "display_name": "account 60", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000060" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/19.svg", + "display_name": "account 61", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000061" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/20.svg", + "display_name": "account 62", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000062" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/21.svg", + "display_name": "account 63", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000063" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/1.svg", + "display_name": "account 64", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000064" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/2.svg", + "display_name": "account 65", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000065" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/3.svg", + "display_name": "account 66", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000066" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/4.svg", + "display_name": "account 67", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000067" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/5.svg", + "display_name": "account 68", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000068" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/6.svg", + "display_name": "account 69", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000069" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/7.svg", + "display_name": "account 70", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000070" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/8.svg", + "display_name": "account 71", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000071" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/9.svg", + "display_name": "account 72", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000072" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/10.svg", + "display_name": "account 73", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000073" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/11.svg", + "display_name": "account 74", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000074" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/12.svg", + "display_name": "account 75", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000075" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/13.svg", + "display_name": "account 76", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000076" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/14.svg", + "display_name": "account 77", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000077" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/15.svg", + "display_name": "account 78", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000078" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/16.svg", + "display_name": "account 79", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000079" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/17.svg", + "display_name": "account 80", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000080" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/18.svg", + "display_name": "account 81", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000081" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/19.svg", + "display_name": "account 82", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000082" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/20.svg", + "display_name": "account 83", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000083" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/21.svg", + "display_name": "account 84", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000084" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/1.svg", + "display_name": "account 85", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000085" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/2.svg", + "display_name": "account 86", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000086" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/3.svg", + "display_name": "account 87", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000087" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/4.svg", + "display_name": "account 88", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000088" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/5.svg", + "display_name": "account 89", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000089" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/6.svg", + "display_name": "account 90", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000090" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/7.svg", + "display_name": "account 91", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000091" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/8.svg", + "display_name": "account 92", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000092" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/9.svg", + "display_name": "account 93", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000093" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/10.svg", + "display_name": "account 94", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000094" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/11.svg", + "display_name": "account 95", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000095" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/12.svg", + "display_name": "account 96", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000096" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/13.svg", + "display_name": "account 97", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000097" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/14.svg", + "display_name": "account 98", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000098" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/15.svg", + "display_name": "account 99", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000099" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/16.svg", + "display_name": "account 100", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000100" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/17.svg", + "display_name": "account 101", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000101" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/18.svg", + "display_name": "account 102", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000102" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/19.svg", + "display_name": "account 103", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000103" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/20.svg", + "display_name": "account 104", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000104" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/21.svg", + "display_name": "account 105", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000105" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/1.svg", + "display_name": "account 106", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000106" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/2.svg", + "display_name": "account 107", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000107" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/3.svg", + "display_name": "account 108", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000108" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/4.svg", + "display_name": "account 109", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000109" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/5.svg", + "display_name": "account 110", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000110" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/6.svg", + "display_name": "account 111", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000111" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/7.svg", + "display_name": "account 112", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000112" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/8.svg", + "display_name": "account 113", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000113" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/9.svg", + "display_name": "account 114", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000114" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/10.svg", + "display_name": "account 115", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000115" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/11.svg", + "display_name": "account 116", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000116" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/12.svg", + "display_name": "account 117", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000117" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/13.svg", + "display_name": "account 118", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000118" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/14.svg", + "display_name": "account 119", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000119" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/15.svg", + "display_name": "account 120", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000120" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/16.svg", + "display_name": "account 121", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000121" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/17.svg", + "display_name": "account 122", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000122" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/18.svg", + "display_name": "account 123", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000123" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/19.svg", + "display_name": "account 124", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000124" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/20.svg", + "display_name": "account 125", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000125" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/21.svg", + "display_name": "account 126", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000126" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/1.svg", + "display_name": "account 127", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000127" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/2.svg", + "display_name": "account 128", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000128" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/3.svg", + "display_name": "account 129", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000129" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/4.svg", + "display_name": "account 130", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000130" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/5.svg", + "display_name": "account 131", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000131" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/6.svg", + "display_name": "account 132", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000132" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/7.svg", + "display_name": "account 133", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000133" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/8.svg", + "display_name": "account 134", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000134" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/9.svg", + "display_name": "account 135", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000135" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/10.svg", + "display_name": "account 136", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000136" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/11.svg", + "display_name": "account 137", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000137" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/12.svg", + "display_name": "account 138", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000138" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/13.svg", + "display_name": "account 139", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000139" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/14.svg", + "display_name": "account 140", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000140" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/15.svg", + "display_name": "account 141", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000141" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/16.svg", + "display_name": "account 142", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000142" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/17.svg", + "display_name": "account 143", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000143" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/18.svg", + "display_name": "account 144", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000144" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/19.svg", + "display_name": "account 145", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000145" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/20.svg", + "display_name": "account 146", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000146" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/21.svg", + "display_name": "account 147", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000147" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/1.svg", + "display_name": "account 148", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000148" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/2.svg", + "display_name": "account 149", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000149" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/3.svg", + "display_name": "account 150", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000150" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/4.svg", + "display_name": "account 151", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000151" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/5.svg", + "display_name": "account 152", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000152" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/6.svg", + "display_name": "account 153", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000153" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/7.svg", + "display_name": "account 154", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000154" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/8.svg", + "display_name": "account 155", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000155" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/9.svg", + "display_name": "account 156", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000156" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/10.svg", + "display_name": "account 157", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000157" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/11.svg", + "display_name": "account 158", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000158" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/12.svg", + "display_name": "account 159", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000159" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/13.svg", + "display_name": "account 160", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000160" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/14.svg", + "display_name": "account 161", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000161" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/15.svg", + "display_name": "account 162", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000162" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/16.svg", + "display_name": "account 163", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000163" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/17.svg", + "display_name": "account 164", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000164" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/18.svg", + "display_name": "account 165", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000165" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/19.svg", + "display_name": "account 166", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000166" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/20.svg", + "display_name": "account 167", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000167" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/21.svg", + "display_name": "account 168", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000168" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/1.svg", + "display_name": "account 169", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000169" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/2.svg", + "display_name": "account 170", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000170" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/3.svg", + "display_name": "account 171", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000171" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/4.svg", + "display_name": "account 172", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000172" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/5.svg", + "display_name": "account 173", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000173" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/6.svg", + "display_name": "account 174", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000174" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/7.svg", + "display_name": "account 175", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000175" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/8.svg", + "display_name": "account 176", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000176" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/9.svg", + "display_name": "account 177", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000177" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/10.svg", + "display_name": "account 178", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000178" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/11.svg", + "display_name": "account 179", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000179" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/12.svg", + "display_name": "account 180", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000180" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/13.svg", + "display_name": "account 181", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000181" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/14.svg", + "display_name": "account 182", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000182" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/15.svg", + "display_name": "account 183", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000183" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/16.svg", + "display_name": "account 184", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000184" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/17.svg", + "display_name": "account 185", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000185" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/18.svg", + "display_name": "account 186", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000186" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/19.svg", + "display_name": "account 187", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000187" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/20.svg", + "display_name": "account 188", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000188" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/21.svg", + "display_name": "account 189", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000189" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/1.svg", + "display_name": "account 190", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000190" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/2.svg", + "display_name": "account 191", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000191" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/3.svg", + "display_name": "account 192", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000192" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/4.svg", + "display_name": "account 193", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000193" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/5.svg", + "display_name": "account 194", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000194" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/6.svg", + "display_name": "account 195", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000195" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/7.svg", + "display_name": "account 196", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000196" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/8.svg", + "display_name": "account 197", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000197" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/9.svg", + "display_name": "account 198", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000198" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/10.svg", + "display_name": "account 199", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561199000000199" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/11.svg", + "display_name": "account 200", + "vac_banned": false, + "trade_ban_state": "none" + } + } + ], + "steam_get_profile_infos": [ + { + "args": { + "accountIds": [ + "76561199000000000", + "76561199000000001", + "76561199000000002", + "76561199000000003", + "76561199000000004", + "76561199000000005", + "76561199000000006", + "76561199000000007", + "76561199000000008", + "76561199000000009", + "76561199000000010", + "76561199000000011", + "76561199000000012", + "76561199000000013", + "76561199000000014", + "76561199000000015", + "76561199000000016", + "76561199000000017", + "76561199000000018", + "76561199000000019", + "76561199000000020", + "76561199000000021", + "76561199000000022", + "76561199000000023", + "76561199000000024", + "76561199000000025", + "76561199000000026", + "76561199000000027", + "76561199000000028", + "76561199000000029", + "76561199000000030", + "76561199000000031", + "76561199000000032", + "76561199000000033", + "76561199000000034", + "76561199000000035", + "76561199000000036", + "76561199000000037", + "76561199000000038", + "76561199000000039", + "76561199000000040", + "76561199000000041", + "76561199000000042", + "76561199000000043", + "76561199000000044", + "76561199000000045", + "76561199000000046", + "76561199000000047", + "76561199000000048", + "76561199000000049", + "76561199000000050", + "76561199000000051", + "76561199000000052", + "76561199000000053", + "76561199000000054", + "76561199000000055", + "76561199000000056", + "76561199000000057", + "76561199000000058", + "76561199000000059", + "76561199000000060", + "76561199000000061", + "76561199000000062", + "76561199000000063", + "76561199000000064", + "76561199000000065", + "76561199000000066", + "76561199000000067", + "76561199000000068", + "76561199000000069", + "76561199000000070", + "76561199000000071", + "76561199000000072", + "76561199000000073", + "76561199000000074", + "76561199000000075", + "76561199000000076", + "76561199000000077", + "76561199000000078", + "76561199000000079", + "76561199000000080", + "76561199000000081", + "76561199000000082", + "76561199000000083", + "76561199000000084", + "76561199000000085", + "76561199000000086", + "76561199000000087", + "76561199000000088", + "76561199000000089", + "76561199000000090", + "76561199000000091", + "76561199000000092", + "76561199000000093", + "76561199000000094", + "76561199000000095", + "76561199000000096", + "76561199000000097", + "76561199000000098", + "76561199000000099", + "76561199000000100", + "76561199000000101", + "76561199000000102", + "76561199000000103", + "76561199000000104", + "76561199000000105", + "76561199000000106", + "76561199000000107", + "76561199000000108", + "76561199000000109", + "76561199000000110", + "76561199000000111", + "76561199000000112", + "76561199000000113", + "76561199000000114", + "76561199000000115", + "76561199000000116", + "76561199000000117", + "76561199000000118", + "76561199000000119", + "76561199000000120", + "76561199000000121", + "76561199000000122", + "76561199000000123", + "76561199000000124", + "76561199000000125", + "76561199000000126", + "76561199000000127", + "76561199000000128", + "76561199000000129", + "76561199000000130", + "76561199000000131", + "76561199000000132", + "76561199000000133", + "76561199000000134", + "76561199000000135", + "76561199000000136", + "76561199000000137", + "76561199000000138", + "76561199000000139", + "76561199000000140", + "76561199000000141", + "76561199000000142", + "76561199000000143", + "76561199000000144", + "76561199000000145", + "76561199000000146", + "76561199000000147", + "76561199000000148", + "76561199000000149", + "76561199000000150", + "76561199000000151", + "76561199000000152", + "76561199000000153", + "76561199000000154", + "76561199000000155", + "76561199000000156", + "76561199000000157", + "76561199000000158", + "76561199000000159", + "76561199000000160", + "76561199000000161", + "76561199000000162", + "76561199000000163", + "76561199000000164", + "76561199000000165", + "76561199000000166", + "76561199000000167", + "76561199000000168", + "76561199000000169", + "76561199000000170", + "76561199000000171", + "76561199000000172", + "76561199000000173", + "76561199000000174", + "76561199000000175", + "76561199000000176", + "76561199000000177", + "76561199000000178", + "76561199000000179", + "76561199000000180", + "76561199000000181", + "76561199000000182", + "76561199000000183", + "76561199000000184", + "76561199000000185", + "76561199000000186", + "76561199000000187", + "76561199000000188", + "76561199000000189", + "76561199000000190", + "76561199000000191", + "76561199000000192", + "76561199000000193", + "76561199000000194", + "76561199000000195", + "76561199000000196", + "76561199000000197", + "76561199000000198", + "76561199000000199" + ] + }, + "result": { + "76561199000000000": { + "avatar_url": "src/lib/mock/avatars/1.svg", + "display_name": "account 1", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000001": { + "avatar_url": "src/lib/mock/avatars/2.svg", + "display_name": "account 2", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000002": { + "avatar_url": "src/lib/mock/avatars/3.svg", + "display_name": "account 3", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000003": { + "avatar_url": "src/lib/mock/avatars/4.svg", + "display_name": "account 4", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000004": { + "avatar_url": "src/lib/mock/avatars/5.svg", + "display_name": "account 5", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000005": { + "avatar_url": "src/lib/mock/avatars/6.svg", + "display_name": "account 6", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000006": { + "avatar_url": "src/lib/mock/avatars/7.svg", + "display_name": "account 7", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000007": { + "avatar_url": "src/lib/mock/avatars/8.svg", + "display_name": "account 8", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000008": { + "avatar_url": "src/lib/mock/avatars/9.svg", + "display_name": "account 9", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000009": { + "avatar_url": "src/lib/mock/avatars/10.svg", + "display_name": "account 10", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000010": { + "avatar_url": "src/lib/mock/avatars/11.svg", + "display_name": "account 11", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000011": { + "avatar_url": "src/lib/mock/avatars/12.svg", + "display_name": "account 12", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000012": { + "avatar_url": "src/lib/mock/avatars/13.svg", + "display_name": "account 13", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000013": { + "avatar_url": "src/lib/mock/avatars/14.svg", + "display_name": "account 14", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000014": { + "avatar_url": "src/lib/mock/avatars/15.svg", + "display_name": "account 15", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000015": { + "avatar_url": "src/lib/mock/avatars/16.svg", + "display_name": "account 16", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000016": { + "avatar_url": "src/lib/mock/avatars/17.svg", + "display_name": "account 17", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000017": { + "avatar_url": "src/lib/mock/avatars/18.svg", + "display_name": "account 18", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000018": { + "avatar_url": "src/lib/mock/avatars/19.svg", + "display_name": "account 19", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000019": { + "avatar_url": "src/lib/mock/avatars/20.svg", + "display_name": "account 20", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000020": { + "avatar_url": "src/lib/mock/avatars/21.svg", + "display_name": "account 21", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000021": { + "avatar_url": "src/lib/mock/avatars/1.svg", + "display_name": "account 22", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000022": { + "avatar_url": "src/lib/mock/avatars/2.svg", + "display_name": "account 23", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000023": { + "avatar_url": "src/lib/mock/avatars/3.svg", + "display_name": "account 24", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000024": { + "avatar_url": "src/lib/mock/avatars/4.svg", + "display_name": "account 25", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000025": { + "avatar_url": "src/lib/mock/avatars/5.svg", + "display_name": "account 26", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000026": { + "avatar_url": "src/lib/mock/avatars/6.svg", + "display_name": "account 27", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000027": { + "avatar_url": "src/lib/mock/avatars/7.svg", + "display_name": "account 28", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000028": { + "avatar_url": "src/lib/mock/avatars/8.svg", + "display_name": "account 29", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000029": { + "avatar_url": "src/lib/mock/avatars/9.svg", + "display_name": "account 30", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000030": { + "avatar_url": "src/lib/mock/avatars/10.svg", + "display_name": "account 31", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000031": { + "avatar_url": "src/lib/mock/avatars/11.svg", + "display_name": "account 32", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000032": { + "avatar_url": "src/lib/mock/avatars/12.svg", + "display_name": "account 33", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000033": { + "avatar_url": "src/lib/mock/avatars/13.svg", + "display_name": "account 34", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000034": { + "avatar_url": "src/lib/mock/avatars/14.svg", + "display_name": "account 35", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000035": { + "avatar_url": "src/lib/mock/avatars/15.svg", + "display_name": "account 36", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000036": { + "avatar_url": "src/lib/mock/avatars/16.svg", + "display_name": "account 37", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000037": { + "avatar_url": "src/lib/mock/avatars/17.svg", + "display_name": "account 38", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000038": { + "avatar_url": "src/lib/mock/avatars/18.svg", + "display_name": "account 39", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000039": { + "avatar_url": "src/lib/mock/avatars/19.svg", + "display_name": "account 40", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000040": { + "avatar_url": "src/lib/mock/avatars/20.svg", + "display_name": "account 41", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000041": { + "avatar_url": "src/lib/mock/avatars/21.svg", + "display_name": "account 42", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000042": { + "avatar_url": "src/lib/mock/avatars/1.svg", + "display_name": "account 43", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000043": { + "avatar_url": "src/lib/mock/avatars/2.svg", + "display_name": "account 44", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000044": { + "avatar_url": "src/lib/mock/avatars/3.svg", + "display_name": "account 45", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000045": { + "avatar_url": "src/lib/mock/avatars/4.svg", + "display_name": "account 46", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000046": { + "avatar_url": "src/lib/mock/avatars/5.svg", + "display_name": "account 47", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000047": { + "avatar_url": "src/lib/mock/avatars/6.svg", + "display_name": "account 48", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000048": { + "avatar_url": "src/lib/mock/avatars/7.svg", + "display_name": "account 49", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000049": { + "avatar_url": "src/lib/mock/avatars/8.svg", + "display_name": "account 50", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000050": { + "avatar_url": "src/lib/mock/avatars/9.svg", + "display_name": "account 51", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000051": { + "avatar_url": "src/lib/mock/avatars/10.svg", + "display_name": "account 52", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000052": { + "avatar_url": "src/lib/mock/avatars/11.svg", + "display_name": "account 53", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000053": { + "avatar_url": "src/lib/mock/avatars/12.svg", + "display_name": "account 54", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000054": { + "avatar_url": "src/lib/mock/avatars/13.svg", + "display_name": "account 55", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000055": { + "avatar_url": "src/lib/mock/avatars/14.svg", + "display_name": "account 56", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000056": { + "avatar_url": "src/lib/mock/avatars/15.svg", + "display_name": "account 57", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000057": { + "avatar_url": "src/lib/mock/avatars/16.svg", + "display_name": "account 58", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000058": { + "avatar_url": "src/lib/mock/avatars/17.svg", + "display_name": "account 59", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000059": { + "avatar_url": "src/lib/mock/avatars/18.svg", + "display_name": "account 60", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000060": { + "avatar_url": "src/lib/mock/avatars/19.svg", + "display_name": "account 61", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000061": { + "avatar_url": "src/lib/mock/avatars/20.svg", + "display_name": "account 62", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000062": { + "avatar_url": "src/lib/mock/avatars/21.svg", + "display_name": "account 63", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000063": { + "avatar_url": "src/lib/mock/avatars/1.svg", + "display_name": "account 64", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000064": { + "avatar_url": "src/lib/mock/avatars/2.svg", + "display_name": "account 65", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000065": { + "avatar_url": "src/lib/mock/avatars/3.svg", + "display_name": "account 66", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000066": { + "avatar_url": "src/lib/mock/avatars/4.svg", + "display_name": "account 67", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000067": { + "avatar_url": "src/lib/mock/avatars/5.svg", + "display_name": "account 68", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000068": { + "avatar_url": "src/lib/mock/avatars/6.svg", + "display_name": "account 69", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000069": { + "avatar_url": "src/lib/mock/avatars/7.svg", + "display_name": "account 70", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000070": { + "avatar_url": "src/lib/mock/avatars/8.svg", + "display_name": "account 71", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000071": { + "avatar_url": "src/lib/mock/avatars/9.svg", + "display_name": "account 72", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000072": { + "avatar_url": "src/lib/mock/avatars/10.svg", + "display_name": "account 73", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000073": { + "avatar_url": "src/lib/mock/avatars/11.svg", + "display_name": "account 74", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000074": { + "avatar_url": "src/lib/mock/avatars/12.svg", + "display_name": "account 75", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000075": { + "avatar_url": "src/lib/mock/avatars/13.svg", + "display_name": "account 76", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000076": { + "avatar_url": "src/lib/mock/avatars/14.svg", + "display_name": "account 77", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000077": { + "avatar_url": "src/lib/mock/avatars/15.svg", + "display_name": "account 78", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000078": { + "avatar_url": "src/lib/mock/avatars/16.svg", + "display_name": "account 79", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000079": { + "avatar_url": "src/lib/mock/avatars/17.svg", + "display_name": "account 80", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000080": { + "avatar_url": "src/lib/mock/avatars/18.svg", + "display_name": "account 81", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000081": { + "avatar_url": "src/lib/mock/avatars/19.svg", + "display_name": "account 82", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000082": { + "avatar_url": "src/lib/mock/avatars/20.svg", + "display_name": "account 83", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000083": { + "avatar_url": "src/lib/mock/avatars/21.svg", + "display_name": "account 84", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000084": { + "avatar_url": "src/lib/mock/avatars/1.svg", + "display_name": "account 85", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000085": { + "avatar_url": "src/lib/mock/avatars/2.svg", + "display_name": "account 86", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000086": { + "avatar_url": "src/lib/mock/avatars/3.svg", + "display_name": "account 87", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000087": { + "avatar_url": "src/lib/mock/avatars/4.svg", + "display_name": "account 88", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000088": { + "avatar_url": "src/lib/mock/avatars/5.svg", + "display_name": "account 89", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000089": { + "avatar_url": "src/lib/mock/avatars/6.svg", + "display_name": "account 90", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000090": { + "avatar_url": "src/lib/mock/avatars/7.svg", + "display_name": "account 91", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000091": { + "avatar_url": "src/lib/mock/avatars/8.svg", + "display_name": "account 92", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000092": { + "avatar_url": "src/lib/mock/avatars/9.svg", + "display_name": "account 93", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000093": { + "avatar_url": "src/lib/mock/avatars/10.svg", + "display_name": "account 94", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000094": { + "avatar_url": "src/lib/mock/avatars/11.svg", + "display_name": "account 95", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000095": { + "avatar_url": "src/lib/mock/avatars/12.svg", + "display_name": "account 96", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000096": { + "avatar_url": "src/lib/mock/avatars/13.svg", + "display_name": "account 97", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000097": { + "avatar_url": "src/lib/mock/avatars/14.svg", + "display_name": "account 98", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000098": { + "avatar_url": "src/lib/mock/avatars/15.svg", + "display_name": "account 99", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000099": { + "avatar_url": "src/lib/mock/avatars/16.svg", + "display_name": "account 100", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000100": { + "avatar_url": "src/lib/mock/avatars/17.svg", + "display_name": "account 101", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000101": { + "avatar_url": "src/lib/mock/avatars/18.svg", + "display_name": "account 102", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000102": { + "avatar_url": "src/lib/mock/avatars/19.svg", + "display_name": "account 103", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000103": { + "avatar_url": "src/lib/mock/avatars/20.svg", + "display_name": "account 104", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000104": { + "avatar_url": "src/lib/mock/avatars/21.svg", + "display_name": "account 105", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000105": { + "avatar_url": "src/lib/mock/avatars/1.svg", + "display_name": "account 106", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000106": { + "avatar_url": "src/lib/mock/avatars/2.svg", + "display_name": "account 107", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000107": { + "avatar_url": "src/lib/mock/avatars/3.svg", + "display_name": "account 108", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000108": { + "avatar_url": "src/lib/mock/avatars/4.svg", + "display_name": "account 109", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000109": { + "avatar_url": "src/lib/mock/avatars/5.svg", + "display_name": "account 110", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000110": { + "avatar_url": "src/lib/mock/avatars/6.svg", + "display_name": "account 111", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000111": { + "avatar_url": "src/lib/mock/avatars/7.svg", + "display_name": "account 112", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000112": { + "avatar_url": "src/lib/mock/avatars/8.svg", + "display_name": "account 113", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000113": { + "avatar_url": "src/lib/mock/avatars/9.svg", + "display_name": "account 114", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000114": { + "avatar_url": "src/lib/mock/avatars/10.svg", + "display_name": "account 115", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000115": { + "avatar_url": "src/lib/mock/avatars/11.svg", + "display_name": "account 116", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000116": { + "avatar_url": "src/lib/mock/avatars/12.svg", + "display_name": "account 117", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000117": { + "avatar_url": "src/lib/mock/avatars/13.svg", + "display_name": "account 118", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000118": { + "avatar_url": "src/lib/mock/avatars/14.svg", + "display_name": "account 119", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000119": { + "avatar_url": "src/lib/mock/avatars/15.svg", + "display_name": "account 120", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000120": { + "avatar_url": "src/lib/mock/avatars/16.svg", + "display_name": "account 121", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000121": { + "avatar_url": "src/lib/mock/avatars/17.svg", + "display_name": "account 122", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000122": { + "avatar_url": "src/lib/mock/avatars/18.svg", + "display_name": "account 123", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000123": { + "avatar_url": "src/lib/mock/avatars/19.svg", + "display_name": "account 124", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000124": { + "avatar_url": "src/lib/mock/avatars/20.svg", + "display_name": "account 125", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000125": { + "avatar_url": "src/lib/mock/avatars/21.svg", + "display_name": "account 126", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000126": { + "avatar_url": "src/lib/mock/avatars/1.svg", + "display_name": "account 127", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000127": { + "avatar_url": "src/lib/mock/avatars/2.svg", + "display_name": "account 128", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000128": { + "avatar_url": "src/lib/mock/avatars/3.svg", + "display_name": "account 129", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000129": { + "avatar_url": "src/lib/mock/avatars/4.svg", + "display_name": "account 130", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000130": { + "avatar_url": "src/lib/mock/avatars/5.svg", + "display_name": "account 131", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000131": { + "avatar_url": "src/lib/mock/avatars/6.svg", + "display_name": "account 132", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000132": { + "avatar_url": "src/lib/mock/avatars/7.svg", + "display_name": "account 133", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000133": { + "avatar_url": "src/lib/mock/avatars/8.svg", + "display_name": "account 134", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000134": { + "avatar_url": "src/lib/mock/avatars/9.svg", + "display_name": "account 135", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000135": { + "avatar_url": "src/lib/mock/avatars/10.svg", + "display_name": "account 136", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000136": { + "avatar_url": "src/lib/mock/avatars/11.svg", + "display_name": "account 137", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000137": { + "avatar_url": "src/lib/mock/avatars/12.svg", + "display_name": "account 138", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000138": { + "avatar_url": "src/lib/mock/avatars/13.svg", + "display_name": "account 139", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000139": { + "avatar_url": "src/lib/mock/avatars/14.svg", + "display_name": "account 140", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000140": { + "avatar_url": "src/lib/mock/avatars/15.svg", + "display_name": "account 141", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000141": { + "avatar_url": "src/lib/mock/avatars/16.svg", + "display_name": "account 142", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000142": { + "avatar_url": "src/lib/mock/avatars/17.svg", + "display_name": "account 143", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000143": { + "avatar_url": "src/lib/mock/avatars/18.svg", + "display_name": "account 144", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000144": { + "avatar_url": "src/lib/mock/avatars/19.svg", + "display_name": "account 145", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000145": { + "avatar_url": "src/lib/mock/avatars/20.svg", + "display_name": "account 146", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000146": { + "avatar_url": "src/lib/mock/avatars/21.svg", + "display_name": "account 147", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000147": { + "avatar_url": "src/lib/mock/avatars/1.svg", + "display_name": "account 148", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000148": { + "avatar_url": "src/lib/mock/avatars/2.svg", + "display_name": "account 149", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000149": { + "avatar_url": "src/lib/mock/avatars/3.svg", + "display_name": "account 150", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000150": { + "avatar_url": "src/lib/mock/avatars/4.svg", + "display_name": "account 151", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000151": { + "avatar_url": "src/lib/mock/avatars/5.svg", + "display_name": "account 152", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000152": { + "avatar_url": "src/lib/mock/avatars/6.svg", + "display_name": "account 153", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000153": { + "avatar_url": "src/lib/mock/avatars/7.svg", + "display_name": "account 154", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000154": { + "avatar_url": "src/lib/mock/avatars/8.svg", + "display_name": "account 155", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000155": { + "avatar_url": "src/lib/mock/avatars/9.svg", + "display_name": "account 156", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000156": { + "avatar_url": "src/lib/mock/avatars/10.svg", + "display_name": "account 157", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000157": { + "avatar_url": "src/lib/mock/avatars/11.svg", + "display_name": "account 158", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000158": { + "avatar_url": "src/lib/mock/avatars/12.svg", + "display_name": "account 159", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000159": { + "avatar_url": "src/lib/mock/avatars/13.svg", + "display_name": "account 160", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000160": { + "avatar_url": "src/lib/mock/avatars/14.svg", + "display_name": "account 161", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000161": { + "avatar_url": "src/lib/mock/avatars/15.svg", + "display_name": "account 162", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000162": { + "avatar_url": "src/lib/mock/avatars/16.svg", + "display_name": "account 163", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000163": { + "avatar_url": "src/lib/mock/avatars/17.svg", + "display_name": "account 164", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000164": { + "avatar_url": "src/lib/mock/avatars/18.svg", + "display_name": "account 165", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000165": { + "avatar_url": "src/lib/mock/avatars/19.svg", + "display_name": "account 166", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000166": { + "avatar_url": "src/lib/mock/avatars/20.svg", + "display_name": "account 167", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000167": { + "avatar_url": "src/lib/mock/avatars/21.svg", + "display_name": "account 168", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000168": { + "avatar_url": "src/lib/mock/avatars/1.svg", + "display_name": "account 169", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000169": { + "avatar_url": "src/lib/mock/avatars/2.svg", + "display_name": "account 170", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000170": { + "avatar_url": "src/lib/mock/avatars/3.svg", + "display_name": "account 171", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000171": { + "avatar_url": "src/lib/mock/avatars/4.svg", + "display_name": "account 172", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000172": { + "avatar_url": "src/lib/mock/avatars/5.svg", + "display_name": "account 173", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000173": { + "avatar_url": "src/lib/mock/avatars/6.svg", + "display_name": "account 174", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000174": { + "avatar_url": "src/lib/mock/avatars/7.svg", + "display_name": "account 175", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000175": { + "avatar_url": "src/lib/mock/avatars/8.svg", + "display_name": "account 176", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000176": { + "avatar_url": "src/lib/mock/avatars/9.svg", + "display_name": "account 177", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000177": { + "avatar_url": "src/lib/mock/avatars/10.svg", + "display_name": "account 178", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000178": { + "avatar_url": "src/lib/mock/avatars/11.svg", + "display_name": "account 179", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000179": { + "avatar_url": "src/lib/mock/avatars/12.svg", + "display_name": "account 180", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000180": { + "avatar_url": "src/lib/mock/avatars/13.svg", + "display_name": "account 181", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000181": { + "avatar_url": "src/lib/mock/avatars/14.svg", + "display_name": "account 182", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000182": { + "avatar_url": "src/lib/mock/avatars/15.svg", + "display_name": "account 183", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000183": { + "avatar_url": "src/lib/mock/avatars/16.svg", + "display_name": "account 184", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000184": { + "avatar_url": "src/lib/mock/avatars/17.svg", + "display_name": "account 185", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000185": { + "avatar_url": "src/lib/mock/avatars/18.svg", + "display_name": "account 186", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000186": { + "avatar_url": "src/lib/mock/avatars/19.svg", + "display_name": "account 187", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000187": { + "avatar_url": "src/lib/mock/avatars/20.svg", + "display_name": "account 188", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000188": { + "avatar_url": "src/lib/mock/avatars/21.svg", + "display_name": "account 189", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000189": { + "avatar_url": "src/lib/mock/avatars/1.svg", + "display_name": "account 190", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000190": { + "avatar_url": "src/lib/mock/avatars/2.svg", + "display_name": "account 191", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000191": { + "avatar_url": "src/lib/mock/avatars/3.svg", + "display_name": "account 192", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000192": { + "avatar_url": "src/lib/mock/avatars/4.svg", + "display_name": "account 193", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000193": { + "avatar_url": "src/lib/mock/avatars/5.svg", + "display_name": "account 194", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000194": { + "avatar_url": "src/lib/mock/avatars/6.svg", + "display_name": "account 195", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000195": { + "avatar_url": "src/lib/mock/avatars/7.svg", + "display_name": "account 196", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000196": { + "avatar_url": "src/lib/mock/avatars/8.svg", + "display_name": "account 197", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000197": { + "avatar_url": "src/lib/mock/avatars/9.svg", + "display_name": "account 198", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000198": { + "avatar_url": "src/lib/mock/avatars/10.svg", + "display_name": "account 199", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561199000000199": { + "avatar_url": "src/lib/mock/avatars/11.svg", + "display_name": "account 200", + "vac_banned": false, + "trade_ban_state": "none" + } + } + } + ], + "steam_has_api_key": [ + { + "args": {}, + "result": true + } + ], + "steam_open_api_key_page": [ + { + "args": {}, + "result": null + } + ], + "steam_open_userdata": [ + { + "args": {}, + "result": null + } + ], + "telemetry_get_state": [ + { + "args": {}, + "result": { + "mode_a_enabled": false, + "mode_b_enabled": false, + "install_id_set": false, + "forget_pending": false, + "onboarding_completed": true + } + } + ] + }, + "skipped": { + "pin_lock": "PIN session state", + "pin_unlock": "PIN session state, reads the live settings store", + "platform_switch_account": "changes the current account after switchDelayMs; modelled from spec" + } +} diff --git a/crates/accshift-egui/parity/data/slow.json b/crates/accshift-egui/parity/data/slow.json new file mode 100644 index 0000000..a113fe3 --- /dev/null +++ b/crates/accshift-egui/parity/data/slow.json @@ -0,0 +1,984 @@ +{ + "format": 1, + "scenario": "slow", + "now": 1791000000, + "spec": { + "label": "dev dataset with a 4s switch — spinners and pending states", + "runtimeOs": "windows", + "switchDelayMs": 4000, + "steamPath": "C:\\Program Files (x86)\\Steam", + "hasSteamApiKey": true, + "currentSteamAccount": "76561198100000001", + "currentRiotProfile": "riot-1", + "currentRobloxAccount": "" + }, + "commands": { + "cs2_bridge_get_settings": [ + { + "args": {}, + "result": { + "enabled": false, + "url": "", + "apiKey": "" + } + } + ], + "descriptor_install_file": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "descriptor_preview_file": [ + { + "args": {}, + "error": "a mock session adds no platform" + } + ], + "descriptor_remove": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "descriptor_select_file": [ + { + "args": {}, + "error": "cancelled" + } + ], + "detect_streaming_software": [ + { + "args": {}, + "result": false + } + ], + "get_boot_payload": [ + { + "args": {}, + "result": { + "migration": "skipped", + "runtimeOs": "windows", + "storageSnapshot": { + "manifest": { + "schemaVersion": 1, + "stores": {} + }, + "stores": { + "client.settings": { + "language": "en", + "animations": "on", + "streamerMode": "off", + "inactivityBlurSeconds": 0, + "pinEnabled": false, + "enabledPlatforms": ["steam", "riot"], + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": true + } + }, + "client.folders": { + "version": 1, + "folders": [ + { + "id": "dev-folder-outer", + "name": "Outer folder", + "parentId": null, + "platform": "steam" + }, + { + "id": "dev-folder-inner", + "name": "Inner folder", + "parentId": "dev-folder-outer", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198100000001" + }, + { + "type": "account", + "id": "76561198100000002" + }, + { + "type": "account", + "id": "76561198100000003" + }, + { + "type": "account", + "id": "76561198100000004" + }, + { + "type": "account", + "id": "76561198100000005" + }, + { + "type": "account", + "id": "76561198100000006" + }, + { + "type": "folder", + "id": "dev-folder-outer" + } + ], + "dev-folder-outer": [ + { + "type": "account", + "id": "76561198100000007" + }, + { + "type": "folder", + "id": "dev-folder-inner" + } + ], + "dev-folder-inner": [ + { + "type": "account", + "id": "76561198100000008" + } + ] + } + }, + "client.personas": [], + "client.account-card-notes": { + "76561198100000001": "short note", + "76561198100000005": "a note that runs long enough to test wrapping and truncation on an inline card note" + }, + "client.account-card-colors": { + "76561198100000001": "#3b82f6", + "76561198100000003": "#f43f5e", + "76561198100000005": "#10b981" + }, + "client.account-default-game": {}, + "client.folder-card-colors": {}, + "client.view-mode": "grid", + "cache.steam.profiles": { + "76561198100000001": { + "url": "src/lib/mock/avatars/1.svg", + "displayName": "main", + "timestamp": 1791000000000 + }, + "76561198100000003": { + "url": "src/lib/mock/avatars/3.svg", + "displayName": "vac banned", + "timestamp": 1791000000000 + }, + "76561198100000004": { + "url": "src/lib/mock/avatars/4.svg", + "displayName": "trade banned", + "timestamp": 1791000000000 + }, + "76561198100000005": { + "url": "src/lib/mock/avatars/5.svg", + "displayName": "a persona name long enough to overflow every card layout", + "timestamp": 1791000000000 + }, + "76561198100000006": { + "url": "src/lib/mock/avatars/6.svg", + "displayName": "never logged in", + "timestamp": 1791000000000 + }, + "76561198100000007": { + "url": "src/lib/mock/avatars/7.svg", + "displayName": "nested account", + "timestamp": 1791000000000 + }, + "76561198100000008": { + "url": "src/lib/mock/avatars/2.svg", + "displayName": "deeply nested", + "timestamp": 1791000000000 + } + }, + "cache.roblox.profiles": {}, + "cache.steam.ban-check-state": {}, + "cache.steam.ban-info-cache": {} + } + }, + "customThemes": [], + "userPlatforms": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + } + ], + "get_desktop_wallpaper": [ + { + "args": {}, + "result": null + } + ], + "get_runtime_os": [ + { + "args": {}, + "result": "windows" + } + ], + "get_storage_manifest": [ + { + "args": {}, + "result": { + "schemaVersion": 1, + "stores": {} + } + } + ], + "list_custom_themes": [ + { + "args": {}, + "result": [] + } + ], + "load_client_storage_snapshot": [ + { + "args": {}, + "result": { + "manifest": { + "schemaVersion": 1, + "stores": {} + }, + "stores": { + "client.settings": { + "language": "en", + "animations": "on", + "streamerMode": "off", + "inactivityBlurSeconds": 0, + "pinEnabled": false, + "enabledPlatforms": ["steam", "riot"], + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": true + } + }, + "client.folders": { + "version": 1, + "folders": [ + { + "id": "dev-folder-outer", + "name": "Outer folder", + "parentId": null, + "platform": "steam" + }, + { + "id": "dev-folder-inner", + "name": "Inner folder", + "parentId": "dev-folder-outer", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198100000001" + }, + { + "type": "account", + "id": "76561198100000002" + }, + { + "type": "account", + "id": "76561198100000003" + }, + { + "type": "account", + "id": "76561198100000004" + }, + { + "type": "account", + "id": "76561198100000005" + }, + { + "type": "account", + "id": "76561198100000006" + }, + { + "type": "folder", + "id": "dev-folder-outer" + } + ], + "dev-folder-outer": [ + { + "type": "account", + "id": "76561198100000007" + }, + { + "type": "folder", + "id": "dev-folder-inner" + } + ], + "dev-folder-inner": [ + { + "type": "account", + "id": "76561198100000008" + } + ] + } + }, + "client.personas": [], + "client.account-card-notes": { + "76561198100000001": "short note", + "76561198100000005": "a note that runs long enough to test wrapping and truncation on an inline card note" + }, + "client.account-card-colors": { + "76561198100000001": "#3b82f6", + "76561198100000003": "#f43f5e", + "76561198100000005": "#10b981" + }, + "client.account-default-game": {}, + "client.folder-card-colors": {}, + "client.view-mode": "grid", + "cache.steam.profiles": { + "76561198100000001": { + "url": "src/lib/mock/avatars/1.svg", + "displayName": "main", + "timestamp": 1791000000000 + }, + "76561198100000003": { + "url": "src/lib/mock/avatars/3.svg", + "displayName": "vac banned", + "timestamp": 1791000000000 + }, + "76561198100000004": { + "url": "src/lib/mock/avatars/4.svg", + "displayName": "trade banned", + "timestamp": 1791000000000 + }, + "76561198100000005": { + "url": "src/lib/mock/avatars/5.svg", + "displayName": "a persona name long enough to overflow every card layout", + "timestamp": 1791000000000 + }, + "76561198100000006": { + "url": "src/lib/mock/avatars/6.svg", + "displayName": "never logged in", + "timestamp": 1791000000000 + }, + "76561198100000007": { + "url": "src/lib/mock/avatars/7.svg", + "displayName": "nested account", + "timestamp": 1791000000000 + }, + "76561198100000008": { + "url": "src/lib/mock/avatars/2.svg", + "displayName": "deeply nested", + "timestamp": 1791000000000 + } + }, + "cache.roblox.profiles": {}, + "cache.steam.ban-check-state": {}, + "cache.steam.ban-info-cache": {} + } + } + } + ], + "migrate_legacy_config": [ + { + "args": {}, + "result": "skipped" + } + ], + "open_descriptors_folder": [ + { + "args": {}, + "result": null + } + ], + "open_logs_folder": [ + { + "args": {}, + "result": null + } + ], + "open_url": [ + { + "args": {}, + "result": null + } + ], + "platform_detect_installed": [ + { + "args": {}, + "result": ["steam", "riot"] + } + ], + "platform_dry_run": [ + { + "args": { + "platformId": "steam", + "accountId": "76561198100000001" + }, + "result": { + "platformId": "steam", + "operation": "switch", + "accountId": "76561198100000001", + "applied": false, + "roots": [], + "steps": [], + "warnings": [] + } + } + ], + "platform_get_accounts": [ + { + "args": { + "platformId": "steam" + }, + "result": [ + { + "steam_id": "76561198100000001", + "account_name": "mainacct", + "persona_name": "main", + "last_login_at": 1790996400 + }, + { + "steam_id": "76561198100000002", + "account_name": "no_avatar_here", + "persona_name": "no avatar", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561198100000003", + "account_name": "banned_one", + "persona_name": "vac banned", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561198100000004", + "account_name": "trade_locked", + "persona_name": "trade banned", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561198100000005", + "account_name": "overflow_test_account_name", + "persona_name": "a persona name long enough to overflow every card layout", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561198100000006", + "account_name": "never_used", + "persona_name": "never logged in", + "last_login_at": null + }, + { + "steam_id": "76561198100000007", + "account_name": "nested_one", + "persona_name": "nested account", + "last_login_at": 1788408000 + }, + { + "steam_id": "76561198100000008", + "account_name": "nested_two", + "persona_name": "deeply nested", + "last_login_at": 1787198400 + } + ] + }, + { + "args": { + "platformId": "riot" + }, + "result": [ + { + "id": "riot-1", + "label": "ready", + "snapshot_state": "ready", + "last_used_at": 1790996400000 + }, + { + "id": "riot-2", + "label": "capturing", + "snapshot_state": "capturing", + "last_used_at": null + }, + { + "id": "riot-3", + "label": "awaiting", + "snapshot_state": "awaiting_capture", + "last_used_at": null + }, + { + "id": "riot-4", + "label": "setup pending", + "snapshot_state": "setup_pending", + "last_used_at": null + } + ] + }, + { + "args": { + "platformId": "roblox" + }, + "result": [] + } + ], + "platform_get_current_account": [ + { + "args": { + "platformId": "steam" + }, + "result": "76561198100000001" + }, + { + "args": { + "platformId": "riot" + }, + "result": "riot-1" + }, + { + "args": { + "platformId": "roblox" + }, + "result": "" + } + ], + "platform_get_path": [ + { + "args": {}, + "result": "C:\\Program Files (x86)\\Steam" + } + ], + "platform_get_startup_snapshot": [ + { + "args": { + "platformId": "steam" + }, + "result": { + "accounts": [ + { + "steam_id": "76561198100000001", + "account_name": "mainacct", + "persona_name": "main", + "last_login_at": 1790996400 + }, + { + "steam_id": "76561198100000002", + "account_name": "no_avatar_here", + "persona_name": "no avatar", + "last_login_at": 1790827200 + }, + { + "steam_id": "76561198100000003", + "account_name": "banned_one", + "persona_name": "vac banned", + "last_login_at": 1790568000 + }, + { + "steam_id": "76561198100000004", + "account_name": "trade_locked", + "persona_name": "trade banned", + "last_login_at": 1790308800 + }, + { + "steam_id": "76561198100000005", + "account_name": "overflow_test_account_name", + "persona_name": "a persona name long enough to overflow every card layout", + "last_login_at": 1789790400 + }, + { + "steam_id": "76561198100000006", + "account_name": "never_used", + "persona_name": "never logged in", + "last_login_at": null + }, + { + "steam_id": "76561198100000007", + "account_name": "nested_one", + "persona_name": "nested account", + "last_login_at": 1788408000 + }, + { + "steam_id": "76561198100000008", + "account_name": "nested_two", + "persona_name": "deeply nested", + "last_login_at": 1787198400 + } + ], + "currentAccount": "76561198100000001" + } + }, + { + "args": { + "platformId": "riot" + }, + "result": { + "profiles": [ + { + "id": "riot-1", + "label": "ready", + "snapshot_state": "ready", + "last_used_at": 1790996400000 + }, + { + "id": "riot-2", + "label": "capturing", + "snapshot_state": "capturing", + "last_used_at": null + }, + { + "id": "riot-3", + "label": "awaiting", + "snapshot_state": "awaiting_capture", + "last_used_at": null + }, + { + "id": "riot-4", + "label": "setup pending", + "snapshot_state": "setup_pending", + "last_used_at": null + } + ], + "currentProfile": "riot-1" + } + }, + { + "args": { + "platformId": "roblox" + }, + "result": { + "accounts": [], + "currentAccount": "" + } + } + ], + "platform_set_account_label": [ + { + "args": {}, + "result": null + } + ], + "reload_user_platforms": [ + { + "args": {}, + "result": { + "dir": "C:\\Users\\player\\AppData\\Roaming\\accshift\\platforms", + "loaded": [], + "skipped": [], + "rejected": [] + } + } + ], + "roblox_check_sessions": [ + { + "args": {}, + "result": [] + } + ], + "roblox_get_profile_info": [], + "save_client_storage_store": [ + { + "args": {}, + "result": null + } + ], + "steam_get_account_games": [ + { + "args": {}, + "result": [] + } + ], + "steam_get_copyable_games": [ + { + "args": {}, + "result": [] + } + ], + "steam_get_player_bans": [ + { + "args": { + "steamIds": [ + "76561198100000001", + "76561198100000002", + "76561198100000003", + "76561198100000004", + "76561198100000005", + "76561198100000006", + "76561198100000007", + "76561198100000008" + ] + }, + "result": [ + { + "steam_id": "76561198100000001", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198100000002", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198100000003", + "community_banned": false, + "vac_banned": true, + "number_of_vac_bans": 1, + "days_since_last_ban": 412, + "number_of_game_bans": 1, + "economy_ban": "none" + }, + { + "steam_id": "76561198100000004", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "banned" + }, + { + "steam_id": "76561198100000005", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198100000006", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198100000007", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + }, + { + "steam_id": "76561198100000008", + "community_banned": false, + "vac_banned": false, + "number_of_vac_bans": 0, + "days_since_last_ban": 0, + "number_of_game_bans": 0, + "economy_ban": "none" + } + ] + } + ], + "steam_get_profile_info": [ + { + "args": { + "accountId": "76561198100000001" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/1.svg", + "display_name": "main", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198100000002" + }, + "result": { + "avatar_url": null, + "display_name": "no avatar", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198100000003" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/3.svg", + "display_name": "vac banned", + "vac_banned": true, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198100000004" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/4.svg", + "display_name": "trade banned", + "vac_banned": false, + "trade_ban_state": "banned" + } + }, + { + "args": { + "accountId": "76561198100000005" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/5.svg", + "display_name": "a persona name long enough to overflow every card layout", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198100000006" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/6.svg", + "display_name": "never logged in", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198100000007" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/7.svg", + "display_name": "nested account", + "vac_banned": false, + "trade_ban_state": "none" + } + }, + { + "args": { + "accountId": "76561198100000008" + }, + "result": { + "avatar_url": "src/lib/mock/avatars/2.svg", + "display_name": "deeply nested", + "vac_banned": false, + "trade_ban_state": "none" + } + } + ], + "steam_get_profile_infos": [ + { + "args": { + "accountIds": [ + "76561198100000001", + "76561198100000002", + "76561198100000003", + "76561198100000004", + "76561198100000005", + "76561198100000006", + "76561198100000007", + "76561198100000008" + ] + }, + "result": { + "76561198100000001": { + "avatar_url": "src/lib/mock/avatars/1.svg", + "display_name": "main", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198100000002": { + "avatar_url": null, + "display_name": "no avatar", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198100000003": { + "avatar_url": "src/lib/mock/avatars/3.svg", + "display_name": "vac banned", + "vac_banned": true, + "trade_ban_state": "none" + }, + "76561198100000004": { + "avatar_url": "src/lib/mock/avatars/4.svg", + "display_name": "trade banned", + "vac_banned": false, + "trade_ban_state": "banned" + }, + "76561198100000005": { + "avatar_url": "src/lib/mock/avatars/5.svg", + "display_name": "a persona name long enough to overflow every card layout", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198100000006": { + "avatar_url": "src/lib/mock/avatars/6.svg", + "display_name": "never logged in", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198100000007": { + "avatar_url": "src/lib/mock/avatars/7.svg", + "display_name": "nested account", + "vac_banned": false, + "trade_ban_state": "none" + }, + "76561198100000008": { + "avatar_url": "src/lib/mock/avatars/2.svg", + "display_name": "deeply nested", + "vac_banned": false, + "trade_ban_state": "none" + } + } + } + ], + "steam_has_api_key": [ + { + "args": {}, + "result": true + } + ], + "steam_open_api_key_page": [ + { + "args": {}, + "result": null + } + ], + "steam_open_userdata": [ + { + "args": {}, + "result": null + } + ], + "telemetry_get_state": [ + { + "args": {}, + "result": { + "mode_a_enabled": false, + "mode_b_enabled": false, + "install_id_set": false, + "forget_pending": false, + "onboarding_completed": true + } + } + ] + }, + "skipped": { + "pin_lock": "PIN session state", + "pin_unlock": "PIN session state, reads the live settings store", + "platform_switch_account": "changes the current account after switchDelayMs; modelled from spec" + } +} diff --git a/crates/accshift-egui/parity/diff.mjs b/crates/accshift-egui/parity/diff.mjs new file mode 100644 index 0000000..6d56598 --- /dev/null +++ b/crates/accshift-egui/parity/diff.mjs @@ -0,0 +1,337 @@ +// Pixel diff of the parity bench: reference frames (ref.mjs) against egui +// frames. Node and ffmpeg only (ffmpeg on PATH, or FFMPEG=). +// +// node diff.mjs --out +// node diff.mjs --batch --out +// +// Both images are decoded to raw RGBA by ffmpeg (PNG or PAM in), then each is +// composited over black so a transparent pixel compares as what an opaque +// window would show. Per shot: +// mae mean absolute error over R, G and B, in 0-255 levels +// psnr 10 log10(255^2 / MSE) over R, G and B, in dB (99 when identical) +// over8 percent of pixels whose largest channel delta exceeds 8 +// over24 same, above 24 +// worst the 16x16 tile (aligned on a 16 px grid, in image pixels) with the +// highest mean error: x, y and its mae +// and two PNGs: -heat.png (largest channel delta per pixel, x4, on a +// black to red to yellow to white ramp) and -side.png (reference on +// top, other below). When the sizes differ, the overlapping top-left area is +// compared and the shot is flagged. +// +// Batch mode pairs shots through /manifest.json (written by ref.mjs): +// //.png|pam first, then /.png|pam for +// shots whose scene step names an alias (the spike's `--capture` PAM names). +// Without a manifest, every //.png is tried. Writes +// /report.json and /report.md, one row per shot, and the images +// under //. + +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, extname, join, relative, resolve } from "node:path"; + +const FFMPEG = process.env.FFMPEG ?? "ffmpeg"; + +function headerSize(file) { + const buf = readFileSync(file); + if (buf.subarray(1, 4).toString("latin1") === "PNG") { + return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; + } + if (buf.subarray(0, 2).toString("latin1") === "P7") { + const head = buf.subarray(0, 256).toString("latin1"); + const width = Number(head.match(/WIDTH (\d+)/)?.[1]); + const height = Number(head.match(/HEIGHT (\d+)/)?.[1]); + if (width && height) return { width, height }; + } + throw new Error(`${file}: not a PNG or PAM`); +} + +// ffmpeg takes about two seconds to start on Windows, so each shot costs two +// runs: one decodes both inputs, one encodes both outputs. Raw frames go +// through a temp folder. +function withTemp(job) { + const dir = mkdtempSync(join(tmpdir(), "parity-diff-")); + try { + return job(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +/** RGBA pixels of PNG or PAM files, through one ffmpeg run. */ +export function decode(...files) { + return withTemp((dir) => { + const args = ["-v", "error", "-y"]; + for (const f of files) args.push("-i", f); + files.forEach((_, i) => + args.push("-map", `${i}:v`, "-f", "rawvideo", "-pix_fmt", "rgba", join(dir, `${i}.rgba`)), + ); + execFileSync(FFMPEG, args, { windowsHide: true, stdio: ["ignore", "ignore", "pipe"] }); + return files.map((file, i) => { + const { width, height } = headerSize(file); + const data = readFileSync(join(dir, `${i}.rgba`)); + if (data.length !== width * height * 4) { + throw new Error(`${file}: ffmpeg gave ${data.length} bytes for ${width}x${height}`); + } + return { width, height, data }; + }); + }); +} + +/** Write RGBA buffers as PNGs, through one ffmpeg run. */ +function encodePngs(images) { + withTemp((dir) => { + const args = ["-v", "error", "-y"]; + images.forEach(({ file, width, height, rgba }, i) => { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(join(dir, `${i}.rgba`), rgba); + args.push( + "-f", + "rawvideo", + "-pix_fmt", + "rgba", + "-s", + `${width}x${height}`, + "-i", + join(dir, `${i}.rgba`), + ); + }); + images.forEach(({ file }, i) => args.push("-map", `${i}:v`, "-frames:v", "1", file)); + execFileSync(FFMPEG, args, { windowsHide: true, stdio: ["ignore", "ignore", "pipe"] }); + }); +} + +/** RGB of every pixel, composited over black, 3 bytes a pixel. */ +function overBlack(img) { + const n = img.width * img.height; + const rgb = new Uint8Array(n * 3); + for (let i = 0; i < n; i++) { + const alpha = img.data[i * 4 + 3]; + for (let c = 0; c < 3; c++) rgb[i * 3 + c] = Math.round((img.data[i * 4 + c] * alpha) / 255); + } + return rgb; +} + +function ramp(v) { + // 0 black, 1/3 red, 2/3 yellow, 1 white. + const t = Math.min(1, v / 255); + const r = Math.min(1, t * 3); + const g = Math.min(1, Math.max(0, t * 3 - 1)); + const b = Math.min(1, Math.max(0, t * 3 - 2)); + return [r * 255, g * 255, b * 255]; +} + +export function compare(refFile, otherFile, heatFile, sideFile) { + const [a, b] = decode(refFile, otherFile); + a.rgb = overBlack(a); + b.rgb = overBlack(b); + const width = Math.min(a.width, b.width); + const height = Math.min(a.height, b.height); + const sizeMatch = a.width === b.width && a.height === b.height; + const heat = Buffer.alloc(width * height * 4); + const tilesX = Math.ceil(width / 16); + const tilesY = Math.ceil(height / 16); + const tileSum = new Float64Array(tilesX * tilesY); + const tileCount = new Uint32Array(tilesX * tilesY); + let absSum = 0; + let sqSum = 0; + let over8 = 0; + let over24 = 0; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const ia = y * a.width + x; + const ib = y * b.width + x; + let max = 0; + let abs = 0; + for (let c = 0; c < 3; c++) { + const d = Math.abs(a.rgb[ia * 3 + c] - b.rgb[ib * 3 + c]); + abs += d; + sqSum += d * d; + if (d > max) max = d; + } + absSum += abs; + if (max > 8) over8++; + if (max > 24) over24++; + const t = Math.floor(y / 16) * tilesX + Math.floor(x / 16); + tileSum[t] += abs / 3; + tileCount[t]++; + const [r, g, bl] = ramp(max * 4); + const o = (y * width + x) * 4; + heat[o] = r; + heat[o + 1] = g; + heat[o + 2] = bl; + heat[o + 3] = 255; + } + } + const n = width * height; + const mse = sqSum / (n * 3); + let worst = 0; + for (let t = 1; t < tileSum.length; t++) { + if (tileSum[t] / tileCount[t] > tileSum[worst] / tileCount[worst]) worst = t; + } + + // Reference on top, other below, both over black, on the wider canvas. + const sw = Math.max(a.width, b.width); + const side = Buffer.alloc(sw * (a.height + b.height) * 4); + const blit = (img, top) => { + for (let y = 0; y < img.height; y++) { + for (let x = 0; x < img.width; x++) { + const i = y * img.width + x; + const o = ((top + y) * sw + x) * 4; + side[o] = img.rgb[i * 3]; + side[o + 1] = img.rgb[i * 3 + 1]; + side[o + 2] = img.rgb[i * 3 + 2]; + side[o + 3] = 255; + } + } + }; + blit(a, 0); + blit(b, a.height); + encodePngs([ + { file: heatFile, width, height, rgba: heat }, + { file: sideFile, width: sw, height: a.height + b.height, rgba: side }, + ]); + + const round = (v, d = 2) => Math.round(v * 10 ** d) / 10 ** d; + return { + width, + height, + sizeMatch, + refSize: [a.width, a.height], + otherSize: [b.width, b.height], + mae: round(absSum / (n * 3), 3), + psnr: mse === 0 ? 99 : round(10 * Math.log10((255 * 255) / mse)), + over8: round((over8 / n) * 100), + over24: round((over24 / n) * 100), + worst: { + x: (worst % tilesX) * 16, + y: Math.floor(worst / tilesX) * 16, + mae: round(tileSum[worst] / tileCount[worst]), + }, + }; +} + +function findOther(otherDir, stems) { + for (const stem of stems) { + for (const ext of [".png", ".pam"]) { + const p = join(otherDir, stem + ext); + if (existsSync(p)) return p; + } + } + return null; +} + +function refShots(refDir) { + const manifest = join(refDir, "manifest.json"); + if (existsSync(manifest)) return JSON.parse(readFileSync(manifest, "utf8")); + const shots = []; + for (const scene of readdirSync(refDir)) { + const dir = join(refDir, scene); + if (!statSync(dir).isDirectory() || scene.startsWith(".")) continue; + for (const f of readdirSync(dir)) { + if (extname(f) === ".png") + shots.push({ scene, shot: basename(f, ".png"), file: `${scene}/${f}` }); + } + } + return shots; +} + +function batch(refDir, otherDir, out) { + const rows = []; + for (const s of refShots(refDir)) { + const other = findOther(otherDir, [`${s.scene}/${s.shot}`, ...(s.alias ? [s.alias] : [])]); + const row = { scene: s.scene, shot: s.shot, ref: join(refDir, s.file), other }; + if (!other) { + row.missing = true; + console.log(`${s.scene}/${s.shot}: no counterpart in ${otherDir}`); + } else { + Object.assign( + row, + compare( + row.ref, + other, + join(out, s.scene, `${s.shot}-heat.png`), + join(out, s.scene, `${s.shot}-side.png`), + ), + ); + console.log( + `${s.scene}/${s.shot}: psnr ${row.psnr} dB, mae ${row.mae}, >8 ${row.over8}%, >24 ${row.over24}%`, + ); + } + rows.push(row); + } + const done = rows.filter((r) => !r.missing); + const mean = (k) => + done.length ? Math.round((done.reduce((s, r) => s + r[k], 0) / done.length) * 100) / 100 : null; + const summary = { + shots: rows.length, + compared: done.length, + meanPsnr: mean("psnr"), + meanMae: mean("mae"), + }; + writeFileSync( + join(out, "report.json"), + JSON.stringify({ refDir, otherDir, summary, rows }, null, 2) + "\n", + ); + + const rel = (p) => relative(out, p).split("\\").join("/"); + const md = [ + `# Parity diff`, + "", + `Reference \`${refDir}\` against \`${otherDir}\`: ${done.length} of ${rows.length} shots compared, mean PSNR ${summary.meanPsnr} dB, mean MAE ${summary.meanMae}.`, + "", + "| Shot | Other | PSNR (dB) | MAE | > 8 (%) | > 24 (%) | Worst 16x16 tile | Images |", + "| --- | --- | ---: | ---: | ---: | ---: | --- | --- |", + ...rows.map((r) => + r.missing + ? `| ${r.scene}/${r.shot} | missing | | | | | | |` + : `| ${r.scene}/${r.shot}${r.sizeMatch ? "" : ` (size ${r.refSize.join("x")} vs ${r.otherSize.join("x")})`} | ${basename(r.other)} | ${r.psnr} | ${r.mae} | ${r.over8} | ${r.over24} | ${r.worst.x},${r.worst.y} (mae ${r.worst.mae}) | [heat](${rel(join(out, r.scene, `${r.shot}-heat.png`))}), [side](${rel(join(out, r.scene, `${r.shot}-side.png`))}) |`, + ), + "", + ].join("\n"); + writeFileSync(join(out, "report.md"), md); + console.log( + `${done.length}/${rows.length} shots, mean PSNR ${summary.meanPsnr} dB -> ${join(out, "report.md")}`, + ); + return summary; +} + +function main() { + const argv = process.argv.slice(2); + let out = null; + let isBatch = false; + const pos = []; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--out") out = argv[++i]; + else if (argv[i] === "--batch") isBatch = true; + else pos.push(argv[i]); + } + if (!out || pos.length !== 2) { + console.error( + "usage: diff.mjs --out | diff.mjs --batch --out ", + ); + process.exit(2); + } + out = resolve(out); + mkdirSync(out, { recursive: true }); + const [ref, other] = pos.map((p) => resolve(p)); + if (isBatch) { + const summary = batch(ref, other, out); + if (summary.compared === 0) process.exitCode = 1; + return; + } + const stem = basename(ref, extname(ref)); + const result = compare(ref, other, join(out, `${stem}-heat.png`), join(out, `${stem}-side.png`)); + console.log(JSON.stringify(result)); +} + +main(); diff --git a/crates/accshift-egui/parity/dump-data.mjs b/crates/accshift-egui/parity/dump-data.mjs new file mode 100644 index 0000000..f24f273 --- /dev/null +++ b/crates/accshift-egui/parity/dump-data.mjs @@ -0,0 +1,190 @@ +// Mock data for the egui side of the parity bench: per scenario, the resolved +// result of every mocked command, read from the same fixtures module the +// reference app runs on. +// +// node crates/accshift-egui/parity/dump-data.mjs [scenario ...] [--exe ] +// +// No scenario argument dumps them all. ACCSHIFT_PARITY_SCRATCH moves the +// run's scratch folder (default: the system temp dir). Writes +// crates/accshift-egui/parity/data/.json. Needs the parity-capture +// build of accshift-gui, like ref.mjs: the modules are imported through the +// vite dev server from inside its WebView2 (Runtime.evaluate), so they resolve +// exactly as in the app. +// +// Format (version 1): +// +// { +// "format": 1, +// "scenario": "demo", +// "now": 1791000000, // mock NOW, seconds +// "spec": { // scenario fields no command returns +// "label", "runtimeOs", "switchDelayMs", "steamPath", "hasSteamApiKey", +// "currentSteamAccount", "currentRiotProfile", "currentRobloxAccount" +// }, +// "commands": { +// "": [ +// { "args": { ... }, "result": } // or "error": "" +// ] +// }, +// "skipped": { "": "" } +// } +// +// Each command is called once with `{}` unless it reads arguments: the +// platform commands once per platform id ("steam", "riot", "roblox"), the +// per-account ones once per account, the batch ones once with every id. +// Cache timestamps that the fixtures take from `Date.now()` are taken at +// exactly NOW * 1000. Avatar URLs (`http://localhost:1420/src/lib/mock/ +// avatars/.svg`) are rewritten to repo-relative paths +// (`src/lib/mock/avatars/.svg`). + +import { execFileSync } from "node:child_process"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { HERE, MOCK_NOW, REPO, defaultExe, launchApp, startVite } from "./harness.mjs"; + +const SKIP = { + platform_switch_account: "changes the current account after switchDelayMs; modelled from spec", + pin_unlock: "PIN session state, reads the live settings store", + pin_lock: "PIN session state", +}; + +const DUMP = (scenario) => ` +(async () => { + const { SCENARIOS } = await import("/src/lib/mock/scenarios.ts"); + const { createHandlers, NOW } = await import("/src/lib/mock/fixtures.ts"); + const id = ${JSON.stringify(scenario)}; + const skip = ${JSON.stringify(SKIP)}; + const realNow = Date.now; + Date.now = () => NOW * 1000; + try { + const spec = SCENARIOS[id](); + const handlers = createHandlers(spec); + const platforms = ["steam", "riot", "roblox"]; + const steamIds = spec.steamAccounts.map((a) => a.steam_id); + const robloxIds = spec.robloxAccounts.map((a) => a.userId); + const argSets = { + platform_get_accounts: platforms.map((platformId) => ({ platformId })), + platform_get_current_account: platforms.map((platformId) => ({ platformId })), + platform_get_startup_snapshot: platforms.map((platformId) => ({ platformId })), + platform_dry_run: steamIds.slice(0, 1).map((accountId) => ({ platformId: "steam", accountId })), + steam_get_profile_info: steamIds.map((accountId) => ({ accountId })), + steam_get_profile_infos: [{ accountIds: steamIds }], + steam_get_player_bans: [{ steamIds }], + roblox_get_profile_info: robloxIds.map((userId) => ({ userId })), + }; + const commands = {}; + const skipped = {}; + for (const name of Object.keys(handlers).sort()) { + if (skip[name]) { + skipped[name] = skip[name]; + continue; + } + commands[name] = []; + for (const args of argSets[name] ?? [{}]) { + try { + commands[name].push({ args, result: (await handlers[name](args)) ?? null }); + } catch (error) { + commands[name].push({ args, error: String(error?.message ?? error) }); + } + } + } + return { + format: 1, + scenario: id, + now: NOW, + spec: { + label: spec.label, + runtimeOs: spec.runtimeOs, + switchDelayMs: spec.switchDelayMs, + steamPath: spec.steamPath, + hasSteamApiKey: spec.hasSteamApiKey, + currentSteamAccount: spec.currentSteamAccount, + currentRiotProfile: spec.currentRiotProfile, + currentRobloxAccount: spec.currentRobloxAccount, + }, + commands, + skipped, + }; + } finally { + Date.now = realNow; + } +})() +`; + +// http://localhost:1420/src/lib/mock/avatars/03.svg?no-inline -> src/lib/mock/avatars/03.svg +function relink(value) { + if (typeof value === "string") { + const m = value.match( + /^https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?\/(src\/lib\/mock\/avatars\/[^?#]+)/, + ); + return m ? m[1] : value; + } + if (Array.isArray(value)) return value.map(relink); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, relink(v)])); + } + return value; +} + +async function main() { + const argv = process.argv.slice(2); + let exe = defaultExe(); + const wanted = []; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--exe") exe = argv[++i]; + else wanted.push(argv[i]); + } + const outDir = join(HERE, "data"); + // App dirs, WebView2 profile and logs of the run; removed at the end. + const scratch = resolve( + process.env.ACCSHIFT_PARITY_SCRATCH ?? join(tmpdir(), "accshift-parity-dump"), + ); + mkdirSync(outDir, { recursive: true }); + mkdirSync(scratch, { recursive: true }); + + const vite = await startVite({ logFile: join(scratch, "dump-vite.log") }); + let app = null; + try { + app = await launchApp({ + exe: resolve(exe), + scratch: join(scratch, "dump"), + logFile: join(scratch, "dump-app.log"), + }); + const { js } = app.page; + const ids = wanted.length + ? wanted + : await js(`import("/src/lib/mock/scenarios.ts").then((m) => Object.keys(m.SCENARIOS))`); + for (const id of ids) { + const data = relink(await js(DUMP(id))); + if (data.now !== MOCK_NOW) + console.warn(`${id}: fixtures NOW ${data.now} differs from harness ${MOCK_NOW}`); + const file = join(outDir, `${id}.json`); + writeFileSync(file, JSON.stringify(data, null, 2) + "\n"); + const calls = Object.values(data.commands).reduce((n, c) => n + c.length, 0); + console.log( + `${id}: ${Object.keys(data.commands).length} commands, ${calls} calls -> ${file}`, + ); + } + } finally { + await app?.close(); + await vite.close(); + } + // The repo formatter checks every tracked file: hand it the dumps now + // rather than fail `pnpm check` later. + execFileSync( + process.execPath, + [join(REPO, "node_modules", "vite-plus", "bin", "vp"), "fmt", outDir], + { + cwd: REPO, + stdio: "ignore", + windowsHide: true, + }, + ); + rmSync(scratch, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/crates/accshift-egui/parity/harness.mjs b/crates/accshift-egui/parity/harness.mjs new file mode 100644 index 0000000..8bb7ea9 --- /dev/null +++ b/crates/accshift-egui/parity/harness.mjs @@ -0,0 +1,419 @@ +// Shared plumbing of the parity bench: the vite dev server with the mock +// backend, the real Tauri app built with the `parity-capture` feature, and a +// raw CDP client on its own WebView2. Used by ref.mjs and dump-data.mjs. +// +// Nothing here opens a window or touches the real app data: +// - the app is a debug `accshift-gui` built with `--features parity-capture` +// (checked through the marker string `accshift-parity-capture-build`), which +// never shows its window (or, with --window cloak, shows it cloaked off +// screen) and moves every app directory under ACCSHIFT_PARITY_DIR; +// - WEBVIEW2_USER_DATA_FOLDER points at the same scratch folder, and CDP is +// opened by WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS on a free port; +// - the binary is copied into the scratch folder before launch, so another +// build in the shared cargo target cannot swap it mid-run. + +import { spawn, execFileSync } from "node:child_process"; +import { + copyFileSync, + createWriteStream, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, +} from "node:fs"; +import { createServer } from "node:net"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const HERE = dirname(fileURLToPath(import.meta.url)); +export const REPO = resolve(HERE, "../../.."); +/** `NOW` of src/lib/mock/fixtures.ts, in seconds. */ +export const MOCK_NOW = 1_791_000_000; +/** `devUrl` of src-tauri/tauri.conf.json, compiled into the debug app. */ +export const DEV_PORT = 1420; +export const DEV_URL = `http://localhost:${DEV_PORT}/`; +export const MARKER = "accshift-parity-capture-build"; + +export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +export function freePort() { + return new Promise((res, rej) => { + const srv = createServer(); + srv.unref(); + srv.on("error", rej); + srv.listen(0, "127.0.0.1", () => { + const { port } = srv.address(); + srv.close(() => res(port)); + }); + }); +} + +/** True when something already listens on 127.0.0.1:port. */ +export function portBusy(port) { + return new Promise((res) => { + const srv = createServer(); + srv.once("error", () => res(true)); + srv.listen(port, "127.0.0.1", () => srv.close(() => res(false))); + }); +} + +export function defaultExe() { + const target = process.env.CARGO_TARGET_DIR ?? cargoTargetDir() ?? join(REPO, "target"); + return join(target, "debug", process.platform === "win32" ? "accshift-gui.exe" : "accshift-gui"); +} + +function cargoTargetDir() { + try { + const out = execFileSync("cargo", ["metadata", "--format-version", "1", "--no-deps"], { + cwd: REPO, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + maxBuffer: 64 << 20, + }); + return JSON.parse(out).target_directory; + } catch { + return null; + } +} + +/** Refuse any binary that is not a parity-capture build. */ +export function assertParityBuild(exe) { + if (!existsSync(exe)) + throw new Error( + `no binary at ${exe}; build it with: cargo build -p accshift-gui --features parity-capture`, + ); + if (!readFileSync(exe).includes(Buffer.from(MARKER))) { + throw new Error(`${exe} is not a parity-capture build (marker ${MARKER} missing)`); + } +} + +function killTree(pid) { + if (!pid) return; + try { + if (process.platform === "win32") { + execFileSync("taskkill", ["/PID", String(pid), "/T", "/F"], { + stdio: "ignore", + windowsHide: true, + }); + } else { + process.kill(-pid, "SIGKILL"); + } + } catch { + /* already gone */ + } +} + +async function waitHttp(url, timeoutMs, child) { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + if (child?.exitCode != null) throw new Error(`${url}: process exited with ${child.exitCode}`); + try { + const r = await fetch(url); + if (r.ok) return; + } catch { + /* not up yet */ + } + await sleep(150); + } + throw new Error(`${url} did not answer within ${timeoutMs} ms`); +} + +/** + * The frontend on the vite dev server, non-frozen mock (`VITE_MOCK=dev`) so + * the scene can pick any scenario through `accshift.mock.overrides`. + */ +export async function startVite({ logFile }) { + if (await portBusy(DEV_PORT)) { + throw new Error( + `port ${DEV_PORT} is taken: the debug app loads its devUrl from there, stop whatever owns it`, + ); + } + const vp = join(REPO, "node_modules", "vite-plus", "bin", "vp"); + const log = createWriteStream(logFile); + const child = spawn(process.execPath, [vp, "dev", "--port", String(DEV_PORT), "--strictPort"], { + cwd: REPO, + env: { ...process.env, VITE_MOCK: "dev", BROWSER: "none" }, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + child.stdout.pipe(log); + child.stderr.pipe(log); + await waitHttp(DEV_URL, 60_000, child); + return { + child, + close: async () => { + killTree(child.pid); + log.end(); + }, + }; +} + +/** + * Launch the parity build against the dev server. `scratch` receives the app + * directories (ACCSHIFT_PARITY_DIR), the WebView2 profile and the binary copy. + * `onStderr` sees every stderr line (the `parity-window` samples among them). + */ +export async function launchApp({ exe, scratch, logFile, onStderr, windowMode }) { + assertParityBuild(exe); + rmSync(scratch, { recursive: true, force: true }); + mkdirSync(join(scratch, "bin"), { recursive: true }); + const copy = join(scratch, "bin", exe.split(/[\\/]/).pop()); + copyFileSync(exe, copy); + assertParityBuild(copy); + + const cdpPort = await freePort(); + const log = createWriteStream(logFile); + const env = { + ...process.env, + ACCSHIFT_PARITY_DIR: join(scratch, "app"), + ACCSHIFT_PARITY_WINDOW: windowMode ?? "hidden", + WEBVIEW2_USER_DATA_FOLDER: join(scratch, "app", "webview"), + // Replaces wry's own argument list, hence its three disabled features + // repeated here. CalculateNativeWinOcclusion off: Chromium would read the + // cloaked, off-screen window as occluded and stop painting. + WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS: [ + `--remote-debugging-port=${cdpPort}`, + "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection,CalculateNativeWinOcclusion", + "--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", + ].join(" "), + }; + const child = spawn(copy, [], { + cwd: scratch, + env, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + child.stdout.pipe(log); + let pending = ""; + child.stderr.on("data", (chunk) => { + log.write(chunk); + pending += chunk.toString("utf8"); + const lines = pending.split(/\r?\n/); + pending = lines.pop(); + for (const line of lines) onStderr?.(line); + }); + + // The page target exists once WebView2 is up and the app navigated to its + // devUrl. + const started = Date.now(); + let target = null; + while (!target && Date.now() - started < 60_000) { + if (child.exitCode != null) + throw new Error(`app exited with ${child.exitCode}, see ${logFile}`); + try { + const list = await (await fetch(`http://127.0.0.1:${cdpPort}/json`)).json(); + target = list.find((t) => t.type === "page" && t.url.startsWith(DEV_URL)); + } catch { + /* CDP not up yet */ + } + if (!target) await sleep(200); + } + if (!target) throw new Error(`no page on ${DEV_URL} over CDP ${cdpPort}, see ${logFile}`); + const page = await connect(target.webSocketDebuggerUrl); + // The target can be listed before its document commits; localStorage + // throws on anything but the dev origin. + const origin = DEV_URL.slice(0, -1); + while (Date.now() - started < 60_000) { + const ready = await page + .js(`location.origin === ${JSON.stringify(origin)} && document.readyState === "complete"`) + .catch(() => false); + if (ready) break; + await sleep(200); + } + return { + child, + cdpPort, + page, + close: async () => { + page.close(); + killTree(child.pid); + await new Promise((r) => (child.exitCode != null ? r() : child.once("exit", r))); + log.end(); + }, + }; +} + +export async function connect(wsUrl) { + const ws = new WebSocket(wsUrl); + await new Promise((res, rej) => { + ws.onopen = res; + ws.onerror = rej; + }); + let seq = 0; + const pending = new Map(); + ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + if (msg.id && pending.has(msg.id)) { + const { res, rej } = pending.get(msg.id); + pending.delete(msg.id); + if (msg.error) rej(new Error(JSON.stringify(msg.error))); + else res(msg.result); + } + }; + const send = (method, params = {}) => + new Promise((res, rej) => { + const id = ++seq; + pending.set(id, { res, rej }); + ws.send(JSON.stringify({ id, method, params })); + }); + const js = async (expression) => { + const r = await send("Runtime.evaluate", { + expression, + awaitPromise: true, + returnByValue: true, + }); + if (r.exceptionDetails) { + throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text); + } + return r.result.value; + }; + return { send, js, close: () => ws.close() }; +} + +/** + * `Date.now()` and argument-less `new Date()` start at the mock's NOW when the + * document starts and advance with `performance.now()`, so relative labels + * ("2 hours ago") never drift while timers and debounces still see time pass. + * `Date()` called as a function keeps returning a string. + */ +export const PIN_CLOCK = ` +(() => { + const NOW_MS = ${MOCK_NOW * 1000}; + const RealDate = Date; + const now = () => NOW_MS + Math.floor(performance.now()); + class PinnedDate extends RealDate { + constructor(...args) { + if (args.length === 0) super(now()); + else super(...args); + } + static now() { return now(); } + } + globalThis.Date = new Proxy(PinnedDate, { + apply() { return new PinnedDate().toString(); }, + }); +})(); +`; + +/** Every file under `dir` with its mtime and size, keyed by relative path. */ +export function snapshotTree(dir) { + const out = {}; + const walk = (d) => { + let entries; + try { + entries = readdirSync(d, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + const p = join(d, e.name); + if (e.isDirectory()) walk(p); + else { + try { + const s = statSync(p); + out[p.slice(dir.length + 1)] = `${s.mtimeMs}:${s.size}`; + } catch { + /* vanished */ + } + } + } + }; + walk(dir); + return out; +} + +export function diffTrees(before, after) { + const changed = []; + for (const [k, v] of Object.entries(after)) { + if (!(k in before)) changed.push(`added ${k}`); + else if (before[k] !== v) changed.push(`changed ${k}`); + } + for (const k of Object.keys(before)) if (!(k in after)) changed.push(`removed ${k}`); + return changed; +} + +/** The real app's directories, which a parity run must leave untouched. */ +export function realAppDirs() { + if (process.platform !== "win32") return []; + const id = "com.accshift.desktop"; + return [process.env.APPDATA, process.env.LOCALAPPDATA] + .filter(Boolean) + .map((base) => join(base, id)); +} + +/** The accshift:// handler in HKCU, which `register_all()` would rewrite. */ +export function deepLinkRegistration() { + if (process.platform !== "win32") return ""; + try { + return execFileSync( + "reg", + ["query", "HKCU\\Software\\Classes\\accshift\\shell\\open\\command"], + { + encoding: "utf8", + windowsHide: true, + stdio: ["ignore", "pipe", "ignore"], + }, + ).trim(); + } catch { + return "(absent)"; + } +} + +/** Whether the installed app runs, which would explain changes in its dirs. */ +export function installedAppRunning() { + if (process.platform !== "win32") return false; + try { + const out = execFileSync( + "tasklist", + ["/FI", "IMAGENAME eq accshift-gui.exe", "/FO", "CSV", "/NH", "/V"], + { + encoding: "utf8", + windowsHide: true, + }, + ); + return out.includes("accshift-gui.exe"); + } catch { + return false; + } +} + +/** + * Proof the window never reached the screen, from the `parity-window` samples + * the app prints on stderr: every sample cloaked or invisible, off every + * monitor, never foreground. + */ +export function windowWatch() { + const samples = []; + const onLine = (line) => { + const m = line.match( + /^parity-window (\w+) visible=(\w+) cloaked=(\d+) foreground=(\w+) rect=(-?\d+),(-?\d+),(-?\d+),(-?\d+)/, + ); + if (!m) return; + samples.push({ + when: m[1], + visible: m[2] === "true", + cloaked: Number(m[3]), + foreground: m[4] === "true", + rect: [Number(m[5]), Number(m[6]), Number(m[7]), Number(m[8])], + }); + }; + const summary = () => { + const seen = samples.filter( + (s) => + s.foreground || (s.visible && s.cloaked === 0 && s.rect[2] > -30000 && s.rect[3] > -30000), + ); + const first = samples.find((s) => s.when === "shown") ?? samples[0]; + return { + samples: samples.length, + everVisible: samples.some((s) => s.visible), + everUncloaked: samples.some((s) => s.visible && s.cloaked === 0), + everForeground: samples.some((s) => s.foreground), + firstRect: first?.rect ?? null, + hidden: samples.length > 0 && seen.length === 0, + }; + }; + return { onLine, samples, summary }; +} diff --git a/crates/accshift-egui/parity/ref.mjs b/crates/accshift-egui/parity/ref.mjs new file mode 100644 index 0000000..8f16426 --- /dev/null +++ b/crates/accshift-egui/parity/ref.mjs @@ -0,0 +1,377 @@ +// Reference frames for the egui parity bench: the real Tauri app (WebView2) +// on the mock frontend, driven over CDP, one PNG per `shot` of each scene. +// +// cargo build -p accshift-gui --features parity-capture +// node crates/accshift-egui/parity/ref.mjs crates/accshift-egui/parity/scenes/*.json --out +// +// Options: --out (required), --exe (default: the debug +// accshift-gui of the cargo target dir), --scratch (app dirs, WebView2 +// profile and binary copy, removed after the run; default /.scratch), +// --window hidden|cloak (default hidden: the window is never shown). +// +// Output: //.png at viewport x scale, /manifest.json +// (every shot, with its `alias` if the scene gives one, read by +// diff.mjs --batch), /run.json (window samples and the real-dir check). +// +// Scene format: README.md next to this file. What a scene sets up: +// - the mock: the dev server runs `VITE_MOCK=dev` (not frozen), and the +// scene's scenario, os, theme, locale and seed are written into +// `accshift.mock.overrides` in localStorage before each load. `theme` and +// `locale` patch `client.settings` (`themeId`, `language`); a `seed` key is a +// store id ("client.view-mode") or the suffix of its CLIENT_STORE_ constant +// ("SETTINGS"), and an object value is merged over the scenario's store. +// - the clock: `Date.now()` and `new Date()` start at the mock's NOW +// (1_791_000_000 s) and advance with `performance.now()` (PIN_CLOCK in +// harness.mjs). +// - the stand-in desktop of the glass themes, painted as the `html` +// background. It is the README recorder's (`WALLPAPER` and `DESKTOP` in +// .agents/scripts/record-demo.mjs) and crates/accshift-egui/src/backdrop.rs +// draws the same thing: a desktop of the viewport plus PAD = 36 CSS px on +// each side, the window at 36,36, so the page sees the desktop shifted by +// -36,-36. Layers, top first: +// radial-gradient(48% 62% at 16% 20%, rgba(109,40,217,.42), transparent 72%) +// radial-gradient(46% 60% at 86% 84%, rgba(29,78,216,.40), transparent 72%) +// radial-gradient(34% 44% at 72% 10%, rgba(190,24,93,.22), transparent 72%) +// radial-gradient(38% 48% at 28% 96%, rgba(8,145,178,.22), transparent 72%) +// linear-gradient(160deg, #10121f, #0a0c16) +// - input: CDP Input.dispatchMouseEvent / dispatchKeyEvent, so :hover, focus +// and keyboard handlers behave as under a real hand. + +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; +import { + DEV_URL, + PIN_CLOCK, + defaultExe, + deepLinkRegistration, + diffTrees, + installedAppRunning, + launchApp, + realAppDirs, + sleep, + snapshotTree, + startVite, + windowWatch, +} from "./harness.mjs"; + +const PAD = 36; +const WALLPAPER = [ + "radial-gradient(48% 62% at 16% 20%, rgba(109,40,217,.42), transparent 72%)", + "radial-gradient(46% 60% at 86% 84%, rgba(29,78,216,.40), transparent 72%)", + "radial-gradient(34% 44% at 72% 10%, rgba(190,24,93,.22), transparent 72%)", + "radial-gradient(38% 48% at 28% 96%, rgba(8,145,178,.22), transparent 72%)", + "linear-gradient(160deg, #10121f, #0a0c16)", +].join(","); + +const desktop = (vp) => ` +(() => { + const style = document.createElement("style"); + style.id = "__parity_desktop"; + style.textContent = "html{background:${WALLPAPER} !important;" + + "background-size:${vp.width + 2 * PAD}px ${vp.height + 2 * PAD}px !important;" + + "background-position:-${PAD}px -${PAD}px !important;background-repeat:no-repeat !important}"; + const add = () => document.head.append(style); + if (document.head) add(); + else document.addEventListener("DOMContentLoaded", add); +})(); +`; + +// Writes the scene's mock state, then the page reloads into it. Runs inside +// the page so the scenario's own stores come from the same modules the app +// imports. +const applyMock = (scene) => ` +(async () => { + const scene = ${JSON.stringify(scene)}; + const { SCENARIOS } = await import("/src/lib/mock/scenarios.ts"); + const ids = await import("/src/lib/storage/storeIds.ts"); + if (!SCENARIOS[scene.scenario]) throw new Error("unknown scenario " + scene.scenario); + const base = SCENARIOS[scene.scenario]().stores; + const plain = (v) => v && typeof v === "object" && !Array.isArray(v); + const stores = {}; + const settings = { ...(base["client.settings"] ?? {}) }; + if (scene.theme) settings.themeId = scene.theme; + if (scene.locale) settings.language = scene.locale; + stores["client.settings"] = settings; + for (const [key, value] of Object.entries(scene.seed ?? {})) { + const id = ids["CLIENT_STORE_" + key] ?? key; + const prev = stores[id] ?? base[id]; + stores[id] = plain(value) && plain(prev) ? { ...prev, ...value } : value; + } + localStorage.clear(); + sessionStorage.clear(); + const overrides = { scenario: scene.scenario, stores }; + if (scene.os) overrides.runtimeOs = scene.os; + localStorage.setItem("accshift.mock.overrides", JSON.stringify(overrides)); + return Object.keys(stores); +})() +`; + +// Settled: a new document (the one before the reload carries __parityStale), +// on the scenario asked for, past its boot entrance (`.app-frame.boot-ready` +// fully opaque, no finite animation still running), fonts in, every image +// decoded and none blurred under the startup avatar refresh. +const SETTLED = (scenario) => ` +(() => { + if (window.__parityStale) return false; + const state = window.__mock?.state?.(); + if (!state || state.scenario !== ${JSON.stringify(scenario)}) return false; + if (document.readyState !== "complete" || document.fonts.status !== "loaded") return false; + const frame = document.querySelector(".app-frame.boot-ready"); + if (!frame || getComputedStyle(frame).opacity !== "1") return false; + const finite = document.getAnimations().filter( + (a) => a.playState === "running" && a.effect?.getComputedTiming().endTime !== Infinity, + ); + if (finite.length) return false; + if (!document.querySelector(".card, .row, .empty-state, [class*=empty]")) return false; + const imgs = [...document.querySelectorAll("img")]; + return imgs.every((i) => i.complete && i.naturalWidth > 0 && !i.classList.contains("blurred")) + && !document.querySelector(".card .spinner, .card [class*=spin]"); +})() +`; + +const KEYS = { + Enter: { code: "Enter", windowsVirtualKeyCode: 13, text: "\r" }, + Escape: { code: "Escape", windowsVirtualKeyCode: 27 }, + Backspace: { code: "Backspace", windowsVirtualKeyCode: 8 }, + Tab: { code: "Tab", windowsVirtualKeyCode: 9 }, + Delete: { code: "Delete", windowsVirtualKeyCode: 46 }, + Home: { code: "Home", windowsVirtualKeyCode: 36 }, + End: { code: "End", windowsVirtualKeyCode: 35 }, + ArrowLeft: { code: "ArrowLeft", windowsVirtualKeyCode: 37 }, + ArrowUp: { code: "ArrowUp", windowsVirtualKeyCode: 38 }, + ArrowRight: { code: "ArrowRight", windowsVirtualKeyCode: 39 }, + ArrowDown: { code: "ArrowDown", windowsVirtualKeyCode: 40 }, + Space: { key: " ", code: "Space", windowsVirtualKeyCode: 32, text: " " }, +}; +const MODIFIERS = { Alt: 1, Ctrl: 2, Meta: 4, Shift: 8 }; + +function parseArgs(argv) { + const opts = { scenes: [], out: null, exe: null, scratch: null, window: "hidden" }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--out") opts.out = argv[++i]; + else if (a === "--exe") opts.exe = argv[++i]; + else if (a === "--scratch") opts.scratch = argv[++i]; + else if (a === "--window") opts.window = argv[++i]; + else opts.scenes.push(a); + } + if (!opts.out || opts.scenes.length === 0) { + console.error( + "usage: ref.mjs ... --out [--exe ] [--scratch ] [--window hidden|cloak]", + ); + process.exit(2); + } + return opts; +} + +function loadScene(file) { + const scene = JSON.parse(readFileSync(file, "utf8")); + scene.id ??= basename(file, ".json"); + scene.scenario ??= "demo"; + scene.viewport ??= { width: 860, height: 440, scale: 1.5 }; + scene.steps ??= []; + return scene; +} + +function pngSize(buf) { + return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; +} + +async function runScene(page, scene, outDir, manifest) { + const { send, js } = page; + const vp = scene.viewport; + await send("Emulation.setDeviceMetricsOverride", { + width: vp.width, + height: vp.height, + deviceScaleFactor: vp.scale, + mobile: false, + }); + const desk = await send("Page.addScriptToEvaluateOnNewDocument", { source: desktop(vp) }); + + await js(applyMock(scene)); + await js("window.__parityStale = true"); + await send("Page.navigate", { url: DEV_URL }); + let settled = false; + const started = Date.now(); + while (!settled && Date.now() - started < 30_000) { + await sleep(100); + settled = await js(SETTLED(scene.scenario)).catch(() => false); + } + if (!settled) console.error(`${scene.id}: page never settled, shooting anyway`); + else console.log(`${scene.id}: settled after ${Date.now() - started} ms`); + + const mouse = { x: 0, y: 0 }; + const mouseEvent = (type, extra = {}) => + send("Input.dispatchMouseEvent", { type, x: mouse.x, y: mouse.y, ...extra }); + const moveTo = async ([x, y]) => { + mouse.x = x; + mouse.y = y; + await mouseEvent("mouseMoved"); + }; + const press = async (button, clickCount) => { + await mouseEvent("mousePressed", { button, clickCount }); + await sleep(40); + await mouseEvent("mouseReleased", { button, clickCount }); + }; + const stroke = async (spec) => { + const parts = spec.split("+"); + const name = parts.pop(); + const modifiers = parts.reduce((m, p) => m | (MODIFIERS[p] ?? 0), 0); + const known = KEYS[name]; + const upper = name.toUpperCase(); + const base = known + ? { key: name, ...known } + : name.length === 1 + ? { + key: name, + code: /[a-z]/i.test(name) ? `Key${upper}` : /\d/.test(name) ? `Digit${name}` : "", + windowsVirtualKeyCode: upper.charCodeAt(0), + text: name, + } + : { key: name, code: name }; + const { text, ...rest } = base; + const typed = text && !(modifiers & (MODIFIERS.Ctrl | MODIFIERS.Alt | MODIFIERS.Meta)); + await send("Input.dispatchKeyEvent", { + type: typed ? "keyDown" : "rawKeyDown", + modifiers, + ...rest, + ...(typed ? { text, unmodifiedText: text } : {}), + }); + await send("Input.dispatchKeyEvent", { type: "keyUp", modifiers, ...rest }); + }; + + for (const step of scene.steps) { + if ("wait" in step) await sleep(step.wait); + else if ("move" in step) await moveTo(step.move); + else if ("click" in step) { + await moveTo(step.click); + await press("left", 1); + } else if ("dblclick" in step) { + await moveTo(step.dblclick); + await press("left", 1); + await press("left", 2); + } else if ("rclick" in step) { + await moveTo(step.rclick); + await press("right", 1); + } else if ("key" in step) await stroke(step.key); + else if ("text" in step) { + for (const ch of step.text) { + await stroke(ch === " " ? "Space" : ch); + await sleep(30); + } + } else if ("scroll" in step) { + const [x, y, dy] = step.scroll; + mouse.x = x; + mouse.y = y; + await mouseEvent("mouseWheel", { deltaX: 0, deltaY: dy }); + } else if ("shot" in step) { + const { data } = await send("Page.captureScreenshot", { format: "png", fromSurface: true }); + const buf = Buffer.from(data, "base64"); + const dir = join(outDir, scene.id); + mkdirSync(dir, { recursive: true }); + const file = join(dir, `${step.shot}.png`); + writeFileSync(file, buf); + const size = pngSize(buf); + const want = { + width: Math.round(vp.width * vp.scale), + height: Math.round(vp.height * vp.scale), + }; + const ok = size.width === want.width && size.height === want.height; + console.log( + `${scene.id}/${step.shot}.png ${size.width}x${size.height}${ok ? "" : ` (expected ${want.width}x${want.height})`}`, + ); + manifest.push({ + scene: scene.id, + shot: step.shot, + file: `${scene.id}/${step.shot}.png`, + ...(step.alias ? { alias: step.alias } : {}), + width: size.width, + height: size.height, + sizeOk: ok, + }); + } else console.error(`${scene.id}: unknown step ${JSON.stringify(step)}`); + } + await send("Page.removeScriptToEvaluateOnNewDocument", { identifier: desk.identifier }); +} + +async function main() { + const opts = parseArgs(process.argv.slice(2)); + const out = resolve(opts.out); + const scratch = resolve(opts.scratch ?? join(out, ".scratch")); + mkdirSync(out, { recursive: true }); + mkdirSync(scratch, { recursive: true }); + const scenes = opts.scenes.map(loadScene); + const exe = resolve(opts.exe ?? defaultExe()); + + const dirs = realAppDirs(); + const before = Object.fromEntries(dirs.map((d) => [d, snapshotTree(d)])); + const regBefore = deepLinkRegistration(); + const watch = windowWatch(); + + const vite = await startVite({ logFile: join(out, "vite.log") }); + let app = null; + const manifest = []; + try { + app = await launchApp({ + exe, + scratch: join(scratch, "run"), + logFile: join(out, "app.log"), + onStderr: watch.onLine, + windowMode: opts.window, + }); + const { send } = app.page; + await send("Page.enable"); + await send("Runtime.enable"); + await send("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-reduced-motion", value: "no-preference" }], + }); + await send("Page.addScriptToEvaluateOnNewDocument", { source: PIN_CLOCK }); + for (const scene of scenes) await runScene(app.page, scene, out, manifest); + } finally { + await app?.close(); + await vite.close(); + } + + // The last samples land while the app shuts down. The scratch run (binary + // copy, app dirs, WebView2 profile) goes with it; app.log stays in . + await sleep(300); + rmSync(join(scratch, "run"), { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); + const after = Object.fromEntries(dirs.map((d) => [d, snapshotTree(d)])); + const touched = dirs.flatMap((d) => diffTrees(before[d], after[d]).map((c) => `${d}: ${c}`)); + const regAfter = deepLinkRegistration(); + const win = watch.summary(); + const run = { + exe, + window: { + mode: opts.window, + ...win, + samplesLog: watch.samples.slice(0, 3).concat(watch.samples.slice(-2)), + }, + realDirs: { dirs, filesBefore: dirs.map((d) => Object.keys(before[d]).length), touched }, + installedAppRunning: installedAppRunning(), + deepLink: { unchanged: regBefore === regAfter, value: regAfter }, + shots: manifest.length, + }; + writeFileSync(join(out, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n"); + writeFileSync(join(out, "run.json"), JSON.stringify(run, null, 2) + "\n"); + console.log( + `window: ${win.samples} samples, visible=${win.everVisible} uncloaked=${win.everUncloaked} ` + + `foreground=${win.everForeground} rect=${win.firstRect?.join(",") ?? "-"} -> hidden=${win.hidden}`, + ); + console.log( + `real app dirs: ${touched.length === 0 ? "untouched" : `${touched.length} change(s)`} ` + + `(${run.realDirs.filesBefore.join(" + ")} files checked), accshift:// ${run.deepLink.unchanged ? "unchanged" : "CHANGED"}`, + ); + for (const t of touched) console.log(` ${t}`); + console.log( + `${manifest.length} shots, ${manifest.filter((m) => !m.sizeOk).length} with a wrong size -> ${out}`, + ); + if (!win.hidden || touched.length || !run.deepLink.unchanged || manifest.some((m) => !m.sizeOk)) { + process.exitCode = 1; + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/crates/accshift-egui/parity/scenes/spike-main.json b/crates/accshift-egui/parity/scenes/spike-main.json new file mode 100644 index 0000000..93b125a --- /dev/null +++ b/crates/accshift-egui/parity/scenes/spike-main.json @@ -0,0 +1,50 @@ +{ + "id": "spike-main", + "scenario": "demo", + "os": "windows", + "viewport": { "width": 860, "height": 440, "scale": 1.5 }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { "wait": 600 }, + { "move": [755, 298] }, + { "wait": 500 }, + { "shot": "hover", "alias": "egui-0-hover" }, + { "click": [315, 152] }, + { "wait": 600 }, + { "shot": "armed", "alias": "egui-2-armed" }, + { "click": [639, 60] }, + { "text": "ra" }, + { "wait": 600 }, + { "shot": "search", "alias": "egui-5-search" }, + { "click": [741, 60] }, + { "wait": 400 }, + { "click": [315, 152] }, + { "wait": 300 }, + { "click": [315, 152] }, + { "wait": 1400 }, + { "move": [95, 152] }, + { "wait": 500 }, + { "shot": "switched", "alias": "egui-9-switched" }, + { "click": [447, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "riot", "alias": "egui-riot" }, + { "click": [481, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "roblox", "alias": "egui-roblox" }, + { "click": [413, 18] }, + { "wait": 400 }, + { "click": [95, 152] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "folder", "alias": "egui-folder" }, + { "click": [35, 60] }, + { "wait": 300 }, + { "click": [816, 60] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "list", "alias": "egui-list" } + ] +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7d7d3c3..d4b91da 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -38,6 +38,9 @@ mcp-bridge = ["dep:tauri-plugin-mcp-bridge"] # Keeps the main window hidden after boot so the startup bench can launch the # app in a loop without stealing the focus. Measurement only, never shipped. startup-bench = [] +# Reference renderer of the egui parity bench (crates/accshift-egui/parity): +# window never seen, app dirs redirected, no single instance, no telemetry. +parity-capture = [] [target.'cfg(windows)'.dependencies] winreg = { workspace = true } diff --git a/src-tauri/src/app_runtime.rs b/src-tauri/src/app_runtime.rs index db31813..4c8c6f1 100644 --- a/src-tauri/src/app_runtime.rs +++ b/src-tauri/src/app_runtime.rs @@ -1,7 +1,11 @@ use std::sync::atomic::{AtomicBool, Ordering}; -// Only used by the real show path, which the bench build compiles out. -#[cfg_attr(feature = "startup-bench", allow(unused_imports))] +// Only used by the real show path, which the bench and parity builds compile +// out. +#[cfg_attr( + any(feature = "startup-bench", feature = "parity-capture"), + allow(unused_imports) +)] use tauri::Manager; #[derive(Default)] @@ -37,7 +41,12 @@ pub fn show_main_window(app_handle: &tauri::AppHandle) -> Result<(), String> { Ok(()) } - #[cfg(not(feature = "startup-bench"))] + #[cfg(all(feature = "parity-capture", not(feature = "startup-bench")))] + { + parity::show_unseen(app_handle) + } + + #[cfg(not(any(feature = "startup-bench", feature = "parity-capture")))] { let Some(main_window) = app_handle.get_webview_window("main") else { return Err("Main window is unavailable".to_string()); @@ -48,3 +57,170 @@ pub fn show_main_window(app_handle: &tauri::AppHandle) -> Result<(), String> { Ok(()) } } + +/// Reference renderer of the egui parity bench +/// (`crates/accshift-egui/parity/ref.mjs`). The harness drives the WebView2 +/// over CDP and reads frames with `Page.captureScreenshot`, so the window only +/// has to exist for Chromium to keep painting: it is never seen, never +/// focused, never in the taskbar. Never enabled in a shipped build. +/// +/// `ACCSHIFT_PARITY_DIR` is required: every app directory and the WebView2 +/// profile live under it, so a run never writes to the real +/// `%APPDATA%\com.accshift.desktop` or `%LOCALAPPDATA%\com.accshift.desktop`. +#[cfg(feature = "parity-capture")] +pub mod parity { + use std::path::PathBuf; + + /// The harness refuses to launch a binary that does not carry this marker, + /// so a build that would show a window can never be driven by it. + #[used] + static MARKER: &[u8] = b"accshift-parity-capture-build"; + + /// Root of every directory the run writes to. + pub fn root() -> Option { + std::env::var_os("ACCSHIFT_PARITY_DIR") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + } + + /// Exit before anything resolves a path: without the scratch root, the + /// app would fall back on the real data directories. + pub fn require_root() { + std::hint::black_box(MARKER); + if root().is_none() { + eprintln!("parity-capture build: ACCSHIFT_PARITY_DIR is not set, refusing to start"); + std::process::exit(2); + } + } + + /// The WebView2 profile of the run. + pub fn webview_dir() -> Option { + root().map(|dir| dir.join("webview")) + } + + /// By default the window stays as built: never shown, and WebView2 still + /// paints and captures (checked on WebView2 154 with native occlusion + /// off). `ACCSHIFT_PARITY_WINDOW=cloak` is the fallback if a runtime stops + /// painting a hidden window: shown without activation as a tool window at + /// -32000,-32000 with DWM cloaking, so nothing reaches the screen either. + pub fn show_unseen(app_handle: &tauri::AppHandle) -> Result<(), String> { + std::hint::black_box(MARKER); + #[cfg(windows)] + { + use std::sync::atomic::{AtomicBool, Ordering}; + use tauri::Manager; + static SHOWN: AtomicBool = AtomicBool::new(false); + + let Some(win) = app_handle.get_webview_window("main") else { + return Err("Main window is unavailable".to_string()); + }; + let hwnd = win.hwnd().map_err(|e| e.to_string())?.0 as isize; + if SHOWN.swap(true, Ordering::SeqCst) { + return Ok(()); + } + let cloak = std::env::var("ACCSHIFT_PARITY_WINDOW").as_deref() == Ok("cloak"); + if cloak { + win.run_on_main_thread(move || win32::show_cloaked(hwnd)) + .map_err(|e| e.to_string())?; + } + win32::spawn_monitor(hwnd); + Ok(()) + } + #[cfg(not(windows))] + { + let _ = app_handle; + Ok(()) + } + } + + #[cfg(windows)] + mod win32 { + use core::ffi::c_void; + use windows::Win32::Foundation::{HWND, RECT}; + use windows::Win32::UI::WindowsAndMessaging::{ + GetForegroundWindow, GetWindowLongPtrW, GetWindowRect, IsWindowVisible, + SetWindowLongPtrW, SetWindowPos, ShowWindow, GWL_EXSTYLE, SWP_NOACTIVATE, SWP_NOSIZE, + SWP_NOZORDER, SW_SHOWNOACTIVATE, WS_EX_APPWINDOW, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, + }; + + const DWMWA_CLOAK: u32 = 13; + const DWMWA_CLOAKED: u32 = 14; + const OFF_SCREEN: i32 = -32000; + + #[link(name = "dwmapi")] + extern "system" { + fn DwmSetWindowAttribute(hwnd: HWND, attr: u32, value: *const c_void, size: u32) + -> i32; + fn DwmGetWindowAttribute(hwnd: HWND, attr: u32, value: *mut c_void, size: u32) -> i32; + } + + fn handle(raw: isize) -> HWND { + HWND(raw as *mut c_void) + } + + /// Cloak, move off every monitor and leave the taskbar first, then + /// show without activation: the order means no frame of the window + /// is ever composed on screen. + pub fn show_cloaked(raw: isize) { + let hwnd = handle(raw); + let on: i32 = 1; + // SAFETY: `hwnd` is the live main window, owned by this (UI) + // thread; the DWM attribute is a 4-byte BOOL. + unsafe { + DwmSetWindowAttribute(hwnd, DWMWA_CLOAK, &on as *const i32 as *const c_void, 4); + let ex = GetWindowLongPtrW(hwnd, GWL_EXSTYLE); + let ex = (ex & !(WS_EX_APPWINDOW.0 as isize)) + | WS_EX_TOOLWINDOW.0 as isize + | WS_EX_NOACTIVATE.0 as isize; + SetWindowLongPtrW(hwnd, GWL_EXSTYLE, ex); + let _ = SetWindowPos( + hwnd, + None, + OFF_SCREEN, + OFF_SCREEN, + 0, + 0, + SWP_NOACTIVATE | SWP_NOSIZE | SWP_NOZORDER, + ); + let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE); + } + log_state(hwnd, "shown"); + } + + /// One line per sample on stderr, parsed by the harness as the proof + /// that the window never reached the screen. + fn log_state(hwnd: HWND, when: &str) { + let mut cloaked: u32 = 0; + let mut rect = RECT::default(); + // SAFETY: read-only queries on a window handle; a destroyed window + // only makes them fail, which the zeros below report. + let (visible, foreground) = unsafe { + DwmGetWindowAttribute( + hwnd, + DWMWA_CLOAKED, + &mut cloaked as *mut u32 as *mut c_void, + 4, + ); + let _ = GetWindowRect(hwnd, &mut rect); + ( + IsWindowVisible(hwnd).as_bool(), + GetForegroundWindow() == hwnd, + ) + }; + eprintln!( + "parity-window {when} visible={visible} cloaked={cloaked} foreground={foreground} rect={},{},{},{}", + rect.left, rect.top, rect.right, rect.bottom + ); + } + + /// Samples the window state every 250 ms until the process exits. + pub fn spawn_monitor(raw: isize) { + let _ = std::thread::Builder::new() + .name("parity-window".into()) + .spawn(move || loop { + log_state(handle(raw), "sample"); + std::thread::sleep(std::time::Duration::from_millis(250)); + }); + } + } +} diff --git a/src-tauri/src/boot.rs b/src-tauri/src/boot.rs index 57a4066..6a47769 100644 --- a/src-tauri/src/boot.rs +++ b/src-tauri/src/boot.rs @@ -312,6 +312,13 @@ pub(crate) fn build_main_window( window_builder = window_builder.icon(icon.clone())?; } + // The parity bench's WebView2 profile lives in the run's scratch folder, + // never in the real one under %LOCALAPPDATA%\com.accshift.desktop. + #[cfg(feature = "parity-capture")] + if let Some(dir) = app_runtime::parity::webview_dir() { + window_builder = window_builder.data_directory(dir); + } + let win = timed(&mut phases.window_build_us, || window_builder.build())?; // The builder converts the logical origin with the scale Tauri picks at @@ -672,7 +679,9 @@ pub(crate) fn wire_deep_links(app: &tauri::App, setup_ctx: &AppCtx) { }; #[cfg(not(windows))] let needs_register = true; - if needs_register { + // A parity capture build is a throwaway debug exe: pointing + // accshift:// at it would break the installed app's links. + if needs_register && !cfg!(feature = "parity-capture") { if let Err(reason) = app.deep_link().register_all() { let _ = logging::append_app_log( setup_ctx, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b3b2f79..3026550 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -32,6 +32,11 @@ pub unsafe extern "C" fn accshift_run_v1(handoff: *const wry::PrestartHandoff) - } pub fn run() { + // Before anything resolves a path: without its scratch root, a parity + // capture build would fall back on the real data directories. + #[cfg(feature = "parity-capture")] + app_runtime::parity::require_root(); + // WebKitGTK's DMABUF renderer is broken on the NVIDIA proprietary driver // (white window, severe rendering lag). Opt out only on those machines; // an explicit user-set value always wins. @@ -68,8 +73,12 @@ pub fn run() { // production identifier, so the guard would refuse to start whenever an // installed Accshift is already running. Both instances still write the // same loginusers.vdf and the same Steam registry keys, so never run a - // switch in both at once. - #[cfg(not(all(debug_assertions, feature = "mcp-bridge")))] + // switch in both at once. A parity capture build skips it for the same + // reason: it runs next to the installed app, on a mock backend. + #[cfg(not(any( + all(debug_assertions, feature = "mcp-bridge"), + feature = "parity-capture" + )))] let builder = builder.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { let _ = logging::append_app_log( &ctx(app), @@ -176,7 +185,12 @@ pub fn run() { phases.deep_link_us = step.elapsed().as_micros() as u64; let step = std::time::Instant::now(); - boot::spawn_snapshot_upgrade(setup_ctx.clone()); + // Not in a parity capture build: its app dirs are an empty + // scratch folder, and the keyring sweep would read every real + // entry as an orphan of it. + if !cfg!(feature = "parity-capture") { + boot::spawn_snapshot_upgrade(setup_ctx.clone()); + } boot::spawn_boot_failsafe(app.handle().clone()); phases.threads_us = step.elapsed().as_micros() as u64; diff --git a/src-tauri/src/tauri_context.rs b/src-tauri/src/tauri_context.rs index 9572df1..2009c33 100644 --- a/src-tauri/src/tauri_context.rs +++ b/src-tauri/src/tauri_context.rs @@ -1,7 +1,10 @@ use accshift_core::{AppContext, AppCtx}; use std::path::PathBuf; use std::sync::{Arc, OnceLock}; -use tauri::{AppHandle, Manager}; +use tauri::path::PathResolver; +#[cfg_attr(feature = "parity-capture", allow(unused_imports))] +use tauri::Manager; +use tauri::{AppHandle, Wry}; /// `AppContext` implementation backed by a Tauri `AppHandle`. pub struct TauriAppContext { @@ -12,6 +15,31 @@ impl TauriAppContext { pub fn new(handle: AppHandle) -> Self { Self { handle } } + + /// One app directory. `what` names it in the error ("config", "local + /// data"). + /// + /// A parity capture build moves every directory under the run's scratch + /// root, so the reference renderer of the egui bench never reads or writes + /// the real `%APPDATA%\com.accshift.desktop` or + /// `%LOCALAPPDATA%\com.accshift.desktop`. + fn resolve( + &self, + what: &str, + real: fn(&PathResolver) -> tauri::Result, + ) -> Result { + #[cfg(feature = "parity-capture")] + { + let _ = (&self.handle, real); + crate::app_runtime::parity::root() + .map(|root| root.join(what.replace(' ', "-"))) + .ok_or_else(|| "ACCSHIFT_PARITY_DIR is not set".to_string()) + } + #[cfg(not(feature = "parity-capture"))] + { + real(self.handle.path()).map_err(|e| format!("Could not resolve app {what} dir: {e}")) + } + } } /// Return the process-wide `AppCtx` for this `AppHandle`. Cached: every @@ -25,30 +53,18 @@ pub fn ctx(handle: &AppHandle) -> AppCtx { impl AppContext for TauriAppContext { fn app_config_dir(&self) -> Result { - self.handle - .path() - .app_config_dir() - .map_err(|e| format!("Could not resolve app config dir: {e}")) + self.resolve("config", PathResolver::app_config_dir) } fn app_data_dir(&self) -> Result { - self.handle - .path() - .app_data_dir() - .map_err(|e| format!("Could not resolve app data dir: {e}")) + self.resolve("data", PathResolver::app_data_dir) } fn app_local_data_dir(&self) -> Result { - self.handle - .path() - .app_local_data_dir() - .map_err(|e| format!("Could not resolve app local data dir: {e}")) + self.resolve("local data", PathResolver::app_local_data_dir) } fn app_cache_dir(&self) -> Result { - self.handle - .path() - .app_cache_dir() - .map_err(|e| format!("Could not resolve app cache dir: {e}")) + self.resolve("cache", PathResolver::app_cache_dir) } } diff --git a/src-tauri/src/telemetry_runtime.rs b/src-tauri/src/telemetry_runtime.rs index 9551a0e..e56f254 100644 --- a/src-tauri/src/telemetry_runtime.rs +++ b/src-tauri/src/telemetry_runtime.rs @@ -34,7 +34,13 @@ impl TelemetryState { cfg.telemetry.anonymous_id = anonymous_id; } } - let consent = telemetry::consent_from_config(&cfg.telemetry); + // The parity capture build of the egui bench sends nothing: default + // consent drops every event before it reaches the network. + let consent = if cfg!(feature = "parity-capture") { + telemetry::ConsentState::default() + } else { + telemetry::consent_from_config(&cfg.telemetry) + }; let tctx = telemetry::context_for(env!("CARGO_PKG_VERSION"), "gui"); let worker = Worker::spawn(tctx, consent, QueueParams::default()); Self { From 82e037e08589d680fc848eae813f34a41fe52ee0 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:55:30 +0200 Subject: [PATCH 008/106] feat(app): add the backend trait, mock backend and job runner --- crates/accshift-app/Cargo.lock | 2863 +++++++++++++++++ crates/accshift-app/Cargo.toml | 17 + crates/accshift-app/src/backend.rs | 284 ++ crates/accshift-app/src/dispatch.rs | 251 ++ crates/accshift-app/src/error.rs | 73 + crates/accshift-app/src/jobs.rs | 149 + crates/accshift-app/src/lib.rs | 19 + crates/accshift-app/src/mock.rs | 973 ++++++ crates/accshift-app/src/real.rs | 379 +++ crates/accshift-app/src/types.rs | 109 + crates/accshift-app/tests/mock_parity.rs | 390 +++ .../src/platforms/descriptor/library.rs | 4 +- crates/accshift-core/src/platforms/mod.rs | 10 +- .../accshift-core/src/platforms/steam/bans.rs | 2 +- .../src/platforms/steam/bulk_edit.rs | 4 +- .../src/platforms/steam/cs2_bridge.rs | 4 +- crates/accshift-core/src/storage.rs | 6 +- 17 files changed, 5522 insertions(+), 15 deletions(-) create mode 100644 crates/accshift-app/Cargo.lock create mode 100644 crates/accshift-app/Cargo.toml create mode 100644 crates/accshift-app/src/backend.rs create mode 100644 crates/accshift-app/src/dispatch.rs create mode 100644 crates/accshift-app/src/error.rs create mode 100644 crates/accshift-app/src/jobs.rs create mode 100644 crates/accshift-app/src/lib.rs create mode 100644 crates/accshift-app/src/mock.rs create mode 100644 crates/accshift-app/src/real.rs create mode 100644 crates/accshift-app/src/types.rs create mode 100644 crates/accshift-app/tests/mock_parity.rs diff --git a/crates/accshift-app/Cargo.lock b/crates/accshift-app/Cargo.lock new file mode 100644 index 0000000..91a7d81 --- /dev/null +++ b/crates/accshift-app/Cargo.lock @@ -0,0 +1,2863 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "accshift-app" +version = "0.0.0" +dependencies = [ + "accshift-core", + "directories", + "serde", + "serde_json", +] + +[[package]] +name = "accshift-core" +version = "1.0.5" +dependencies = [ + "fs4", + "keyring", + "open", + "pbkdf2", + "reqwest", + "rusqlite", + "serde", + "serde_json", + "sha2 0.11.0", + "sysinfo", + "thiserror", + "tokio", + "uuid", + "windows-sys 0.61.2", + "winreg", + "zeroize", +] + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[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 = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a649af8a827553c29fb0cb4bd4a6f1a0dd695bd3232b9bc98bd9c8a3ffbb8b" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[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" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +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 = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2 0.10.9", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs4" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e72ed92b67c146290f88e9c89d60ca163ea417a446f61ffd7b72df3e7f1dfd5" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "io-uring" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3bd0ecfbb87805f538bb7b32e5239ca0763890c623e349860ecba69469f2bb" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "dbus-secret-service", + "log", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.7.0", + "zeroize", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", + "hmac 0.13.0", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.8", + "serde", + "sha2 0.10.9", + "zbus", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "serde_json" +version = "1.0.152" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1741ab7a6cc54a03a89b5d563ed60075c277d9e3cfa73ad0c1f23f23974703c6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d62a2e0561533f2ca2561d0cf27fd9fedb640a1bf2616ff5d5c80d99017faadc" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "sysinfo" +version = "0.39.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.47.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f885cc8c20014400b9ec7f7d5fa7917176d306f1c0241c0fe183af95f9a3c1f7" +dependencies = [ + "backtrace", + "bytes", + "io-uring", + "libc", + "mio", + "pin-project-lite", + "slab", + "socket2", + "tokio-macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.7", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +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 = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", + "synstructure", +] + +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.8", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/crates/accshift-app/Cargo.toml b/crates/accshift-app/Cargo.toml new file mode 100644 index 0000000..35741a4 --- /dev/null +++ b/crates/accshift-app/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "accshift-app" +description = "UI-free logic of the accshift egui port: the backend trait, its mock and the job runner" +version = "0.0.0" +edition = "2021" +publish = false + +# Standalone during the port, like crates/accshift-egui: the root workspace +# excludes this folder, so its CI build stays what it is today. The crate +# joins the root workspace when the egui shell replaces the Tauri one. +[workspace] + +[dependencies] +accshift-core = { path = "../accshift-core" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +directories = "6" diff --git a/crates/accshift-app/src/backend.rs b/crates/accshift-app/src/backend.rs new file mode 100644 index 0000000..88faa6f --- /dev/null +++ b/crates/accshift-app/src/backend.rs @@ -0,0 +1,284 @@ +//! The `Backend` trait: one blocking method per Tauri command the frontend +//! calls, minus the window controls the egui shell does itself. +//! +//! Method names are the command names. Arguments are the Rust parameters of +//! the command in `src-tauri/src/commands/*.rs` (the webview sends them in +//! camelCase, Tauri renames them). Return values serialize to the JSON the +//! webview receives. Every method blocks: call it from [`crate::jobs`], never +//! from the UI thread. + +use crate::error::BackendResult; +use crate::types::*; +use serde_json::Value; +use std::collections::BTreeMap; + +/// Every command of [`Backend`], in trait order. The window commands +/// (`minimize_window`, `toggle_maximize_window`, `close_window`, +/// `set_maximize_button_rect`, `set_keep_backdrop_active`) are not in it, +/// nor is `diagnostics`, which no frontend code calls. +pub const COMMANDS: &[&str] = &[ + "get_boot_payload", + "get_runtime_os", + "detect_streaming_software", + "migrate_legacy_config", + "load_client_storage_snapshot", + "save_client_storage_store", + "get_storage_manifest", + "log_app_event", + "finish_boot", + "pin_unlock", + "pin_lock", + "platform_get_accounts", + "platform_get_startup_snapshot", + "platform_get_current_account", + "platform_switch_account", + "platform_forget_account", + "platform_begin_setup", + "platform_get_setup_status", + "platform_cancel_setup", + "platform_get_path", + "platform_set_path", + "platform_select_path", + "platform_detect_installed", + "platform_set_account_label", + "platform_dry_run", + "reload_user_platforms", + "descriptor_select_file", + "descriptor_preview_file", + "descriptor_install_file", + "descriptor_remove", + "open_descriptors_folder", + "open_url", + "open_logs_folder", + "get_desktop_wallpaper", + "steam_set_api_key", + "steam_has_api_key", + "steam_open_api_key_page", + "steam_switch_account_and_launch_game", + "steam_get_profile_info", + "steam_get_profile_infos", + "steam_get_player_bans", + "steam_copy_game_settings", + "steam_get_copyable_games", + "steam_open_userdata", + "steam_clear_browser_cache", + "steam_bulk_edit", + "steam_get_account_games", + "cs2_bridge_get_settings", + "cs2_bridge_set_settings", + "cs2_bridge_fetch", + "cs2_bridge_check", + "cs2_bridge_test", + "riot_capture_profile", + "roblox_add_account_by_cookie", + "roblox_get_profile_info", + "roblox_check_sessions", + "list_custom_themes", + "save_custom_theme", + "delete_custom_theme", + "telemetry_get_state", + "telemetry_set_mode_a", + "telemetry_set_mode_b", + "telemetry_retry_forget", + "telemetry_complete_onboarding", + "telemetry_track_persona_switch", + "telemetry_track_account_added", + "telemetry_track_account_add_started", + "telemetry_track_account_add_cancelled", + "telemetry_track_operation_failed", + "telemetry_track_update", + "telemetry_track_settings_snapshot", + "telemetry_track_streamer_mode", + "telemetry_export", +]; + +/// The logic layer the UI talks to. `Send + Sync` so one instance serves the +/// whole job pool. +pub trait Backend: Send + Sync { + // ---------------------------------------------------------------- app + + /// Legacy migration, client storage snapshot, custom themes, runtime OS and + /// the user descriptor folder, in one call. + fn get_boot_payload(&self) -> BackendResult; + /// `windows`, `macos` or `linux`. + fn get_runtime_os(&self) -> BackendResult; + /// True when OBS, Streamlabs, XSplit and the like are running. + fn detect_streaming_software(&self) -> BackendResult; + /// `migrated`, `none` or `error:`. + fn migrate_legacy_config(&self) -> BackendResult; + fn load_client_storage_snapshot(&self) -> BackendResult; + /// The store's new fingerprint. `None` from the mock, which saves nothing. + fn save_client_storage_store( + &self, + store_id: &str, + value: &Value, + ) -> BackendResult>; + fn get_storage_manifest(&self) -> BackendResult; + fn log_app_event( + &self, + level: &str, + source: &str, + message: &str, + details: Option<&str>, + ) -> BackendResult<()>; + /// Boot milestones and first-run telemetry. The Tauri command also shows + /// the window; the egui shell does that part itself. + fn finish_boot(&self, source: &str, marks: Option<&Value>) -> BackendResult<()>; + + // ---------------------------------------------------------------- PIN + + fn pin_unlock(&self, code: &str) -> BackendResult; + fn pin_lock(&self) -> BackendResult<()>; + + // ---------------------------------------------------------------- platforms + + /// The platform's own account list (`SteamAccount[]`, Riot profiles, + /// `RobloxAccount[]`, descriptor accounts), as the command's `Value`. + fn platform_get_accounts(&self, platform_id: &str) -> BackendResult; + /// Accounts and current account in one call; the shape is the platform's. + fn platform_get_startup_snapshot(&self, platform_id: &str) -> BackendResult; + fn platform_get_current_account(&self, platform_id: &str) -> BackendResult; + /// Refused with the `pin_locked:` message while the PIN session is locked. + fn platform_switch_account( + &self, + platform_id: &str, + account_id: &str, + params: &Value, + ) -> BackendResult<()>; + fn platform_forget_account(&self, platform_id: &str, account_id: &str) -> BackendResult<()>; + fn platform_begin_setup(&self, platform_id: &str, params: &Value) + -> BackendResult; + fn platform_get_setup_status( + &self, + platform_id: &str, + setup_id: &str, + ) -> BackendResult; + fn platform_cancel_setup(&self, platform_id: &str, setup_id: &str) -> BackendResult<()>; + fn platform_get_path(&self, platform_id: &str) -> BackendResult; + fn platform_set_path(&self, platform_id: &str, path: &str) -> BackendResult<()>; + /// Opens a folder picker. Cancelling is an error. + fn platform_select_path(&self, platform_id: &str) -> BackendResult; + fn platform_detect_installed(&self) -> BackendResult>; + fn platform_set_account_label( + &self, + platform_id: &str, + account_id: &str, + label: &str, + ) -> BackendResult<()>; + fn platform_dry_run(&self, platform_id: &str, account_id: &str) -> BackendResult; + + // ---------------------------------------------------------------- descriptors + + fn reload_user_platforms(&self) -> BackendResult; + /// Opens a file picker. Cancelling is an error. + fn descriptor_select_file(&self) -> BackendResult; + fn descriptor_preview_file(&self, path: &str) -> BackendResult; + fn descriptor_install_file(&self, path: &str) -> BackendResult; + fn descriptor_remove(&self, platform_id: &str) -> BackendResult; + fn open_descriptors_folder(&self) -> BackendResult<()>; + + // ---------------------------------------------------------------- utility + + fn open_url(&self, url: &str) -> BackendResult<()>; + fn open_logs_folder(&self) -> BackendResult<()>; + /// `None` when there is nothing to capture (and always from the mock). + fn get_desktop_wallpaper(&self) -> BackendResult>; + + // ---------------------------------------------------------------- Steam + + fn steam_set_api_key(&self, key: &str) -> BackendResult<()>; + fn steam_has_api_key(&self) -> BackendResult; + fn steam_open_api_key_page(&self) -> BackendResult<()>; + fn steam_switch_account_and_launch_game( + &self, + username: &str, + app_id: &str, + run_as_admin: bool, + launch_options: &str, + shutdown_mode: &str, + ) -> BackendResult<()>; + fn steam_get_profile_info(&self, steam_id: &str) -> BackendResult>; + /// Ids without a result are absent from the map. + fn steam_get_profile_infos( + &self, + steam_ids: &[String], + ) -> BackendResult>; + fn steam_get_player_bans(&self, steam_ids: &[String]) -> BackendResult>; + fn steam_copy_game_settings( + &self, + from_steam_id: &str, + to_steam_id: &str, + app_id: &str, + ) -> BackendResult<()>; + fn steam_get_copyable_games( + &self, + from_steam_id: &str, + to_steam_id: &str, + ) -> BackendResult>; + fn steam_open_userdata(&self, steam_id: &str) -> BackendResult<()>; + fn steam_clear_browser_cache(&self) -> BackendResult<()>; + fn steam_bulk_edit(&self, request: BulkEditRequest) -> BackendResult; + fn steam_get_account_games(&self, steam_id: &str) -> BackendResult>; + + // ---------------------------------------------------------------- CS2 bridge + + fn cs2_bridge_get_settings(&self) -> BackendResult; + /// `token` `None` keeps the stored token, an empty one clears it. + fn cs2_bridge_set_settings( + &self, + enabled: bool, + url: &str, + token: Option<&str>, + ) -> BackendResult<()>; + fn cs2_bridge_fetch(&self) -> BackendResult>; + /// `None` when the bridge is off. + fn cs2_bridge_check(&self, steam_id: &str) -> BackendResult>; + fn cs2_bridge_test(&self) -> BackendResult; + + // ---------------------------------------------------------------- Riot, Roblox + + fn riot_capture_profile(&self, profile_id: &str) -> BackendResult<()>; + fn roblox_add_account_by_cookie(&self, cookie: &str) -> BackendResult; + fn roblox_get_profile_info(&self, user_id: &str) -> BackendResult; + /// User ids whose stored session is dead. + fn roblox_check_sessions(&self) -> BackendResult>; + + // ---------------------------------------------------------------- themes + + fn list_custom_themes(&self) -> BackendResult>; + fn save_custom_theme(&self, theme: &CustomTheme) -> BackendResult<()>; + fn delete_custom_theme(&self, theme_id: &str) -> BackendResult<()>; + + // ---------------------------------------------------------------- telemetry + + fn telemetry_get_state(&self) -> BackendResult; + fn telemetry_set_mode_a(&self, enabled: bool) -> BackendResult<()>; + fn telemetry_set_mode_b(&self, enabled: bool) -> BackendResult<()>; + fn telemetry_retry_forget(&self) -> BackendResult<()>; + fn telemetry_complete_onboarding( + &self, + mode_a_enabled: bool, + mode_b_enabled: bool, + ) -> BackendResult<()>; + fn telemetry_track_persona_switch(&self, platforms: u64, succeeded: u64) -> BackendResult<()>; + fn telemetry_track_account_added(&self, platform_id: &str) -> BackendResult<()>; + fn telemetry_track_account_add_started(&self, platform_id: &str) -> BackendResult<()>; + fn telemetry_track_account_add_cancelled(&self, platform_id: &str) -> BackendResult<()>; + fn telemetry_track_operation_failed( + &self, + operation: &str, + error_code: &str, + platform_id: Option<&str>, + ) -> BackendResult<()>; + /// `stage` is `available`, `downloaded`, `applied` or `failed`. + fn telemetry_track_update( + &self, + stage: &str, + target_version: Option<&str>, + error_code: Option<&str>, + ) -> BackendResult<()>; + fn telemetry_track_settings_snapshot(&self, snapshot: &SettingsSnapshot) -> BackendResult<()>; + fn telemetry_track_streamer_mode(&self) -> BackendResult<()>; + /// What the telemetry backend holds for this install (Mode B). + fn telemetry_export(&self) -> BackendResult; +} diff --git a/crates/accshift-app/src/dispatch.rs b/crates/accshift-app/src/dispatch.rs new file mode 100644 index 0000000..f551db5 --- /dev/null +++ b/crates/accshift-app/src/dispatch.rs @@ -0,0 +1,251 @@ +//! Dynamic entry into a [`Backend`]: a command name and its arguments as the +//! webview sends them (camelCase JSON), the result as the JSON it receives. +//! +//! The UI calls the typed methods. This exists for the places that only have +//! a name and a JSON object: the mock's parity tests, a scene harness, a debug +//! bridge. +//! +//! Arguments are read leniently, like the TS mock handlers read theirs: a +//! missing string is empty, a missing flag false, a missing number zero. The +//! real Tauri command would reject the call instead. + +use crate::backend::Backend; +use crate::error::{BackendError, BackendResult}; +use crate::types::{BulkEditRequest, CustomTheme, SettingsSnapshot}; +use serde::de::DeserializeOwned; +use serde::Serialize; +use serde_json::Value; + +/// Runs `command` with `args` on `backend`. `None` for a name that is not a +/// command of [`Backend`]. +pub fn dispatch( + backend: &dyn Backend, + command: &str, + args: &Value, +) -> Option> { + let a = Args(args); + let b = backend; + Some(match command { + "get_boot_payload" => json(b.get_boot_payload()), + "get_runtime_os" => json(b.get_runtime_os()), + "detect_streaming_software" => json(b.detect_streaming_software()), + "migrate_legacy_config" => json(b.migrate_legacy_config()), + "load_client_storage_snapshot" => json(b.load_client_storage_snapshot()), + "save_client_storage_store" => { + json(b.save_client_storage_store(&a.str("storeId"), &a.value("value"))) + } + "get_storage_manifest" => json(b.get_storage_manifest()), + "log_app_event" => json(b.log_app_event( + &a.str("level"), + &a.str("source"), + &a.str("message"), + a.opt_str("details").as_deref(), + )), + "finish_boot" => { + let marks = a.value("marks"); + json(b.finish_boot(&a.str("source"), (!marks.is_null()).then_some(&marks))) + } + "pin_unlock" => json(b.pin_unlock(&a.str("code"))), + "pin_lock" => json(b.pin_lock()), + "platform_get_accounts" => json(b.platform_get_accounts(&a.str("platformId"))), + "platform_get_startup_snapshot" => { + json(b.platform_get_startup_snapshot(&a.str("platformId"))) + } + "platform_get_current_account" => { + json(b.platform_get_current_account(&a.str("platformId"))) + } + "platform_switch_account" => json(b.platform_switch_account( + &a.str("platformId"), + &a.str("accountId"), + &a.value("params"), + )), + "platform_forget_account" => { + json(b.platform_forget_account(&a.str("platformId"), &a.str("accountId"))) + } + "platform_begin_setup" => { + json(b.platform_begin_setup(&a.str("platformId"), &a.value("params"))) + } + "platform_get_setup_status" => { + json(b.platform_get_setup_status(&a.str("platformId"), &a.str("setupId"))) + } + "platform_cancel_setup" => { + json(b.platform_cancel_setup(&a.str("platformId"), &a.str("setupId"))) + } + "platform_get_path" => json(b.platform_get_path(&a.str("platformId"))), + "platform_set_path" => json(b.platform_set_path(&a.str("platformId"), &a.str("path"))), + "platform_select_path" => json(b.platform_select_path(&a.str("platformId"))), + "platform_detect_installed" => json(b.platform_detect_installed()), + "platform_set_account_label" => json(b.platform_set_account_label( + &a.str("platformId"), + &a.str("accountId"), + &a.str("label"), + )), + "platform_dry_run" => json(b.platform_dry_run(&a.str("platformId"), &a.str("accountId"))), + "reload_user_platforms" => json(b.reload_user_platforms()), + "descriptor_select_file" => json(b.descriptor_select_file()), + "descriptor_preview_file" => json(b.descriptor_preview_file(&a.str("path"))), + "descriptor_install_file" => json(b.descriptor_install_file(&a.str("path"))), + "descriptor_remove" => json(b.descriptor_remove(&a.str("platformId"))), + "open_descriptors_folder" => json(b.open_descriptors_folder()), + "open_url" => json(b.open_url(&a.str("url"))), + "open_logs_folder" => json(b.open_logs_folder()), + "get_desktop_wallpaper" => json(b.get_desktop_wallpaper()), + "steam_set_api_key" => json(b.steam_set_api_key(&a.str("key"))), + "steam_has_api_key" => json(b.steam_has_api_key()), + "steam_open_api_key_page" => json(b.steam_open_api_key_page()), + "steam_switch_account_and_launch_game" => json(b.steam_switch_account_and_launch_game( + &a.str("username"), + &a.str("appId"), + a.bool("runAsAdmin"), + &a.str("launchOptions"), + &a.str("shutdownMode"), + )), + // The TS mock reads `accountId` and the frontend sends `steamId`. + "steam_get_profile_info" => { + json(b.steam_get_profile_info(&a.str_any(&["steamId", "accountId"]))) + } + "steam_get_profile_infos" => { + json(b.steam_get_profile_infos(&a.strings_any(&["steamIds", "accountIds"]))) + } + "steam_get_player_bans" => json(b.steam_get_player_bans(&a.strings_any(&["steamIds"]))), + "steam_copy_game_settings" => json(b.steam_copy_game_settings( + &a.str("fromSteamId"), + &a.str("toSteamId"), + &a.str("appId"), + )), + "steam_get_copyable_games" => { + json(b.steam_get_copyable_games(&a.str("fromSteamId"), &a.str("toSteamId"))) + } + "steam_open_userdata" => json(b.steam_open_userdata(&a.str("steamId"))), + "steam_clear_browser_cache" => json(b.steam_clear_browser_cache()), + "steam_bulk_edit" => match a.decode::("request") { + Ok(request) => json(b.steam_bulk_edit(request)), + Err(error) => Err(error), + }, + "steam_get_account_games" => json(b.steam_get_account_games(&a.str("steamId"))), + "cs2_bridge_get_settings" => json(b.cs2_bridge_get_settings()), + "cs2_bridge_set_settings" => json(b.cs2_bridge_set_settings( + a.bool("enabled"), + &a.str("url"), + a.opt_str("token").as_deref(), + )), + "cs2_bridge_fetch" => json(b.cs2_bridge_fetch()), + "cs2_bridge_check" => json(b.cs2_bridge_check(&a.str("steamId"))), + "cs2_bridge_test" => json(b.cs2_bridge_test()), + "riot_capture_profile" => json(b.riot_capture_profile(&a.str("profileId"))), + "roblox_add_account_by_cookie" => json(b.roblox_add_account_by_cookie(&a.str("cookie"))), + "roblox_get_profile_info" => { + json(b.roblox_get_profile_info(&a.str_any(&["userId", "accountId"]))) + } + "roblox_check_sessions" => json(b.roblox_check_sessions()), + "list_custom_themes" => json(b.list_custom_themes()), + "save_custom_theme" => match a.decode::("theme") { + Ok(theme) => json(b.save_custom_theme(&theme)), + Err(error) => Err(error), + }, + "delete_custom_theme" => json(b.delete_custom_theme(&a.str("themeId"))), + "telemetry_get_state" => json(b.telemetry_get_state()), + "telemetry_set_mode_a" => json(b.telemetry_set_mode_a(a.bool("enabled"))), + "telemetry_set_mode_b" => json(b.telemetry_set_mode_b(a.bool("enabled"))), + "telemetry_retry_forget" => json(b.telemetry_retry_forget()), + "telemetry_complete_onboarding" => { + json(b.telemetry_complete_onboarding(a.bool("modeAEnabled"), a.bool("modeBEnabled"))) + } + "telemetry_track_persona_switch" => { + json(b.telemetry_track_persona_switch(a.u64("platforms"), a.u64("succeeded"))) + } + "telemetry_track_account_added" => { + json(b.telemetry_track_account_added(&a.str("platformId"))) + } + "telemetry_track_account_add_started" => { + json(b.telemetry_track_account_add_started(&a.str("platformId"))) + } + "telemetry_track_account_add_cancelled" => { + json(b.telemetry_track_account_add_cancelled(&a.str("platformId"))) + } + "telemetry_track_operation_failed" => json(b.telemetry_track_operation_failed( + &a.str("operation"), + &a.str("errorCode"), + a.opt_str("platformId").as_deref(), + )), + "telemetry_track_update" => json(b.telemetry_track_update( + &a.str("stage"), + a.opt_str("targetVersion").as_deref(), + a.opt_str("errorCode").as_deref(), + )), + // The frontend spreads the snapshot's fields into the arguments. + "telemetry_track_settings_snapshot" => match a.decode_whole::() { + Ok(snapshot) => json(b.telemetry_track_settings_snapshot(&snapshot)), + Err(error) => Err(error), + }, + "telemetry_track_streamer_mode" => json(b.telemetry_track_streamer_mode()), + "telemetry_export" => json(b.telemetry_export()), + _ => return None, + }) +} + +fn json(result: BackendResult) -> BackendResult { + result.and_then(|value| { + serde_json::to_value(value).map_err(|e| BackendError::new(format!("serialize: {e}"))) + }) +} + +/// The argument object, read the way a JS handler reads `args.x`. +struct Args<'a>(&'a Value); + +impl Args<'_> { + fn get(&self, key: &str) -> Option<&Value> { + self.0.get(key).filter(|v| !v.is_null()) + } + + fn opt_str(&self, key: &str) -> Option { + self.get(key).map(|v| match v { + Value::String(s) => s.clone(), + other => other.to_string(), + }) + } + + fn str(&self, key: &str) -> String { + self.opt_str(key).unwrap_or_default() + } + + fn str_any(&self, keys: &[&str]) -> String { + keys.iter() + .find_map(|key| self.opt_str(key)) + .unwrap_or_default() + } + + fn strings_any(&self, keys: &[&str]) -> Vec { + keys.iter() + .find_map(|key| self.get(key).and_then(Value::as_array)) + .map(|items| { + items + .iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() + } + + fn bool(&self, key: &str) -> bool { + self.get(key).and_then(Value::as_bool).unwrap_or(false) + } + + fn u64(&self, key: &str) -> u64 { + self.get(key).and_then(Value::as_u64).unwrap_or(0) + } + + fn value(&self, key: &str) -> Value { + self.get(key).cloned().unwrap_or(Value::Null) + } + + fn decode(&self, key: &str) -> BackendResult { + serde_json::from_value(self.value(key)) + .map_err(|e| BackendError::new(format!("invalid args `{key}`: {e}"))) + } + + fn decode_whole(&self) -> BackendResult { + serde_json::from_value(self.0.clone()) + .map_err(|e| BackendError::new(format!("invalid args: {e}"))) + } +} diff --git a/crates/accshift-app/src/error.rs b/crates/accshift-app/src/error.rs new file mode 100644 index 0000000..770f16e --- /dev/null +++ b/crates/accshift-app/src/error.rs @@ -0,0 +1,73 @@ +//! The one error type every backend call returns. + +use accshift_core::error::PlatformError; +use serde::Serialize; + +/// A failed backend call, carrying the exact message the webview receives +/// today: Tauri sends a rejected command to the frontend as a bare string +/// (`PlatformError` serializes as its message, `Result<_, String>` as the +/// string), and the UI shows that string. Keeping only the message keeps the +/// egui side showing the same text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BackendError { + pub message: String, +} + +impl BackendError { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } + + /// The refusal the mock answers for a guarded command it does not fake. + /// Same text as `mockInvoke` in `src/lib/mock/runtime.ts`. + pub fn mock_refused(command: &str) -> Self { + Self::new(format!("mock mode: {command} is not available")) + } + + /// What the real backend answers until its job fills a command in. + pub fn not_ported(command: &str) -> Self { + Self::new(format!("{command} is not ported to the egui backend yet")) + } + + /// True for the PIN refusal (`isPinLockedError` in the frontend). + pub fn is_pin_locked(&self) -> bool { + self.message.starts_with("pin_locked:") + } +} + +impl std::fmt::Display for BackendError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for BackendError {} + +/// Serialized as the bare message, like the rejection the webview gets. +impl Serialize for BackendError { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.message) + } +} + +impl From for BackendError { + fn from(error: PlatformError) -> Self { + Self::new(error.message) + } +} + +impl From for BackendError { + fn from(message: String) -> Self { + Self::new(message) + } +} + +impl From<&str> for BackendError { + fn from(message: &str) -> Self { + Self::new(message) + } +} + +pub type BackendResult = Result; diff --git a/crates/accshift-app/src/jobs.rs b/crates/accshift-app/src/jobs.rs new file mode 100644 index 0000000..23e5e2d --- /dev/null +++ b/crates/accshift-app/src/jobs.rs @@ -0,0 +1,149 @@ +//! Backend calls off the UI thread. +//! +//! A small fixed pool of threads runs closures against the shared backend. +//! Each call hands back a [`Pending`] the UI polls once per frame; when a +//! result lands, the pool calls the wake callback so an idle UI repaints and +//! picks it up (the egui shell passes `Context::request_repaint`). No async +//! runtime: every backend method blocks, so threads are the whole story. + +use crate::backend::Backend; +use std::sync::mpsc::{self, Receiver, Sender, TryRecvError}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; + +type Task = Box; +type Wake = Arc; + +/// The pool. Dropping it lets queued jobs finish, then joins the threads. +pub struct Jobs { + backend: Arc, + queue: Option>, + workers: Vec>, + wake: Wake, +} + +impl Jobs { + /// `threads` workers (at least one) serving `backend`. `wake` runs on a + /// worker thread after each result is delivered. + pub fn new( + backend: Arc, + threads: usize, + wake: impl Fn() + Send + Sync + 'static, + ) -> Self { + let (queue, tasks) = mpsc::channel::(); + let tasks = Arc::new(Mutex::new(tasks)); + let workers = (0..threads.max(1)) + .map(|index| { + let tasks = Arc::clone(&tasks); + let backend = Arc::clone(&backend); + std::thread::Builder::new() + .name(format!("accshift-job-{index}")) + .spawn(move || loop { + // The lock is held only while taking a task, never + // while running it. + let next = tasks.lock().map(|rx| rx.recv()); + match next { + // A panicking call drops its sender, so its + // Pending reads Gone; the worker lives on. + Ok(Ok(task)) => { + let _ = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + task(backend.as_ref()) + })); + } + _ => break, + } + }) + .expect("spawn job worker") + }) + .collect(); + Self { + backend, + queue: Some(queue), + workers, + wake: Arc::new(wake), + } + } + + /// The backend the pool serves, for a synchronous call where blocking is + /// fine (tests, a CLI, shutdown). + pub fn backend(&self) -> &Arc { + &self.backend + } + + /// Queues `call` and returns its pending result. + pub fn run(&self, call: F) -> Pending + where + T: Send + 'static, + F: FnOnce(&dyn Backend) -> T + Send + 'static, + { + let (tx, rx) = mpsc::channel(); + let wake = Arc::clone(&self.wake); + let task: Task = Box::new(move |backend| { + // A dropped Pending means nobody waits for this result any more. + if tx.send(call(backend)).is_ok() { + wake(); + } + }); + if let Some(queue) = &self.queue { + // Workers only stop once the queue is dropped, in Drop. + let _ = queue.send(task); + } + Pending { rx, done: false } + } +} + +impl Drop for Jobs { + fn drop(&mut self) { + self.queue = None; + for worker in self.workers.drain(..) { + let _ = worker.join(); + } + } +} + +/// What a polled [`Pending`] holds. +#[derive(Debug, PartialEq, Eq)] +pub enum Poll { + /// Still running. + Waiting, + /// The result, handed out once. + Ready(T), + /// Already taken, or the worker died before answering (the call panicked). + Gone, +} + +/// A backend call in flight. Poll it from the UI thread. +#[derive(Debug)] +pub struct Pending { + rx: Receiver, + done: bool, +} + +impl Pending { + /// Non-blocking: the result once, then [`Poll::Gone`]. + pub fn poll(&mut self) -> Poll { + if self.done { + return Poll::Gone; + } + match self.rx.try_recv() { + Ok(value) => { + self.done = true; + Poll::Ready(value) + } + Err(TryRecvError::Empty) => Poll::Waiting, + Err(TryRecvError::Disconnected) => { + self.done = true; + Poll::Gone + } + } + } + + /// Blocks until the result arrives. `None` if the call panicked. + pub fn wait(self) -> Option { + if self.done { + return None; + } + self.rx.recv().ok() + } +} diff --git a/crates/accshift-app/src/lib.rs b/crates/accshift-app/src/lib.rs new file mode 100644 index 0000000..e037316 --- /dev/null +++ b/crates/accshift-app/src/lib.rs @@ -0,0 +1,19 @@ +//! UI-free logic of the accshift egui port. +//! +//! - [`backend::Backend`]: one blocking method per Tauri command the frontend +//! calls, with the same payloads ([`types`]) and error strings ([`error`]). +//! - [`mock::MockBackend`]: the TS mock's data, for parity work. +//! - [`real::RealBackend`]: the real backend, a skeleton until it is ported. +//! - [`jobs`]: runs calls off the UI thread and wakes the UI on each result. +//! - [`dispatch`]: the same commands by name, with JSON arguments. + +pub mod backend; +pub mod dispatch; +pub mod error; +pub mod jobs; +pub mod mock; +pub mod real; +pub mod types; + +pub use backend::{Backend, COMMANDS}; +pub use error::{BackendError, BackendResult}; diff --git a/crates/accshift-app/src/mock.rs b/crates/accshift-app/src/mock.rs new file mode 100644 index 0000000..bc859ef --- /dev/null +++ b/crates/accshift-app/src/mock.rs @@ -0,0 +1,973 @@ +//! The mock backend: the TS mock's data, served through [`Backend`]. +//! +//! Built from `crates/accshift-egui/parity/data/.json`, which +//! `crates/accshift-egui/parity/dump-data.mjs` exports from the very fixtures +//! the reference app runs on (format in the header of that script). Recorded +//! results are served as recorded; the few commands with state are modelled +//! the way `src/lib/mock/fixtures.ts` and `src/lib/mock/runtime.ts` model them: +//! +//! - a switch waits the scenario's `switchDelayMs`, then changes the current +//! account, which `platform_get_current_account` and +//! `platform_get_startup_snapshot` report from then on; +//! - the PIN session starts locked when the scenario's settings carry a PIN, +//! and a switch is refused with the `pin_locked:` message until +//! `pin_unlock` gets the code; +//! - `save_client_storage_store` saves nothing; +//! - a guarded command (`platform_`, `steam_`, `riot_`, `roblox_`, +//! `cs2_bridge_`, `telemetry_`, `pin_`) the mock does not fake is refused +//! with `mock mode: is not available`; +//! - [`MockBackend::fail`] and [`MockBackend::latency`] are the `__mock.fail` +//! and `__mock.latency` overrides, ignored on the frozen `demo` scenario. +//! +//! Nothing here reaches Steam, the keyring, the registry or the disk outside +//! the scenario file. + +use crate::backend::Backend; +use crate::error::{BackendError, BackendResult}; +use crate::types::*; +use accshift_core::pin::{verify_pin_code, PinLock, PinSettings, PinVerdict, PIN_LOCKED_MESSAGE}; +use serde::de::DeserializeOwned; +use serde::Deserialize; +use serde_json::{json, Map, Value}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard}; +use std::time::Duration; + +/// The mock clock, in seconds: every invented date is relative to it. +/// `NOW` in `src/lib/mock/fixtures.ts`. +pub const NOW: u64 = 1_791_000_000; + +/// Version of the scenario files this reads. +pub const SCENARIO_FORMAT: u64 = 1; + +/// The scenario whose overrides are ignored (`FROZEN` in `runtime.ts`). +pub const FROZEN_SCENARIO: &str = "demo"; + +/// Where avatar paths in the scenario data point, relative to the repo root. +pub const AVATAR_DIR: &str = "src/lib/mock/avatars/"; + +/// Scenario files, relative to the repo root. +pub const DATA_DIR: &str = "crates/accshift-egui/parity/data"; + +/// `GUARDED_PREFIXES` in `runtime.ts`. +const GUARDED_PREFIXES: [&str; 7] = [ + "platform_", + "steam_", + "riot_", + "roblox_", + "cs2_bridge_", + "telemetry_", + "pin_", +]; + +/// Scenario fields no command returns. `spec` in the scenario file. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MockSpec { + pub label: String, + pub runtime_os: String, + pub switch_delay_ms: u64, + pub steam_path: String, + pub has_steam_api_key: bool, + pub current_steam_account: String, + pub current_riot_profile: String, + pub current_roblox_account: String, +} + +#[derive(Deserialize)] +struct ScenarioFile { + format: u64, + scenario: String, + now: u64, + spec: MockSpec, + commands: BTreeMap>>, + #[serde(default)] + skipped: BTreeMap, +} + +/// One recorded call: its arguments and what the TS mock answered. +#[derive(Debug, Clone, PartialEq)] +pub struct RecordedCall { + pub args: Value, + /// `Ok(result)` or `Err(message)`. + pub outcome: Result, +} + +struct MockState { + runtime_os: String, + /// Platform id to current account id, for `steam`, `riot` and `roblox`. + current: BTreeMap, + /// Normalized PIN hash of the live settings, empty when no PIN is set. + pin_hash: String, + pin_unlocked: bool, + latency_ms: u64, + failures: BTreeMap, +} + +/// See the module documentation. +pub struct MockBackend { + scenario: String, + now: u64, + spec: MockSpec, + calls: BTreeMap>, + skipped: BTreeMap, + repo_root: Option, + state: Mutex, +} + +impl MockBackend { + /// `/.json`. With `repo_root`, avatar paths become + /// paths of files under `/src/lib/mock/avatars/`; without it + /// they stay repo-relative, as recorded. + pub fn load(data_dir: &Path, scenario: &str, repo_root: Option<&Path>) -> Result { + Self::from_file(&data_dir.join(format!("{scenario}.json")), repo_root) + } + + pub fn from_file(path: &Path, repo_root: Option<&Path>) -> Result { + let text = + std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?; + Self::from_json(&text, repo_root).map_err(|e| format!("{}: {e}", path.display())) + } + + pub fn from_json(text: &str, repo_root: Option<&Path>) -> Result { + let file: ScenarioFile = + serde_json::from_str(text).map_err(|e| format!("not a scenario file: {e}"))?; + if file.format != SCENARIO_FORMAT { + return Err(format!( + "scenario format {} (this build reads {SCENARIO_FORMAT})", + file.format + )); + } + let calls = file + .commands + .into_iter() + .map(|(command, calls)| { + let calls = calls + .into_iter() + .map(|mut call| { + let args = call.remove("args").unwrap_or_else(|| json!({})); + let outcome = match call.remove("error") { + Some(error) => Err(match error { + Value::String(s) => s, + other => other.to_string(), + }), + None => Ok(resolve_avatars( + call.remove("result").unwrap_or(Value::Null), + repo_root, + )), + }; + RecordedCall { args, outcome } + }) + .collect(); + (command, calls) + }) + .collect(); + let mut backend = Self { + scenario: file.scenario, + now: file.now, + calls, + skipped: file.skipped, + repo_root: repo_root.map(Path::to_path_buf), + state: Mutex::new(MockState { + runtime_os: file.spec.runtime_os.clone(), + current: BTreeMap::from([ + ("steam".into(), file.spec.current_steam_account.clone()), + ("riot".into(), file.spec.current_riot_profile.clone()), + ("roblox".into(), file.spec.current_roblox_account.clone()), + ]), + pin_hash: String::new(), + pin_unlocked: true, + latency_ms: 0, + failures: BTreeMap::new(), + }), + spec: file.spec, + }; + // The session starts locked when the scenario's settings carry a PIN, + // as `createHandlers` starts it. + let settings = backend.scenario_settings(); + let hash = pin_hash_of(&settings); + let state = backend.state.get_mut().unwrap_or_else(|e| e.into_inner()); + state.pin_unlocked = hash.is_empty(); + state.pin_hash = hash; + Ok(backend) + } + + /// Scenario ids with a data file in `data_dir`, sorted. + pub fn scenarios(data_dir: &Path) -> Vec { + let mut ids: Vec = std::fs::read_dir(data_dir) + .into_iter() + .flatten() + .flatten() + .filter_map(|entry| { + let path = entry.path(); + (path.extension()? == "json") + .then(|| path.file_stem()?.to_str().map(str::to_string)) + .flatten() + }) + .collect(); + ids.sort(); + ids + } + + /// `/crates/accshift-egui/parity/data`. + pub fn data_dir(repo_root: &Path) -> PathBuf { + repo_root.join(DATA_DIR) + } + + pub fn scenario(&self) -> &str { + &self.scenario + } + + pub fn spec(&self) -> &MockSpec { + &self.spec + } + + /// The mock clock, in seconds. + pub fn now(&self) -> u64 { + self.now + } + + /// True for the `demo` scenario: overrides do nothing. + pub fn frozen(&self) -> bool { + self.scenario == FROZEN_SCENARIO + } + + /// The recorded calls, avatar paths resolved like served values. + pub fn recorded(&self) -> &BTreeMap> { + &self.calls + } + + /// Commands the dump modelled instead of recording, with the reason. + pub fn skipped(&self) -> &BTreeMap { + &self.skipped + } + + /// True when the TS mock has a handler for `command`. + pub fn is_mocked(&self, command: &str) -> bool { + self.calls.contains_key(command) || self.skipped.contains_key(command) + } + + /// The file behind an avatar path from this backend's data, repo-relative + /// or already resolved. `None` for anything else (a URL, an empty value). + pub fn avatar_path(&self, value: &str) -> Option { + if let Some(root) = &self.repo_root { + let resolved = PathBuf::from(value); + if resolved.starts_with(root.join(AVATAR_DIR)) { + return Some(resolved); + } + return value + .starts_with(AVATAR_DIR) + .then(|| join_relative(root, value)); + } + value.starts_with(AVATAR_DIR).then(|| PathBuf::from(value)) + } + + /// `__mock.latency(ms)`: delays every mocked command. Returns the latency + /// in force, unchanged on the frozen scenario. + pub fn latency(&self, ms: u64) -> u64 { + let mut state = self.lock(); + if !self.frozen() { + state.latency_ms = ms; + } + state.latency_ms + } + + /// `__mock.fail(cmd, msg)`: `Some(message)` makes `command` fail with it, + /// `None` clears that. Returns false on the frozen scenario, which keeps + /// no override. + pub fn fail(&self, command: &str, message: Option<&str>) -> bool { + if self.frozen() { + return false; + } + let mut state = self.lock(); + match message { + Some(message) => state.failures.insert(command.into(), message.into()), + None => state.failures.remove(command), + }; + true + } + + /// `__mock.os(name)`: the OS `get_runtime_os` and the boot payload report. + /// Returns false on the frozen scenario. + pub fn set_runtime_os(&self, os: &str) -> bool { + if self.frozen() { + return false; + } + self.lock().runtime_os = os.into(); + true + } + + /// Feeds the live `client.settings` store, which the TS mock reads for the + /// PIN (`peekSettings`): saves are no-ops, so the PIN set in the window is + /// only known to the window. Leaves the lock state alone. + pub fn set_pin_settings(&self, settings: &Value) { + self.lock().pin_hash = pin_hash_of(settings); + } + + fn lock(&self) -> MutexGuard<'_, MockState> { + self.state.lock().unwrap_or_else(|e| e.into_inner()) + } + + fn scenario_settings(&self) -> Value { + self.recorded_for("load_client_storage_snapshot", &json!({})) + .and_then(|outcome| outcome.ok()) + .and_then(|snapshot| snapshot.pointer("/stores/client.settings").cloned()) + .unwrap_or(Value::Null) + } + + /// The recorded outcome for these exact arguments, or the single + /// argument-less record of a command whose handler ignores its arguments. + fn recorded_for(&self, command: &str, args: &Value) -> Option> { + let calls = self.calls.get(command)?; + if let Some(call) = calls.iter().find(|call| &call.args == args) { + return Some(call.outcome.clone()); + } + match calls.as_slice() { + [only] if only.args.as_object().is_some_and(Map::is_empty) => { + Some(only.outcome.clone()) + } + _ => None, + } + } + + /// The recorded call whose `keys` argument is `id`. + fn recorded_by_id(&self, command: &str, keys: &[&str], id: &str) -> Option { + self.calls + .get(command)? + .iter() + .find(|call| arg_str(&call.args, keys).as_deref() == Some(id)) + .and_then(|call| call.outcome.clone().ok()) + } + + fn current(&self, platform_id: &str) -> String { + self.lock() + .current + .get(platform_id) + .cloned() + .unwrap_or_default() + } + + /// `mockInvoke`: override, latency, handler; else the guard. + fn call(&self, command: &str, args: Value) -> BackendResult { + let (failure, latency_ms) = { + let state = self.lock(); + (state.failures.get(command).cloned(), state.latency_ms) + }; + if let Some(message) = failure { + return Err(BackendError::new(message)); + } + let value = if self.is_mocked(command) { + if latency_ms > 0 { + std::thread::sleep(Duration::from_millis(latency_ms)); + } + self.serve(command, &args)? + } else if GUARDED_PREFIXES + .iter() + .any(|prefix| command.starts_with(prefix)) + { + return Err(BackendError::mock_refused(command)); + } else { + // The TS mock forwards these to the real backend (log_app_event, + // finish_boot, save_custom_theme, delete_custom_theme). Here they + // do nothing, so a mock session never writes to the real config. + Value::Null + }; + serde_json::from_value(value) + .map_err(|e| BackendError::new(format!("mock data for {command} does not decode: {e}"))) + } + + /// The handler of a mocked command. + fn serve(&self, command: &str, args: &Value) -> Result { + let platform = arg_str(args, &["platformId"]).unwrap_or_default(); + let value = match command { + "get_runtime_os" => Value::String(self.lock().runtime_os.clone()), + "get_boot_payload" => { + let mut payload = self.answer(command, args)?; + payload["runtimeOs"] = Value::String(self.lock().runtime_os.clone()); + payload + } + "platform_get_accounts" => self + .recorded_for(command, args) + .transpose()? + .unwrap_or_else(|| json!([])), + "platform_get_current_account" => Value::String(self.current(&platform)), + "platform_get_startup_snapshot" => { + let mut snapshot = self + .recorded_for(command, args) + .transpose()? + .unwrap_or_else(|| json!({ "accounts": [], "currentAccount": "" })); + let key = match platform.as_str() { + "riot" => Some("currentProfile"), + "steam" | "roblox" => Some("currentAccount"), + _ => None, + }; + if let (Some(key), Some(object)) = (key, snapshot.as_object_mut()) { + object.insert(key.into(), Value::String(self.current(&platform))); + } + snapshot + } + "platform_switch_account" => { + self.switch(&platform, arg_str(args, &["accountId"]))?; + Value::Null + } + "pin_unlock" => self.pin_unlock_value(&arg_str(args, &["code"]).unwrap_or_default()), + "pin_lock" => { + self.lock().pin_unlocked = false; + Value::Null + } + "platform_dry_run" => json!({ + "platformId": platform, + "operation": "switch", + "accountId": arg_str(args, &["accountId"]).unwrap_or_default(), + "applied": false, + "roots": [], + "steps": [], + "warnings": [], + }), + "steam_get_profile_info" => { + let id = arg_str(args, &["steamId", "accountId"]).unwrap_or_default(); + self.steam_profile(&id).unwrap_or(Value::Null) + } + "steam_get_profile_infos" => { + let mut infos = Map::new(); + for id in arg_strings(args, &["steamIds", "accountIds"]) { + if let Some(info) = self.steam_profile(&id).filter(|v| !v.is_null()) { + infos.insert(id, info); + } + } + Value::Object(infos) + } + "steam_get_player_bans" => { + let known = self.all_bans(); + let bans = arg_strings(args, &["steamIds"]) + .iter() + .filter_map(|id| { + known + .iter() + .find(|ban| ban["steam_id"].as_str() == Some(id.as_str())) + .cloned() + }) + .collect(); + Value::Array(bans) + } + "roblox_get_profile_info" => { + let id = arg_str(args, &["userId", "accountId"]).unwrap_or_default(); + self.recorded_by_id(command, &["userId", "accountId"], &id) + .unwrap_or_else(|| json!({ "avatarUrl": null })) + } + "cs2_bridge_get_settings" => cs2_settings_from_fixture(self.answer(command, args)?), + _ => self.answer(command, args)?, + }; + Ok(value) + } + + fn answer(&self, command: &str, args: &Value) -> Result { + match self.recorded_for(command, args) { + Some(outcome) => outcome.map_err(BackendError::new), + None => Err(BackendError::new(format!( + "mock data has no answer for {command} with {args}" + ))), + } + } + + fn steam_profile(&self, id: &str) -> Option { + self.recorded_by_id("steam_get_profile_info", &["steamId", "accountId"], id) + } + + /// Every ban record of the scenario: the batch call covers every account. + fn all_bans(&self) -> Vec { + self.calls + .get("steam_get_player_bans") + .into_iter() + .flatten() + .filter_map(|call| call.outcome.as_ref().ok()?.as_array().cloned()) + .flatten() + .collect() + } + + /// `platform_switch_account` in `createHandlers`: PIN gate, the + /// scenario's delay, then the new current account. + fn switch(&self, platform: &str, account_id: Option) -> Result<(), BackendError> { + { + let state = self.lock(); + if !state.pin_unlocked && !state.pin_hash.is_empty() { + return Err(BackendError::new(PIN_LOCKED_MESSAGE)); + } + } + std::thread::sleep(Duration::from_millis(self.spec.switch_delay_ms)); + if let Some(account_id) = account_id { + if matches!(platform, "steam" | "riot" | "roblox") { + self.lock().current.insert(platform.into(), account_id); + } + } + Ok(()) + } + + fn pin_unlock_value(&self, code: &str) -> Value { + let hash = self.lock().pin_hash.clone(); + let (status, legacy, retry_after_ms) = if hash.is_empty() { + ("not_configured", false, 0) + } else if verify_pin_code(code, &hash) == PinVerdict::Rejected { + ("invalid", false, 1000) + } else { + self.lock().pin_unlocked = true; + ("unlocked", !hash.contains(':'), 0) + }; + json!({ "status": status, "legacy": legacy, "retryAfterMs": retry_after_ms }) + } +} + +/// `pinHashOf` in `fixtures.ts`: the hash when the PIN is on and well formed. +fn pin_hash_of(settings: &Value) -> String { + match PinSettings::from_value(settings).lock() { + PinLock::On(hash) => hash, + _ => String::new(), + } +} + +/// The TS fixture answers `cs2_bridge_get_settings` with +/// `{ enabled, url, apiKey }`, a shape the real command stopped sending: it +/// sends `{ enabled, url, tokenConfigured }`. The typed answer is the real +/// shape, with `tokenConfigured` true when the fixture's key is non-empty. +pub fn cs2_settings_from_fixture(mut value: Value) -> Value { + if let Some(object) = value.as_object_mut() { + if let Some(key) = object.remove("apiKey") { + let configured = key.as_str().is_some_and(|k| !k.is_empty()); + object + .entry("tokenConfigured") + .or_insert(Value::Bool(configured)); + } + } + value +} + +/// Rewrites every repo-relative avatar path in `value` to the file under +/// `repo_root`. Unchanged without a root. +pub fn resolve_avatars(value: Value, repo_root: Option<&Path>) -> Value { + let Some(root) = repo_root else { + return value; + }; + match value { + Value::String(s) if s.starts_with(AVATAR_DIR) => { + Value::String(join_relative(root, &s).to_string_lossy().into_owned()) + } + Value::Array(items) => Value::Array( + items + .into_iter() + .map(|v| resolve_avatars(v, Some(root))) + .collect(), + ), + Value::Object(map) => Value::Object( + map.into_iter() + .map(|(k, v)| (k, resolve_avatars(v, Some(root)))) + .collect(), + ), + other => other, + } +} + +/// `root` joined with a `/`-separated relative path, one component at a time +/// so the separators are the platform's. +fn join_relative(root: &Path, relative: &str) -> PathBuf { + relative + .split('/') + .filter(|part| !part.is_empty()) + .fold(root.to_path_buf(), |path, part| path.join(part)) +} + +fn arg_str(args: &Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| args.get(key)?.as_str().map(str::to_string)) +} + +fn arg_strings(args: &Value, keys: &[&str]) -> Vec { + keys.iter() + .find_map(|key| args.get(key)?.as_array()) + .map(|items| { + items + .iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +impl Backend for MockBackend { + fn get_boot_payload(&self) -> BackendResult { + self.call("get_boot_payload", json!({})) + } + fn get_runtime_os(&self) -> BackendResult { + self.call("get_runtime_os", json!({})) + } + fn detect_streaming_software(&self) -> BackendResult { + self.call("detect_streaming_software", json!({})) + } + fn migrate_legacy_config(&self) -> BackendResult { + self.call("migrate_legacy_config", json!({})) + } + fn load_client_storage_snapshot(&self) -> BackendResult { + self.call("load_client_storage_snapshot", json!({})) + } + fn save_client_storage_store( + &self, + store_id: &str, + value: &Value, + ) -> BackendResult> { + self.call( + "save_client_storage_store", + json!({ "storeId": store_id, "value": value }), + ) + } + fn get_storage_manifest(&self) -> BackendResult { + self.call("get_storage_manifest", json!({})) + } + fn log_app_event( + &self, + level: &str, + source: &str, + message: &str, + details: Option<&str>, + ) -> BackendResult<()> { + self.call( + "log_app_event", + json!({ "level": level, "source": source, "message": message, "details": details }), + ) + } + fn finish_boot(&self, source: &str, marks: Option<&Value>) -> BackendResult<()> { + self.call("finish_boot", json!({ "source": source, "marks": marks })) + } + fn pin_unlock(&self, code: &str) -> BackendResult { + self.call("pin_unlock", json!({ "code": code })) + } + fn pin_lock(&self) -> BackendResult<()> { + self.call("pin_lock", json!({})) + } + fn platform_get_accounts(&self, platform_id: &str) -> BackendResult { + self.call( + "platform_get_accounts", + json!({ "platformId": platform_id }), + ) + } + fn platform_get_startup_snapshot(&self, platform_id: &str) -> BackendResult { + self.call( + "platform_get_startup_snapshot", + json!({ "platformId": platform_id }), + ) + } + fn platform_get_current_account(&self, platform_id: &str) -> BackendResult { + self.call( + "platform_get_current_account", + json!({ "platformId": platform_id }), + ) + } + fn platform_switch_account( + &self, + platform_id: &str, + account_id: &str, + params: &Value, + ) -> BackendResult<()> { + self.call( + "platform_switch_account", + json!({ "platformId": platform_id, "accountId": account_id, "params": params }), + ) + } + fn platform_forget_account(&self, platform_id: &str, account_id: &str) -> BackendResult<()> { + self.call( + "platform_forget_account", + json!({ "platformId": platform_id, "accountId": account_id }), + ) + } + fn platform_begin_setup( + &self, + platform_id: &str, + params: &Value, + ) -> BackendResult { + self.call( + "platform_begin_setup", + json!({ "platformId": platform_id, "params": params }), + ) + } + fn platform_get_setup_status( + &self, + platform_id: &str, + setup_id: &str, + ) -> BackendResult { + self.call( + "platform_get_setup_status", + json!({ "platformId": platform_id, "setupId": setup_id }), + ) + } + fn platform_cancel_setup(&self, platform_id: &str, setup_id: &str) -> BackendResult<()> { + self.call( + "platform_cancel_setup", + json!({ "platformId": platform_id, "setupId": setup_id }), + ) + } + fn platform_get_path(&self, platform_id: &str) -> BackendResult { + self.call("platform_get_path", json!({ "platformId": platform_id })) + } + fn platform_set_path(&self, platform_id: &str, path: &str) -> BackendResult<()> { + self.call( + "platform_set_path", + json!({ "platformId": platform_id, "path": path }), + ) + } + fn platform_select_path(&self, platform_id: &str) -> BackendResult { + self.call("platform_select_path", json!({ "platformId": platform_id })) + } + fn platform_detect_installed(&self) -> BackendResult> { + self.call("platform_detect_installed", json!({})) + } + fn platform_set_account_label( + &self, + platform_id: &str, + account_id: &str, + label: &str, + ) -> BackendResult<()> { + self.call( + "platform_set_account_label", + json!({ "platformId": platform_id, "accountId": account_id, "label": label }), + ) + } + fn platform_dry_run(&self, platform_id: &str, account_id: &str) -> BackendResult { + self.call( + "platform_dry_run", + json!({ "platformId": platform_id, "accountId": account_id }), + ) + } + fn reload_user_platforms(&self) -> BackendResult { + self.call("reload_user_platforms", json!({})) + } + fn descriptor_select_file(&self) -> BackendResult { + self.call("descriptor_select_file", json!({})) + } + fn descriptor_preview_file(&self, path: &str) -> BackendResult { + self.call("descriptor_preview_file", json!({ "path": path })) + } + fn descriptor_install_file(&self, path: &str) -> BackendResult { + self.call("descriptor_install_file", json!({ "path": path })) + } + fn descriptor_remove(&self, platform_id: &str) -> BackendResult { + self.call("descriptor_remove", json!({ "platformId": platform_id })) + } + fn open_descriptors_folder(&self) -> BackendResult<()> { + self.call("open_descriptors_folder", json!({})) + } + fn open_url(&self, url: &str) -> BackendResult<()> { + self.call("open_url", json!({ "url": url })) + } + fn open_logs_folder(&self) -> BackendResult<()> { + self.call("open_logs_folder", json!({})) + } + fn get_desktop_wallpaper(&self) -> BackendResult> { + self.call("get_desktop_wallpaper", json!({})) + } + fn steam_set_api_key(&self, key: &str) -> BackendResult<()> { + self.call("steam_set_api_key", json!({ "key": key })) + } + fn steam_has_api_key(&self) -> BackendResult { + self.call("steam_has_api_key", json!({})) + } + fn steam_open_api_key_page(&self) -> BackendResult<()> { + self.call("steam_open_api_key_page", json!({})) + } + fn steam_switch_account_and_launch_game( + &self, + username: &str, + app_id: &str, + run_as_admin: bool, + launch_options: &str, + shutdown_mode: &str, + ) -> BackendResult<()> { + self.call( + "steam_switch_account_and_launch_game", + json!({ + "username": username, + "appId": app_id, + "runAsAdmin": run_as_admin, + "launchOptions": launch_options, + "shutdownMode": shutdown_mode, + }), + ) + } + fn steam_get_profile_info(&self, steam_id: &str) -> BackendResult> { + self.call("steam_get_profile_info", json!({ "steamId": steam_id })) + } + fn steam_get_profile_infos( + &self, + steam_ids: &[String], + ) -> BackendResult> { + self.call("steam_get_profile_infos", json!({ "steamIds": steam_ids })) + } + fn steam_get_player_bans(&self, steam_ids: &[String]) -> BackendResult> { + self.call("steam_get_player_bans", json!({ "steamIds": steam_ids })) + } + fn steam_copy_game_settings( + &self, + from_steam_id: &str, + to_steam_id: &str, + app_id: &str, + ) -> BackendResult<()> { + self.call( + "steam_copy_game_settings", + json!({ "fromSteamId": from_steam_id, "toSteamId": to_steam_id, "appId": app_id }), + ) + } + fn steam_get_copyable_games( + &self, + from_steam_id: &str, + to_steam_id: &str, + ) -> BackendResult> { + self.call( + "steam_get_copyable_games", + json!({ "fromSteamId": from_steam_id, "toSteamId": to_steam_id }), + ) + } + fn steam_open_userdata(&self, steam_id: &str) -> BackendResult<()> { + self.call("steam_open_userdata", json!({ "steamId": steam_id })) + } + fn steam_clear_browser_cache(&self) -> BackendResult<()> { + self.call("steam_clear_browser_cache", json!({})) + } + fn steam_bulk_edit(&self, request: BulkEditRequest) -> BackendResult { + let request = json!({ + "steamIds": request.steam_ids, + "newsPopup": request.news_popup, + "doNotDisturb": request.do_not_disturb, + "launchOptions": request + .launch_options + .iter() + .map(|edit| json!({ "appId": edit.app_id, "value": edit.value })) + .collect::>(), + }); + self.call("steam_bulk_edit", json!({ "request": request })) + } + fn steam_get_account_games(&self, steam_id: &str) -> BackendResult> { + self.call("steam_get_account_games", json!({ "steamId": steam_id })) + } + fn cs2_bridge_get_settings(&self) -> BackendResult { + self.call("cs2_bridge_get_settings", json!({})) + } + fn cs2_bridge_set_settings( + &self, + enabled: bool, + url: &str, + token: Option<&str>, + ) -> BackendResult<()> { + self.call( + "cs2_bridge_set_settings", + json!({ "enabled": enabled, "url": url, "token": token }), + ) + } + fn cs2_bridge_fetch(&self) -> BackendResult> { + self.call("cs2_bridge_fetch", json!({})) + } + fn cs2_bridge_check(&self, steam_id: &str) -> BackendResult> { + self.call("cs2_bridge_check", json!({ "steamId": steam_id })) + } + fn cs2_bridge_test(&self) -> BackendResult { + self.call("cs2_bridge_test", json!({})) + } + fn riot_capture_profile(&self, profile_id: &str) -> BackendResult<()> { + self.call("riot_capture_profile", json!({ "profileId": profile_id })) + } + fn roblox_add_account_by_cookie(&self, cookie: &str) -> BackendResult { + self.call("roblox_add_account_by_cookie", json!({ "cookie": cookie })) + } + fn roblox_get_profile_info(&self, user_id: &str) -> BackendResult { + self.call("roblox_get_profile_info", json!({ "userId": user_id })) + } + fn roblox_check_sessions(&self) -> BackendResult> { + self.call("roblox_check_sessions", json!({})) + } + fn list_custom_themes(&self) -> BackendResult> { + self.call("list_custom_themes", json!({})) + } + fn save_custom_theme(&self, theme: &CustomTheme) -> BackendResult<()> { + self.call("save_custom_theme", json!({ "theme": theme })) + } + fn delete_custom_theme(&self, theme_id: &str) -> BackendResult<()> { + self.call("delete_custom_theme", json!({ "themeId": theme_id })) + } + fn telemetry_get_state(&self) -> BackendResult { + self.call("telemetry_get_state", json!({})) + } + fn telemetry_set_mode_a(&self, enabled: bool) -> BackendResult<()> { + self.call("telemetry_set_mode_a", json!({ "enabled": enabled })) + } + fn telemetry_set_mode_b(&self, enabled: bool) -> BackendResult<()> { + self.call("telemetry_set_mode_b", json!({ "enabled": enabled })) + } + fn telemetry_retry_forget(&self) -> BackendResult<()> { + self.call("telemetry_retry_forget", json!({})) + } + fn telemetry_complete_onboarding( + &self, + mode_a_enabled: bool, + mode_b_enabled: bool, + ) -> BackendResult<()> { + self.call( + "telemetry_complete_onboarding", + json!({ "modeAEnabled": mode_a_enabled, "modeBEnabled": mode_b_enabled }), + ) + } + fn telemetry_track_persona_switch(&self, platforms: u64, succeeded: u64) -> BackendResult<()> { + self.call( + "telemetry_track_persona_switch", + json!({ "platforms": platforms, "succeeded": succeeded }), + ) + } + fn telemetry_track_account_added(&self, platform_id: &str) -> BackendResult<()> { + self.call( + "telemetry_track_account_added", + json!({ "platformId": platform_id }), + ) + } + fn telemetry_track_account_add_started(&self, platform_id: &str) -> BackendResult<()> { + self.call( + "telemetry_track_account_add_started", + json!({ "platformId": platform_id }), + ) + } + fn telemetry_track_account_add_cancelled(&self, platform_id: &str) -> BackendResult<()> { + self.call( + "telemetry_track_account_add_cancelled", + json!({ "platformId": platform_id }), + ) + } + fn telemetry_track_operation_failed( + &self, + operation: &str, + error_code: &str, + platform_id: Option<&str>, + ) -> BackendResult<()> { + self.call( + "telemetry_track_operation_failed", + json!({ "operation": operation, "errorCode": error_code, "platformId": platform_id }), + ) + } + fn telemetry_track_update( + &self, + stage: &str, + target_version: Option<&str>, + error_code: Option<&str>, + ) -> BackendResult<()> { + self.call( + "telemetry_track_update", + json!({ "stage": stage, "targetVersion": target_version, "errorCode": error_code }), + ) + } + fn telemetry_track_settings_snapshot(&self, snapshot: &SettingsSnapshot) -> BackendResult<()> { + self.call( + "telemetry_track_settings_snapshot", + serde_json::to_value(snapshot).unwrap_or(Value::Null), + ) + } + fn telemetry_track_streamer_mode(&self) -> BackendResult<()> { + self.call("telemetry_track_streamer_mode", json!({})) + } + fn telemetry_export(&self) -> BackendResult { + self.call("telemetry_export", json!({})) + } +} diff --git a/crates/accshift-app/src/real.rs b/crates/accshift-app/src/real.rs new file mode 100644 index 0000000..0c2123b --- /dev/null +++ b/crates/accshift-app/src/real.rs @@ -0,0 +1,379 @@ +//! The real backend, a skeleton for now. +//! +//! [`RealBackend`] will run every command on `accshift-core` the way +//! `src-tauri/src/commands` does. Until a later job ports each body, every +//! method answers a "not ported" error, so nothing here can touch Steam, the +//! keyring or the registry yet. + +use crate::backend::Backend; +use crate::error::{BackendError, BackendResult}; +use crate::types::*; +use accshift_core::AppContext; +use directories::BaseDirs; +use serde_json::Value; +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::Arc; + +/// Must match `identifier` in `src-tauri/tauri.conf.json`, like the CLI's. +const BUNDLE_IDENTIFIER: &str = "com.accshift.desktop"; + +/// The app directories, laid out like the Tauri GUI's `AppHandle::path()` +/// (`/`) so config, state and cache stay +/// shared with the Tauri app and the CLI. Same as +/// `crates/accshift-cli/src/context.rs`. +pub struct EguiAppContext { + base: BaseDirs, + identifier: String, +} + +impl EguiAppContext { + pub fn new() -> Result { + let base = BaseDirs::new().ok_or_else(|| { + "Could not resolve base directories (no HOME or %APPDATA%?)".to_string() + })?; + Ok(Self { + base, + identifier: BUNDLE_IDENTIFIER.to_string(), + }) + } +} + +impl AppContext for EguiAppContext { + fn app_config_dir(&self) -> Result { + Ok(self.base.config_dir().join(&self.identifier)) + } + + fn app_data_dir(&self) -> Result { + Ok(self.base.data_dir().join(&self.identifier)) + } + + fn app_local_data_dir(&self) -> Result { + Ok(self.base.data_local_dir().join(&self.identifier)) + } + + fn app_cache_dir(&self) -> Result { + Ok(self.base.cache_dir().join(&self.identifier)) + } +} + +/// See the module documentation. +pub struct RealBackend { + ctx: accshift_core::AppCtx, +} + +impl RealBackend { + /// On the user's real app directories. + pub fn new() -> Result { + Ok(Self::with_context(Arc::new(EguiAppContext::new()?))) + } + + /// On any directory layout (tests, a parity scratch folder). + pub fn with_context(ctx: accshift_core::AppCtx) -> Self { + Self { ctx } + } + + pub fn context(&self) -> &accshift_core::AppCtx { + &self.ctx + } +} + +impl Backend for RealBackend { + fn get_boot_payload(&self) -> BackendResult { + Err(BackendError::not_ported("get_boot_payload")) + } + fn get_runtime_os(&self) -> BackendResult { + Err(BackendError::not_ported("get_runtime_os")) + } + fn detect_streaming_software(&self) -> BackendResult { + Err(BackendError::not_ported("detect_streaming_software")) + } + fn migrate_legacy_config(&self) -> BackendResult { + Err(BackendError::not_ported("migrate_legacy_config")) + } + fn load_client_storage_snapshot(&self) -> BackendResult { + Err(BackendError::not_ported("load_client_storage_snapshot")) + } + fn save_client_storage_store( + &self, + _store_id: &str, + _value: &Value, + ) -> BackendResult> { + Err(BackendError::not_ported("save_client_storage_store")) + } + fn get_storage_manifest(&self) -> BackendResult { + Err(BackendError::not_ported("get_storage_manifest")) + } + fn log_app_event( + &self, + _level: &str, + _source: &str, + _message: &str, + _details: Option<&str>, + ) -> BackendResult<()> { + Err(BackendError::not_ported("log_app_event")) + } + fn finish_boot(&self, _source: &str, _marks: Option<&Value>) -> BackendResult<()> { + Err(BackendError::not_ported("finish_boot")) + } + fn pin_unlock(&self, _code: &str) -> BackendResult { + Err(BackendError::not_ported("pin_unlock")) + } + fn pin_lock(&self) -> BackendResult<()> { + Err(BackendError::not_ported("pin_lock")) + } + fn platform_get_accounts(&self, _platform_id: &str) -> BackendResult { + Err(BackendError::not_ported("platform_get_accounts")) + } + fn platform_get_startup_snapshot(&self, _platform_id: &str) -> BackendResult { + Err(BackendError::not_ported("platform_get_startup_snapshot")) + } + fn platform_get_current_account(&self, _platform_id: &str) -> BackendResult { + Err(BackendError::not_ported("platform_get_current_account")) + } + fn platform_switch_account( + &self, + _platform_id: &str, + _account_id: &str, + _params: &Value, + ) -> BackendResult<()> { + Err(BackendError::not_ported("platform_switch_account")) + } + fn platform_forget_account(&self, _platform_id: &str, _account_id: &str) -> BackendResult<()> { + Err(BackendError::not_ported("platform_forget_account")) + } + fn platform_begin_setup( + &self, + _platform_id: &str, + _params: &Value, + ) -> BackendResult { + Err(BackendError::not_ported("platform_begin_setup")) + } + fn platform_get_setup_status( + &self, + _platform_id: &str, + _setup_id: &str, + ) -> BackendResult { + Err(BackendError::not_ported("platform_get_setup_status")) + } + fn platform_cancel_setup(&self, _platform_id: &str, _setup_id: &str) -> BackendResult<()> { + Err(BackendError::not_ported("platform_cancel_setup")) + } + fn platform_get_path(&self, _platform_id: &str) -> BackendResult { + Err(BackendError::not_ported("platform_get_path")) + } + fn platform_set_path(&self, _platform_id: &str, _path: &str) -> BackendResult<()> { + Err(BackendError::not_ported("platform_set_path")) + } + fn platform_select_path(&self, _platform_id: &str) -> BackendResult { + Err(BackendError::not_ported("platform_select_path")) + } + fn platform_detect_installed(&self) -> BackendResult> { + Err(BackendError::not_ported("platform_detect_installed")) + } + fn platform_set_account_label( + &self, + _platform_id: &str, + _account_id: &str, + _label: &str, + ) -> BackendResult<()> { + Err(BackendError::not_ported("platform_set_account_label")) + } + fn platform_dry_run(&self, _platform_id: &str, _account_id: &str) -> BackendResult { + Err(BackendError::not_ported("platform_dry_run")) + } + fn reload_user_platforms(&self) -> BackendResult { + Err(BackendError::not_ported("reload_user_platforms")) + } + fn descriptor_select_file(&self) -> BackendResult { + Err(BackendError::not_ported("descriptor_select_file")) + } + fn descriptor_preview_file(&self, _path: &str) -> BackendResult { + Err(BackendError::not_ported("descriptor_preview_file")) + } + fn descriptor_install_file(&self, _path: &str) -> BackendResult { + Err(BackendError::not_ported("descriptor_install_file")) + } + fn descriptor_remove(&self, _platform_id: &str) -> BackendResult { + Err(BackendError::not_ported("descriptor_remove")) + } + fn open_descriptors_folder(&self) -> BackendResult<()> { + Err(BackendError::not_ported("open_descriptors_folder")) + } + fn open_url(&self, _url: &str) -> BackendResult<()> { + Err(BackendError::not_ported("open_url")) + } + fn open_logs_folder(&self) -> BackendResult<()> { + Err(BackendError::not_ported("open_logs_folder")) + } + fn get_desktop_wallpaper(&self) -> BackendResult> { + Err(BackendError::not_ported("get_desktop_wallpaper")) + } + fn steam_set_api_key(&self, _key: &str) -> BackendResult<()> { + Err(BackendError::not_ported("steam_set_api_key")) + } + fn steam_has_api_key(&self) -> BackendResult { + Err(BackendError::not_ported("steam_has_api_key")) + } + fn steam_open_api_key_page(&self) -> BackendResult<()> { + Err(BackendError::not_ported("steam_open_api_key_page")) + } + fn steam_switch_account_and_launch_game( + &self, + _username: &str, + _app_id: &str, + _run_as_admin: bool, + _launch_options: &str, + _shutdown_mode: &str, + ) -> BackendResult<()> { + Err(BackendError::not_ported( + "steam_switch_account_and_launch_game", + )) + } + fn steam_get_profile_info(&self, _steam_id: &str) -> BackendResult> { + Err(BackendError::not_ported("steam_get_profile_info")) + } + fn steam_get_profile_infos( + &self, + _steam_ids: &[String], + ) -> BackendResult> { + Err(BackendError::not_ported("steam_get_profile_infos")) + } + fn steam_get_player_bans(&self, _steam_ids: &[String]) -> BackendResult> { + Err(BackendError::not_ported("steam_get_player_bans")) + } + fn steam_copy_game_settings( + &self, + _from_steam_id: &str, + _to_steam_id: &str, + _app_id: &str, + ) -> BackendResult<()> { + Err(BackendError::not_ported("steam_copy_game_settings")) + } + fn steam_get_copyable_games( + &self, + _from_steam_id: &str, + _to_steam_id: &str, + ) -> BackendResult> { + Err(BackendError::not_ported("steam_get_copyable_games")) + } + fn steam_open_userdata(&self, _steam_id: &str) -> BackendResult<()> { + Err(BackendError::not_ported("steam_open_userdata")) + } + fn steam_clear_browser_cache(&self) -> BackendResult<()> { + Err(BackendError::not_ported("steam_clear_browser_cache")) + } + fn steam_bulk_edit(&self, _request: BulkEditRequest) -> BackendResult { + Err(BackendError::not_ported("steam_bulk_edit")) + } + fn steam_get_account_games(&self, _steam_id: &str) -> BackendResult> { + Err(BackendError::not_ported("steam_get_account_games")) + } + fn cs2_bridge_get_settings(&self) -> BackendResult { + Err(BackendError::not_ported("cs2_bridge_get_settings")) + } + fn cs2_bridge_set_settings( + &self, + _enabled: bool, + _url: &str, + _token: Option<&str>, + ) -> BackendResult<()> { + Err(BackendError::not_ported("cs2_bridge_set_settings")) + } + fn cs2_bridge_fetch(&self) -> BackendResult> { + Err(BackendError::not_ported("cs2_bridge_fetch")) + } + fn cs2_bridge_check(&self, _steam_id: &str) -> BackendResult> { + Err(BackendError::not_ported("cs2_bridge_check")) + } + fn cs2_bridge_test(&self) -> BackendResult { + Err(BackendError::not_ported("cs2_bridge_test")) + } + fn riot_capture_profile(&self, _profile_id: &str) -> BackendResult<()> { + Err(BackendError::not_ported("riot_capture_profile")) + } + fn roblox_add_account_by_cookie(&self, _cookie: &str) -> BackendResult { + Err(BackendError::not_ported("roblox_add_account_by_cookie")) + } + fn roblox_get_profile_info(&self, _user_id: &str) -> BackendResult { + Err(BackendError::not_ported("roblox_get_profile_info")) + } + fn roblox_check_sessions(&self) -> BackendResult> { + Err(BackendError::not_ported("roblox_check_sessions")) + } + fn list_custom_themes(&self) -> BackendResult> { + Err(BackendError::not_ported("list_custom_themes")) + } + fn save_custom_theme(&self, _theme: &CustomTheme) -> BackendResult<()> { + Err(BackendError::not_ported("save_custom_theme")) + } + fn delete_custom_theme(&self, _theme_id: &str) -> BackendResult<()> { + Err(BackendError::not_ported("delete_custom_theme")) + } + fn telemetry_get_state(&self) -> BackendResult { + Err(BackendError::not_ported("telemetry_get_state")) + } + fn telemetry_set_mode_a(&self, _enabled: bool) -> BackendResult<()> { + Err(BackendError::not_ported("telemetry_set_mode_a")) + } + fn telemetry_set_mode_b(&self, _enabled: bool) -> BackendResult<()> { + Err(BackendError::not_ported("telemetry_set_mode_b")) + } + fn telemetry_retry_forget(&self) -> BackendResult<()> { + Err(BackendError::not_ported("telemetry_retry_forget")) + } + fn telemetry_complete_onboarding( + &self, + _mode_a_enabled: bool, + _mode_b_enabled: bool, + ) -> BackendResult<()> { + Err(BackendError::not_ported("telemetry_complete_onboarding")) + } + fn telemetry_track_persona_switch( + &self, + _platforms: u64, + _succeeded: u64, + ) -> BackendResult<()> { + Err(BackendError::not_ported("telemetry_track_persona_switch")) + } + fn telemetry_track_account_added(&self, _platform_id: &str) -> BackendResult<()> { + Err(BackendError::not_ported("telemetry_track_account_added")) + } + fn telemetry_track_account_add_started(&self, _platform_id: &str) -> BackendResult<()> { + Err(BackendError::not_ported( + "telemetry_track_account_add_started", + )) + } + fn telemetry_track_account_add_cancelled(&self, _platform_id: &str) -> BackendResult<()> { + Err(BackendError::not_ported( + "telemetry_track_account_add_cancelled", + )) + } + fn telemetry_track_operation_failed( + &self, + _operation: &str, + _error_code: &str, + _platform_id: Option<&str>, + ) -> BackendResult<()> { + Err(BackendError::not_ported("telemetry_track_operation_failed")) + } + fn telemetry_track_update( + &self, + _stage: &str, + _target_version: Option<&str>, + _error_code: Option<&str>, + ) -> BackendResult<()> { + Err(BackendError::not_ported("telemetry_track_update")) + } + fn telemetry_track_settings_snapshot(&self, _snapshot: &SettingsSnapshot) -> BackendResult<()> { + Err(BackendError::not_ported( + "telemetry_track_settings_snapshot", + )) + } + fn telemetry_track_streamer_mode(&self) -> BackendResult<()> { + Err(BackendError::not_ported("telemetry_track_streamer_mode")) + } + fn telemetry_export(&self) -> BackendResult { + Err(BackendError::not_ported("telemetry_export")) + } +} diff --git a/crates/accshift-app/src/types.rs b/crates/accshift-app/src/types.rs new file mode 100644 index 0000000..7a3749a --- /dev/null +++ b/crates/accshift-app/src/types.rs @@ -0,0 +1,109 @@ +//! Payloads the backend answers with that `accshift-core` does not define, +//! because they are built in `src-tauri`. Each one serializes to the JSON the +//! webview receives from the matching command today. +//! +//! Payloads `accshift-core` already defines are re-exported here so callers +//! have one place to import from. + +use serde::{Deserialize, Serialize}; + +pub use accshift_core::platforms::descriptor::library::DescriptorPreview; +pub use accshift_core::platforms::descriptor::plan::DryRunPlan; +pub use accshift_core::platforms::steam::accounts::{CopyableGame, SteamAccount}; +pub use accshift_core::platforms::steam::bans::BanInfo; +pub use accshift_core::platforms::steam::bulk_edit::{ + BulkEditFailure, BulkEditRequest, BulkEditResult, LaunchOptionEdit, +}; +pub use accshift_core::platforms::steam::cs2_bridge::{ + Cs2BridgeAccount, Cs2BridgeSettings, Cs2BridgeTestResult, +}; +pub use accshift_core::platforms::steam::profile::ProfileInfo; +pub use accshift_core::platforms::{ + RejectedDescriptor, SetupStatus, SkippedPlatform, UserPlatformReport, +}; +pub use accshift_core::storage::{ClientStorageSnapshot, StorageManifest}; +pub use accshift_core::themes::CustomTheme; + +/// `get_boot_payload`: everything the UI needs before the window shows. +/// Mirrors `BootPayload` in `src-tauri/src/commands/app.rs`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BootPayload { + /// `migrated`, `none`, `error:` (and `skipped` from the mock). + pub migration: String, + pub runtime_os: String, + pub storage_snapshot: ClientStorageSnapshot, + pub custom_themes: Vec, + pub user_platforms: UserPlatformReport, +} + +/// `get_desktop_wallpaper`: the desktop picture behind the window, for the +/// glass themes. Mirrors `WallpaperSnapshot` in +/// `src-tauri/src/commands/backdrop.rs`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WallpaperSnapshot { + pub data_url: String, + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, +} + +/// `pin_unlock`. Mirrors `PinUnlockResult` in `src-tauri/src/commands/pin.rs`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PinUnlockResult { + /// `unlocked`, `invalid`, `retry_later` or `not_configured`. + pub status: String, + /// The stored hash is the legacy unsalted form: the caller rewrites it. + pub legacy: bool, + /// For `invalid` and `retry_later`: how long the next attempt is refused. + pub retry_after_ms: u64, +} + +/// `telemetry_get_state`. Mirrors `TelemetryUiState` in +/// `src-tauri/src/commands/telemetry.rs`, snake_case on the wire. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TelemetryUiState { + pub mode_a_enabled: bool, + pub mode_b_enabled: bool, + pub install_id_set: bool, + pub forget_pending: bool, + pub onboarding_completed: bool, +} + +/// Arguments of `telemetry_track_settings_snapshot`, the `SettingsSnapshot` +/// of `src/lib/app/telemetryClient.ts`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SettingsSnapshot { + pub ui_language: String, + pub enabled_platforms: Vec, + pub personas_enabled: bool, + pub pin_enabled: bool, + pub cli_enabled: bool, + pub deep_links_enabled: bool, + pub streamer_mode: String, + pub animations: String, +} + +/// A Roblox account as `platform_get_accounts` and +/// `roblox_add_account_by_cookie` send it. Mirrors `RobloxAccount` in +/// `accshift_core::platforms::roblox`, which only exists on Windows. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RobloxAccount { + pub user_id: String, + pub username: String, + pub display_name: String, + pub last_login_at: Option, +} + +/// `roblox_get_profile_info`. Mirrors `RobloxProfileInfo` in +/// `accshift_core::platforms::roblox` (Windows only there). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RobloxProfileInfo { + pub avatar_url: Option, +} diff --git a/crates/accshift-app/tests/mock_parity.rs b/crates/accshift-app/tests/mock_parity.rs new file mode 100644 index 0000000..67dd123 --- /dev/null +++ b/crates/accshift-app/tests/mock_parity.rs @@ -0,0 +1,390 @@ +//! The mock backend against the dumped TS mock data. + +use accshift_app::dispatch::dispatch; +use accshift_app::jobs::{Jobs, Poll}; +use accshift_app::mock::{cs2_settings_from_fixture, MockBackend, AVATAR_DIR, NOW}; +use accshift_app::real::RealBackend; +use accshift_app::{Backend, BackendError, COMMANDS}; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +const SCENARIOS: [&str; 5] = ["demo", "dev", "empty", "huge", "slow"]; + +fn repo_root() -> PathBuf { + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); + manifest + .ancestors() + .nth(2) + .expect("repo root") + .to_path_buf() +} + +fn data_dir() -> PathBuf { + MockBackend::data_dir(&repo_root()) +} + +fn raw(scenario: &str) -> Value { + let text = std::fs::read_to_string(data_dir().join(format!("{scenario}.json"))).unwrap(); + serde_json::from_str(&text).unwrap() +} + +fn load(scenario: &str) -> MockBackend { + MockBackend::load(&data_dir(), scenario, Some(&repo_root())).expect(scenario) +} + +/// The scenario's JSON with `edit` applied, as a backend. +fn load_edited(scenario: &str, edit: impl FnOnce(&mut Value)) -> MockBackend { + let mut data = raw(scenario); + edit(&mut data); + MockBackend::from_json(&data.to_string(), Some(&repo_root())).unwrap() +} + +/// Arguments a command accepts: `{}` except where the command decodes a +/// structured argument, which an empty object would fail before the backend. +fn sample_args(command: &str) -> Value { + match command { + "steam_bulk_edit" => json!({ "request": { "steamIds": [], "launchOptions": [] } }), + "save_custom_theme" => json!({ "theme": { + "id": "t", "name": "T", "colorScheme": "dark", "tokens": {} + } }), + "telemetry_track_settings_snapshot" => json!({ + "uiLanguage": "en", "enabledPlatforms": [], "personasEnabled": false, + "pinEnabled": false, "cliEnabled": false, "deepLinksEnabled": false, + "streamerMode": "off", "animations": "on" + }), + _ => json!({}), + } +} + +/// Recorded value with avatar paths rewritten the way the test expects: +/// `/src/lib/mock/avatars/`, built here independently. +fn expected(value: &Value, root: &Path) -> Value { + match value { + Value::String(s) if s.starts_with(AVATAR_DIR) => { + let mut path = root.to_path_buf(); + for part in s.split('/') { + path.push(part); + } + assert!(path.is_file(), "avatar missing: {}", path.display()); + Value::String(path.to_string_lossy().into_owned()) + } + Value::Array(items) => Value::Array(items.iter().map(|v| expected(v, root)).collect()), + Value::Object(map) => Value::Object( + map.iter() + .map(|(k, v)| (k.clone(), expected(v, root))) + .collect(), + ), + other => other.clone(), + } +} + +#[test] +fn every_scenario_file_loads() { + let found = MockBackend::scenarios(&data_dir()); + assert_eq!(found, SCENARIOS.map(String::from).to_vec()); + for id in SCENARIOS { + let mock = load(id); + assert_eq!(mock.scenario(), id); + assert_eq!(mock.now(), NOW); + assert_eq!(mock.frozen(), id == "demo"); + } + println!("scenarios loaded: {}", found.len()); +} + +#[test] +fn every_recorded_command_is_a_backend_command() { + for id in SCENARIOS { + let mock = load(id); + for command in mock.recorded().keys().chain(mock.skipped().keys()) { + assert!(COMMANDS.contains(&command.as_str()), "{id}: {command}"); + } + } +} + +#[test] +fn every_recorded_call_answers_as_recorded() { + let root = repo_root(); + let mut total = 0; + for id in SCENARIOS { + let data = raw(id); + let mock = load(id); + let mut calls = 0; + for (command, recorded) in data["commands"].as_object().unwrap() { + for call in recorded.as_array().unwrap() { + let args = &call["args"]; + let answer = dispatch(&mock, command, args) + .unwrap_or_else(|| panic!("{command} is not dispatched")); + match call.get("error") { + Some(error) => assert_eq!( + answer, + Err(BackendError::new(error.as_str().unwrap())), + "{id}: {command} {args}" + ), + None => { + let mut want = expected(&call["result"], &root); + if command == "cs2_bridge_get_settings" { + want = cs2_settings_from_fixture(want); + } + assert_eq!(answer, Ok(want), "{id}: {command} {args}"); + } + } + calls += 1; + } + } + println!("{id}: {calls} recorded calls answered as recorded"); + total += calls; + } + println!("total: {total} recorded calls"); + assert!(total > 0); +} + +#[test] +fn avatar_paths_resolve_to_files() { + let mock = load("demo"); + let infos = mock + .steam_get_profile_infos(&[mock.spec().current_steam_account.clone()]) + .unwrap(); + let url = infos.values().next().unwrap().avatar_url.clone().unwrap(); + let path = mock.avatar_path(&url).unwrap(); + assert!(path.is_file(), "{}", path.display()); + assert!(mock.avatar_path("https://example.com/a.png").is_none()); +} + +#[test] +fn a_switch_changes_the_current_account() { + let mock = load_edited("dev", |data| data["spec"]["switchDelayMs"] = json!(50)); + let before = mock.platform_get_current_account("steam").unwrap(); + assert_eq!(before, mock.spec().current_steam_account); + let accounts = mock.platform_get_accounts("steam").unwrap(); + let target = accounts + .as_array() + .unwrap() + .iter() + .map(|a| a["steam_id"].as_str().unwrap().to_string()) + .find(|id| *id != before) + .unwrap(); + + let started = Instant::now(); + mock.platform_switch_account("steam", &target, &json!({})) + .unwrap(); + assert!(started.elapsed() >= Duration::from_millis(50)); + assert_eq!(mock.platform_get_current_account("steam").unwrap(), target); + let snapshot = mock.platform_get_startup_snapshot("steam").unwrap(); + assert_eq!(snapshot["currentAccount"], json!(target)); + + mock.platform_switch_account("riot", "riot-2", &json!({})) + .unwrap(); + let riot = mock.platform_get_startup_snapshot("riot").unwrap(); + assert_eq!(riot["currentProfile"], json!("riot-2")); + // Untouched by the other platforms' switches. + assert_eq!(mock.platform_get_current_account("steam").unwrap(), target); +} + +#[test] +fn the_scenario_switch_delay_is_honoured() { + let mock = load("dev"); + let started = Instant::now(); + mock.platform_switch_account("steam", "76561198100000002", &json!({})) + .unwrap(); + assert!(started.elapsed() >= Duration::from_millis(mock.spec().switch_delay_ms)); +} + +#[test] +fn unmocked_guarded_commands_are_refused() { + let mock = load("dev"); + let mut refused = 0; + for command in COMMANDS { + if mock.is_mocked(command) { + continue; + } + let answer = dispatch(&mock, command, &sample_args(command)).unwrap(); + let guarded = [ + "platform_", + "steam_", + "riot_", + "roblox_", + "cs2_bridge_", + "telemetry_", + "pin_", + ] + .iter() + .any(|p| command.starts_with(p)); + if guarded { + assert_eq!( + answer, + Err(BackendError::new(format!( + "mock mode: {command} is not available" + ))), + "{command}" + ); + refused += 1; + } else { + // Forwarded to the real backend by the TS mock; a no-op here. + assert_eq!(answer, Ok(Value::Null), "{command}"); + } + } + println!("refused: {refused}"); + assert!(refused > 0); +} + +#[test] +fn save_client_storage_store_saves_nothing() { + let mock = load("dev"); + let before = mock.load_client_storage_snapshot().unwrap(); + let saved = mock + .save_client_storage_store("client.view-mode", &json!("list")) + .unwrap(); + assert_eq!(saved, None); + let after = mock.load_client_storage_snapshot().unwrap(); + assert_eq!( + serde_json::to_value(before).unwrap(), + serde_json::to_value(after).unwrap() + ); +} + +#[test] +fn fail_and_latency_overrides() { + let mock = load("dev"); + assert!(mock.fail("get_runtime_os", Some("boom"))); + assert_eq!(mock.get_runtime_os(), Err(BackendError::new("boom"))); + // An override beats the guard too, like `mockInvoke`. + assert!(mock.fail("platform_forget_account", Some("nope"))); + assert_eq!( + mock.platform_forget_account("steam", "x"), + Err(BackendError::new("nope")) + ); + assert!(mock.fail("get_runtime_os", None)); + assert_eq!(mock.get_runtime_os().unwrap(), "windows"); + + assert_eq!(mock.latency(40), 40); + let started = Instant::now(); + mock.get_runtime_os().unwrap(); + assert!(started.elapsed() >= Duration::from_millis(40)); + + assert!(mock.set_runtime_os("macos")); + assert_eq!(mock.get_runtime_os().unwrap(), "macos"); + assert_eq!(mock.get_boot_payload().unwrap().runtime_os, "macos"); +} + +#[test] +fn the_demo_scenario_ignores_overrides() { + let mock = load("demo"); + assert!(!mock.fail("get_runtime_os", Some("boom"))); + assert!(mock.get_runtime_os().is_ok()); + assert_eq!(mock.latency(500), 0); + assert!(!mock.set_runtime_os("linux")); + assert_eq!(mock.get_runtime_os().unwrap(), "windows"); +} + +#[test] +fn a_pin_locks_the_switch_until_unlocked() { + let hash = accshift_core::pin::hash_pin_code("1234").unwrap(); + let mock = load_edited("dev", |data| { + data["spec"]["switchDelayMs"] = json!(0); + let settings = &mut data["commands"]["load_client_storage_snapshot"][0]["result"]["stores"] + ["client.settings"]; + settings["pinEnabled"] = json!(true); + settings["pinHash"] = json!(hash); + }); + let locked = mock + .platform_switch_account("steam", "76561198100000002", &json!({})) + .unwrap_err(); + assert!(locked.is_pin_locked(), "{locked}"); + let wrong = mock.pin_unlock("0000").unwrap(); + assert_eq!( + (wrong.status.as_str(), wrong.retry_after_ms), + ("invalid", 1000) + ); + let right = mock.pin_unlock("1234").unwrap(); + assert_eq!((right.status.as_str(), right.legacy), ("unlocked", false)); + mock.platform_switch_account("steam", "76561198100000002", &json!({})) + .unwrap(); + mock.pin_lock().unwrap(); + assert!(mock + .platform_switch_account("steam", "76561198100000001", &json!({})) + .unwrap_err() + .is_pin_locked()); + // No PIN in the scenario: nothing to unlock. + let open = load("empty").pin_unlock("1234").unwrap(); + assert_eq!(open.status, "not_configured"); +} + +#[test] +fn batch_and_unknown_ids_follow_the_fixtures() { + let mock = load("dev"); + let ids = vec![ + "76561198100000003".to_string(), + "nobody".to_string(), + "76561198100000001".to_string(), + ]; + let bans = mock.steam_get_player_bans(&ids).unwrap(); + let order: Vec<&str> = bans.iter().map(|b| b.steam_id.as_str()).collect(); + assert_eq!(order, ["76561198100000003", "76561198100000001"]); + let infos = mock.steam_get_profile_infos(&ids).unwrap(); + assert_eq!(infos.len(), 2); + assert!(mock.steam_get_profile_info("nobody").unwrap().is_none()); + assert_eq!(mock.roblox_get_profile_info("0").unwrap().avatar_url, None); + assert_eq!(mock.platform_get_accounts("epic").unwrap(), json!([])); + assert_eq!(mock.platform_get_current_account("epic").unwrap(), ""); + let plan = mock.platform_dry_run("steam", "76561198100000002").unwrap(); + assert_eq!(plan.account_id, "76561198100000002"); + assert!(!plan.applied); +} + +#[test] +fn jobs_deliver_off_thread_and_wake() { + let wakes = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&wakes); + let jobs = Jobs::new(Arc::new(load("dev")), 2, move || { + counter.fetch_add(1, Ordering::SeqCst); + }); + let mut os = jobs.run(|b| b.get_runtime_os()); + let current = jobs.run(|b| b.platform_get_current_account("steam")); + let panicked = jobs.run::<(), _>(|_| panic!("job panics on purpose")); + assert_eq!(current.wait(), Some(Ok("76561198100000001".to_string()))); + assert_eq!(panicked.wait(), None); + let deadline = Instant::now() + Duration::from_secs(5); + let answer = loop { + match os.poll() { + Poll::Ready(answer) => break answer, + Poll::Waiting if Instant::now() < deadline => std::thread::yield_now(), + other => panic!("{other:?}"), + } + }; + assert_eq!(answer, Ok("windows".to_string())); + assert_eq!(os.poll(), Poll::Gone); + // The pool survives a panicking call. + assert!(jobs.run(|b| b.steam_has_api_key()).wait().unwrap().unwrap()); + assert!(wakes.load(Ordering::SeqCst) >= 3); +} + +#[test] +fn the_real_skeleton_dispatches_every_command_and_ports_none() { + // A context on a scratch folder: nothing reads the user's real dirs. + struct Scratch(PathBuf); + impl accshift_core::AppContext for Scratch { + fn app_config_dir(&self) -> Result { + Ok(self.0.join("config")) + } + fn app_data_dir(&self) -> Result { + Ok(self.0.join("data")) + } + fn app_local_data_dir(&self) -> Result { + Ok(self.0.join("local")) + } + fn app_cache_dir(&self) -> Result { + Ok(self.0.join("cache")) + } + } + let real = RealBackend::with_context(Arc::new(Scratch(std::env::temp_dir()))); + assert_eq!(COMMANDS.len(), 73); + for command in COMMANDS { + let answer = dispatch(&real, command, &sample_args(command)) + .unwrap_or_else(|| panic!("{command} is not dispatched")); + assert_eq!(answer, Err(BackendError::not_ported(command)), "{command}"); + } + assert!(dispatch(&real, "minimize_window", &json!({})).is_none()); +} diff --git a/crates/accshift-core/src/platforms/descriptor/library.rs b/crates/accshift-core/src/platforms/descriptor/library.rs index 8b7ee14..dd172ea 100644 --- a/crates/accshift-core/src/platforms/descriptor/library.rs +++ b/crates/accshift-core/src/platforms/descriptor/library.rs @@ -14,11 +14,11 @@ use super::plan::DryRunPlan; use super::schema::{Descriptor, DescriptorError, Os}; use super::user_dir; use crate::context::AppContext; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; /// A descriptor file judged without installing it. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DescriptorPreview { /// The file the user picked, as they picked it. diff --git a/crates/accshift-core/src/platforms/mod.rs b/crates/accshift-core/src/platforms/mod.rs index e23de4a..22ba5ad 100644 --- a/crates/accshift-core/src/platforms/mod.rs +++ b/crates/accshift-core/src/platforms/mod.rs @@ -1,7 +1,7 @@ use crate::context::{AppContext, AppCtx}; use crate::error::PlatformError; use descriptor::{Descriptor, DescriptorOrigin, DescriptorService}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; use std::path::Path; @@ -156,7 +156,7 @@ pub(crate) fn make_setup_status( } /// Common setup status returned by all platforms. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SetupStatus { pub setup_id: String, @@ -298,7 +298,7 @@ fn user_registry() -> &'static RwLock, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BulkEditFailure { pub steam_id: String, pub error: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BulkEditResult { pub succeeded: u32, diff --git a/crates/accshift-core/src/platforms/steam/cs2_bridge.rs b/crates/accshift-core/src/platforms/steam/cs2_bridge.rs index f4ff9a4..125a3ae 100644 --- a/crates/accshift-core/src/platforms/steam/cs2_bridge.rs +++ b/crates/accshift-core/src/platforms/steam/cs2_bridge.rs @@ -24,7 +24,7 @@ const MAX_IDS_PER_REQUEST: usize = 300; /// Cette limite par requete remplace le timeout global de 10 s du client. const CHECK_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); -#[derive(Debug, Serialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct Cs2BridgeSettings { pub enabled: bool, @@ -52,7 +52,7 @@ pub struct Cs2BridgeAccount { /// Resultat du bouton "tester" des settings : jamais d'erreur Tauri, l'echec /// est une donnee (dot rouge + message), pas une exception. -#[derive(Debug, Serialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct Cs2BridgeTestResult { pub ok: bool, diff --git a/crates/accshift-core/src/storage.rs b/crates/accshift-core/src/storage.rs index bc3a8dc..5384351 100644 --- a/crates/accshift-core/src/storage.rs +++ b/crates/accshift-core/src/storage.rs @@ -1,6 +1,6 @@ use crate::context::AppContext; use crate::fs_utils; -use serde::{de::DeserializeOwned, Serialize}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::Value; use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs; @@ -52,14 +52,14 @@ pub const TARGET_DISCORD_SNAPSHOTS: &str = "platform.discord.snapshots"; const DEV_SCOPE_DIR: &str = "dev"; -#[derive(Debug, Serialize, Clone, Default)] +#[derive(Debug, Serialize, Deserialize, Clone, Default)] #[serde(rename_all = "camelCase")] pub struct StorageManifest { pub schema_version: u32, pub stores: BTreeMap, } -#[derive(Debug, Serialize, Clone, Default)] +#[derive(Debug, Serialize, Deserialize, Clone, Default)] #[serde(rename_all = "camelCase")] pub struct ClientStorageSnapshot { pub manifest: StorageManifest, From 338fefa4eccd633c7d4b61e14cfdb5218911cd05 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:53:13 +0200 Subject: [PATCH 009/106] feat(app): port the client stores, settings and i18n --- crates/accshift-app/i18n/en.json | 741 ++++ crates/accshift-app/i18n/es.json | 741 ++++ crates/accshift-app/i18n/fr.json | 741 ++++ crates/accshift-app/i18n/pt-br.json | 741 ++++ crates/accshift-app/i18n/pt.json | 741 ++++ crates/accshift-app/i18n/ru.json | 741 ++++ crates/accshift-app/i18n/zh.json | 741 ++++ crates/accshift-app/src/folders.rs | 506 +++ crates/accshift-app/src/i18n.rs | 339 ++ crates/accshift-app/src/js.rs | 307 ++ crates/accshift-app/src/lib.rs | 11 + crates/accshift-app/src/personas.rs | 263 ++ crates/accshift-app/src/settings.rs | 502 +++ crates/accshift-app/src/storage.rs | 544 +++ crates/accshift-app/src/stores.rs | 765 ++++ crates/accshift-app/tests/client_storage.rs | 359 ++ .../tests/data/stores/context.json | 15 + .../tests/data/stores/roundtrip.json | 3563 +++++++++++++++++ crates/accshift-app/tests/feature_stores.rs | 668 +++ crates/accshift-app/tests/i18n.rs | 288 ++ crates/accshift-app/tests/stores_roundtrip.rs | 182 + crates/accshift-app/tools/i18n.dump.ts | 40 + crates/accshift-app/tools/stores.dump.ts | 598 +++ .../accshift-app/tools/vitest.dump.config.mjs | 20 + 24 files changed, 14157 insertions(+) create mode 100644 crates/accshift-app/i18n/en.json create mode 100644 crates/accshift-app/i18n/es.json create mode 100644 crates/accshift-app/i18n/fr.json create mode 100644 crates/accshift-app/i18n/pt-br.json create mode 100644 crates/accshift-app/i18n/pt.json create mode 100644 crates/accshift-app/i18n/ru.json create mode 100644 crates/accshift-app/i18n/zh.json create mode 100644 crates/accshift-app/src/folders.rs create mode 100644 crates/accshift-app/src/i18n.rs create mode 100644 crates/accshift-app/src/js.rs create mode 100644 crates/accshift-app/src/personas.rs create mode 100644 crates/accshift-app/src/settings.rs create mode 100644 crates/accshift-app/src/storage.rs create mode 100644 crates/accshift-app/src/stores.rs create mode 100644 crates/accshift-app/tests/client_storage.rs create mode 100644 crates/accshift-app/tests/data/stores/context.json create mode 100644 crates/accshift-app/tests/data/stores/roundtrip.json create mode 100644 crates/accshift-app/tests/feature_stores.rs create mode 100644 crates/accshift-app/tests/i18n.rs create mode 100644 crates/accshift-app/tests/stores_roundtrip.rs create mode 100644 crates/accshift-app/tools/i18n.dump.ts create mode 100644 crates/accshift-app/tools/stores.dump.ts create mode 100644 crates/accshift-app/tools/vitest.dump.config.mjs diff --git a/crates/accshift-app/i18n/en.json b/crates/accshift-app/i18n/en.json new file mode 100644 index 0000000..731d291 --- /dev/null +++ b/crates/accshift-app/i18n/en.json @@ -0,0 +1,741 @@ +{ + "app.loadingSettings": "Loading settings...", + "app.loading": "Loading...", + "app.searchPlaceholder": "Search account...", + "app.noAccountsFound": "No {platform} accounts found", + "app.noAccountsHint": "Make sure {platform} is installed and you have logged in at least once.", + "app.switchingAccount": "Switching account...", + "app.platformRestarting": "{platform} is restarting", + "app.comingSoon": "{platform} - Coming soon", + "app.platformUnsupportedOs": "{platform} is not available on this OS yet", + "app.noCompatiblePlatforms": "No compatible platform is available on this OS", + "app.noCompatiblePlatformsHint": "Check back on Windows support updates or switch to a supported machine.", + "update.ctaAvailable": "Update available", + "update.ctaInstalling": "Installing...", + "update.restartToApplyVersion": "Restart to apply update {version}", + "update.restartToApply": "Restart to apply update", + "update.readyToastVersion": "Update {version} ready", + "update.readyToast": "Update ready", + "update.restartFailed": "Could not restart to apply update", + "pin.lockedTitle": "App Locked", + "pin.lockedPrompt": "Enter PIN to unlock", + "pin.placeholder": "4-digit PIN", + "pin.unlock": "Unlock", + "pin.invalid": "Invalid PIN", + "pin.tooManyAttempts": "Too many attempts. Try again in {seconds} s.", + "common.ok": "OK", + "common.cancel": "Cancel", + "common.confirm": "Confirm", + "common.close": "Close", + "common.back": "Back", + "common.active": "Active", + "common.choose": "Choose...", + "common.none": "None", + "common.custom": "Custom", + "common.configured": "Configured", + "common.missing": "Missing", + "common.enabled": "Enabled", + "common.disabled": "Disabled", + "common.on": "On", + "common.off": "Off", + "common.add": "Add", + "common.visible": "Visible", + "common.hidden": "Hidden", + "common.inline": "Inline", + "common.tooltip": "Tooltip", + "titlebar.refresh": "Refresh", + "titlebar.addAccount": "Add account", + "titlebar.settings": "Settings", + "titlebar.personas": "Personas", + "titlebar.minimize": "Minimize", + "titlebar.maximize": "Maximize", + "titlebar.restore": "Restore", + "titlebar.close": "Close", + "view.grid": "Grid view", + "view.list": "List view", + "settings.title": "Settings", + "settings.general": "General", + "settings.appearance": "Appearance", + "settings.language": "Language", + "settings.theme": "Theme", + "settings.uiZoom": "UI zoom", + "settings.backgroundOpacity": "Background opacity", + "settings.animations": "Animations", + "settings.animationsSystem": "Follow system", + "settings.animationsOn": "Always on", + "settings.animationsOff": "Off", + "settings.performance": "Performance", + "settings.suspendGraphicsWhenMinimized": "Suspend graphics when minimized", + "settings.minimizeOnAccountSwitch": "Minimize on account switch", + "settings.platforms": "Platforms", + "settings.platformSearchPlaceholder": "Search platforms...", + "settings.platformSearchNoResults": "No platform matches your search.", + "settings.accountDisplay": "Account display", + "settings.startupAndExtras": "Startup", + "settings.defaultOnStartup": "Default platform on startup", + "settings.platformDisabledSuffix": "(disabled)", + "settings.platformUnsupportedOs": "Unsupported OS", + "settings.platformNotImplemented": "Planned", + "settings.platformUserProvided": "Added from your descriptors folder", + "settings.customPlatforms": "Custom platforms", + "settings.customPlatformsHint": "Descriptor files in this folder are read when the app starts.", + "settings.customPlatformsNone": "No descriptor loaded from this folder.", + "settings.customPlatformsProblems": "Files that did not load", + "descriptor.addFromFile": "Add from a file", + "descriptor.reload": "Reload", + "descriptor.openFolder": "Open folder", + "descriptor.remove": "Remove", + "descriptor.removeConfirmTitle": "Remove {platform}?", + "descriptor.removeConfirmMessage": "Its descriptor file leaves your folder. Accounts already captured stay on disk.", + "descriptor.previewTitle": "Add a platform", + "descriptor.previewOs": "Described for {list}", + "descriptor.previewRoots": "Folders it may touch", + "descriptor.previewSteps": "What a switch would do", + "descriptor.previewWarnings": "Warnings", + "descriptor.previewNoPlan": "No dry run could be built: {problem}", + "descriptor.previewReplaces": "This replaces the descriptor already in your folder.", + "descriptor.previewNothingWritten": "Nothing is written until you add it.", + "descriptor.replace": "Replace", + "descriptor.installed": "{platform} added", + "descriptor.removed": "{platform} removed", + "descriptor.actionRead": "read", + "descriptor.actionCapture": "capture", + "descriptor.actionRestore": "restore", + "descriptor.actionDelete": "delete", + "descriptor.actionClose": "close", + "descriptor.actionLaunch": "launch", + "settings.dataRefresh": "Data Refresh", + "settings.avatarRefresh": "Avatar refresh", + "settings.banCheckDelay": "Ban check delay", + "settings.refreshNow": "Refresh now", + "settings.zeroEachLaunch": "0 = each launch", + "settings.privacy": "Privacy", + "settings.inactivityTimeout": "Inactivity timeout", + "settings.zeroDisabled": "0 = disabled", + "settings.streamerMode": "Streamer mode", + "settings.streamerModeHint": "Blur account details automatically when streaming software is running.", + "settings.personasHint": "Show the personas identity switcher", + "settings.replayOnboarding": "Replay onboarding", + "settings.replayOnboardingHint": "Show the welcome tour and the usage-statistics choice again.", + "streamer.title": "Streamer mode", + "streamer.disable": "Disable", + "streamer.disablePermanently": "Disable permanently", + "personas.title": "Personas", + "personas.create": "New persona", + "personas.activate": "Switch to this persona", + "personas.edit": "Edit", + "personas.delete": "Delete", + "personas.finish": "Done", + "personas.switching": "Switching...", + "personas.switched": "Switched to {name}", + "personas.switchPartial": "{name}: failed on {failed}", + "personas.switchFailed": "Could not switch to {name}", + "personas.namePlaceholder": "Persona name", + "personas.choosePlatforms": "Pick an account for each platform to include", + "personas.chooseAccount": "Pick the {platform} account", + "personas.removeFromPersona": "Remove from this persona", + "personas.customImage": "Choose an image...", + "personas.removeImage": "Remove image", + "personas.imageError": "Couldn't load that image.", + "personas.loadingAccounts": "Loading accounts...", + "personas.noAccountsOnPlatform": "No accounts on this platform yet", + "personas.emptyHint": "One click switches every included platform to its account.", + "settings.security": "Security", + "settings.pinLockOnAfk": "PIN lock on AFK", + "settings.pinCode": "PIN code", + "settings.pinPlaceholder": "4 digits", + "settings.pinCurrentCode": "Current PIN", + "settings.pinCurrentHint": "Enter the current PIN to change or turn off the lock.", + "settings.telemetry": "Usage statistics (anonymous)", + "settings.telemetryModeA": "Anonymous basic stats", + "settings.telemetryModeAHint": "Counts launches and which features get used. A random local ID prevents duplicate installation counts; no personal data is sent.", + "settings.telemetryModeB": "Enhanced stats with local UUID (opt-in)", + "settings.telemetryModeBHint": "Adds a random UUID stored only on this machine. Lets us measure retention and which features matter. Still no personal data sent.", + "settings.telemetryExport": "Export my data", + "settings.telemetryExported": "Data exported to clipboard", + "settings.telemetryExportFailed": "Export failed", + "settings.telemetryEnableFailed": "Could not enable enhanced stats", + "settings.telemetryDisableFailed": "Could not disable enhanced stats", + "settings.telemetryDeletePending": "Enhanced stats are off; server deletion will need another attempt", + "settings.telemetryDeletePendingHint": "Server deletion is pending. The old local ID is kept only so you can retry deletion or export the associated data.", + "settings.telemetryRetryDelete": "Retry data deletion", + "settings.telemetryDeleteCompleted": "Server data deleted", + "settings.telemetryDeleteRetryFailed": "Server deletion is still pending", + "settings.telemetryLearnMore": "What data is sent?", + "onboarding.welcome.title": "Welcome to Accshift", + "onboarding.welcome.version": "v{version}", + "onboarding.welcome.compatibleWith": "Your operating system is compatible with:", + "onboarding.welcome.detectedPlatforms": "We found these platforms on your machine:", + "onboarding.welcome.next": "Show me around", + "onboarding.welcome.skip": "Skip", + "onboarding.features.hint": "This part of the app isn't visible right now.", + "onboarding.features.addAccount.label": "Add an account", + "onboarding.features.addAccount.body": "The + button at the top right adds an account to the current platform. Log in once, Accshift keeps it.", + "onboarding.features.switch.label": "Switch account", + "onboarding.features.switch.body": "Click a card and you're on that account. That's the whole app, honestly.", + "onboarding.features.contextMenu.label": "Right-click for actions", + "onboarding.features.contextMenu.body": "Right-click a card to rename it, change its color, move it to a folder or delete it.", + "onboarding.features.platformSwitch.label": "Switch platform", + "onboarding.features.platformSwitch.body": "Same deal for Riot, Battle.net, Epic and the rest. The tabs up top switch platforms.", + "onboarding.features.next": "Next", + "onboarding.features.back": "Back", + "onboarding.features.step": "Step {step}", + "onboarding.features.mockRename": "Rename", + "onboarding.features.mockSetColor": "Set color", + "onboarding.features.mockMoveToFolder": "Move to folder", + "onboarding.features.mockDelete": "Delete", + "onboarding.features.mockAccount": "Account {number}", + "onboarding.telemetry.title": "SELL YOUR SOUL TO ACCSHIFT!", + "onboarding.telemetry.intro": "Accshift counts a few anonymous usage stats so I know what to fix first. Nothing personal leaves your machine, the app works the same either way, and you can change your mind anytime in Settings.", + "onboarding.telemetry.question": "Deal?", + "onboarding.telemetry.gifAlt": "Trade offer meme", + "onboarding.telemetry.basic": "NO! Enough is enough", + "onboarding.telemetry.basicDefault": "(default)", + "onboarding.telemetry.basicHint": "Anonymous counters and nothing else: no profile, no identifier that follows you around.", + "onboarding.telemetry.deal": "DEAL", + "onboarding.telemetry.dealHint": "Adds a separate UUID for enhanced stats, stored only on your machine. Still no idea who you are, but you help a lot. Thank you!", + "onboarding.telemetry.optOutNote": "Whatever you pick, you can change it later in Settings -> Privacy. :c", + "onboarding.telemetry.refuse": "No thanks, turn it all off", + "onboarding.telemetry.refuseHint": "Nothing is measured and nothing is sent, not even the anonymous counters.", + "onboarding.telemetry.back": "Back", + "onboarding.persistFailed": "Could not save that choice. Try again.", + "onboarding.persistLater": "Ask me next launch", + "settings.steam": "Steam", + "settings.riot": "Riot", + "settings.battleNet": "Battle.net", + "settings.ubisoft": "Ubisoft Connect", + "settings.runSteamAsAdmin": "Run Steam as admin", + "settings.showUsernames": "Show usernames", + "settings.showSteamLastLogin": "Show Steam last login", + "settings.showRiotLastLogin": "Show Riot last login", + "settings.showBattleNetLastLogin": "Show Battle.net last login", + "settings.showUbisoftLastLogin": "Show Ubisoft last login", + "settings.showNotesUnderCards": "Show notes under cards", + "settings.expandedFolders": "Expand all folders", + "list.rootSection": "Root", + "settings.steamLaunch": "Switch", + "settings.steamInstallation": "Installation", + "settings.steamApiAndData": "API & data", + "settings.launchOptions": "Launch options", + "settings.shutdownMode": "Shutdown mode", + "settings.shutdownModeGraceful": "Graceful", + "settings.shutdownModeForce": "Force kill", + "settings.autoDetected": "Auto-detected", + "settings.steamFolder": "Steam folder", + "settings.steamFolderRegistry": "Registry", + "settings.riotClientPath": "Riot client executable", + "settings.battleNetPath": "Battle.net executable", + "settings.ubisoftPath": "Ubisoft Connect folder", + "settings.steamWebApiKey": "Steam Web API key", + "settings.getKey": "Get key", + "settings.pasteApiKey": "Paste your Steam Web API key", + "settings.saved": "Settings saved", + "settings.openApiKeyFailed": "Could not open Steam API key page", + "settings.integrations": "Integrations", + "settings.deepLinks": "Allow accshift:// links", + "settings.deepLinksHint": "Let other apps switch accounts for you: bookmarks, Stream Deck, scripts.", + "settings.cliEnabled": "Allow the accshift CLI", + "settings.cliEnabledHint": "Let the command line tool list and switch accounts. When off, every accshift command refuses to run, diag included; the app itself is unaffected.", + "settings.help": "Open the accshift wiki", + "settings.helpShort": "Help", + "settings.openHelpFailed": "Could not open the wiki", + "settings.logs": "Logs", + "settings.openLogs": "Open the logs folder", + "settings.openLogsFailed": "Could not open the logs folder", + "context.loading": "Loading...", + "context.noGamesFound": "No games found", + "context.menu.newFolder": "New folder", + "context.menu.refresh": "Refresh", + "context.menu.rename": "Rename", + "context.menu.copy": "Copy...", + "context.menu.deleteFolder": "Delete folder", + "context.menu.folderColor": "Folder color", + "context.menu.moveToFolder": "Move to folder", + "context.menu.moveToRoot": "No folder", + "context.menu.editCardAndColor": "Edit card and color", + "context.menu.cardColor": "Card color", + "context.menu.addNote": "Add note", + "context.menu.editNote": "Edit note", + "platform.rename": "Rename", + "platform.renameTitle": "Rename account", + "platform.renamePlaceholder": "Account name", + "platform.newAccountPending": "Waiting...", + "platform.setupBusy": "Another accshift operation is running. Waiting for it to finish.", + "platform.renameNewAccount": "Name this account", + "platform.copyLabelAccountId": "Account ID", + "platform.forget": "Forget", + "platform.forgetConfirmTitle": "Forget \"{display}\"?", + "platform.forgetConfirmMessage": "This will remove this account and its saved session from Accshift.", + "platform.forgotAccount": "Forgot {display}", + "dialog.newFolderTitle": "New folder", + "dialog.renameFolderTitle": "Rename folder", + "dialog.folderNamePlaceholder": "Folder name", + "dialog.cardNoteTitle": "Card note", + "dialog.cardNotePlaceholder": "Write a short note", + "card.noteAttached": "Note attached", + "card.clickAgainToSwitch": "Click again to switch", + "card.extensionWarnings": "Warnings", + "card.extensionNote": "Note", + "card.cs2Section": "CS2", + "card.cs2Level": "Level {level}", + "card.cs2CaseEarned": "Weekly case earned", + "card.cs2CaseNotEarned": "Weekly case pending", + "list.selectAccountPreview": "Select an account to preview", + "preview.noteLabel": "Note", + "preview.currentlyActive": "Currently active", + "preview.switchAgain": "Switch Again", + "preview.switchAccount": "Switch Account", + "ban.community": "Community ban", + "ban.vac.single": "{count} VAC ban", + "ban.vac.multiple": "{count} VAC bans", + "ban.game.single": "{count} game ban", + "ban.game.multiple": "{count} game bans", + "time.unknown": "unknown", + "time.justNow": "just now", + "time.minuteAgo": "{count} minute ago", + "time.minutesAgo": "{count} minutes ago", + "time.hourAgo": "{count} hour ago", + "time.hoursAgo": "{count} hours ago", + "time.dayAgo": "{count} day ago", + "time.daysAgo": "{count} days ago", + "time.neverConnected": "never connected", + "time.compactWrapper": "({value})", + "toast.copied": "{label} copied", + "toast.legacyConfigMigrated": "Settings migrated to new format", + "toast.legacyConfigMigrationFailed": "Failed to migrate old settings, check logs", + "toast.failedLoadSettingsPanel": "Failed to load settings panel", + "toast.banChecking": "Checking bans...", + "toast.banCheckSummary.single": "Ban check: {count} account with bans", + "toast.banCheckSummary.multiple": "Ban check: {count} accounts with bans", + "toast.banCheckFailed": "Ban check failed: {error}", + "toast.avatarRefreshComplete": "Avatar refresh finished for {count} accounts", + "toast.banRefreshComplete": "Ban refresh finished for {count} accounts", + "toast.banRefreshNoApiKey": "Ban refresh skipped: add a Steam Web API key in settings", + "toast.refreshPartial": "Refresh finished for {ok} of {count} accounts", + "toast.noSteamAccountsFound": "No Steam accounts found. Sign in to Steam at least once, then refresh.", + "toast.accountsRefreshed.single": "{count} account refreshed", + "toast.accountsRefreshed.multiple": "{count} accounts refreshed", + "toast.steamFolderNotFound": "Steam folder was not found. Set it manually in Settings > Steam folder.", + "toast.steamElevated": "Steam is running as admin. Enable 'Run Steam as admin' in Settings > Steam.", + "toast.noRiotProfilesFound": "No Riot profiles yet. Click + to add one.", + "toast.noBattleNetAccountsFound": "No Battle.net accounts found. Sign in to Battle.net at least once, then refresh.", + "toast.noUbisoftAccountsFound": "No Ubisoft accounts found. Sign in to Ubisoft Connect at least once, then refresh.", + "toast.deepLinkInvalid": "Unrecognized accshift:// link", + "toast.deepLinkDisabled": "accshift:// links are disabled in Settings", + "toast.deepLinkUnknownPlatform": "Unknown platform in link: {platform}", + "toast.deepLinkPlatformDisabled": "{platform} is disabled in Settings", + "toast.deepLinkAccountNotFound": "No {platform} account matching \"{account}\"", + "toast.deepLinkSwitched": "Switched to {account}", + "dialog.deepLinkSwitchTitle": "Switch account?", + "dialog.deepLinkSwitchMessage": "An accshift:// link is asking to switch your {platform} account to \"{account}\". Continue?", + "dialog.deepLinkSwitchConfirm": "Switch", + "steam.launchOnline": "Switch online", + "steam.launchInvisible": "Switch invisible", + "steam.switchAndRunGame": "Switch + run {game}", + "steam.switchAndRunPick": "Switch + run a game…", + "steam.runGame": "Run {game}", + "steam.runPick": "Run a game…", + "steam.switchAndRunNoGames": "No games found", + "steam.clearDefaultGame": "Clear default game", + "steam.copySteamId64": "Copy SteamID64", + "steam.copyFriendCode": "Copy Friend Code", + "steam.copyProfileUrl": "Copy profile URL", + "steam.copyLabelUsername": "Username", + "steam.copyLabelSteamId64": "SteamID64", + "steam.copyLabelFriendCode": "Friend Code", + "steam.copyLabelProfileUrl": "Profile URL", + "steam.openUserdataFolder": "Open userdata folder", + "steam.clearIntegratedBrowserCache": "Reset browser session", + "steam.clearIntegratedBrowserCacheConfirmTitle": "Reset Steam browser session?", + "steam.clearIntegratedBrowserCacheConfirmMessage": "This closes Steam and clears the integrated browser cache used by Store and Community.", + "steam.clearedIntegratedBrowserCache": "Steam browser session reset", + "steam.copySettingsFrom": "Copy settings from", + "steam.copiedSettingsToCurrent": "Copied {game} settings to current account", + "steam.forget": "Forget", + "steam.forgetConfirmTitle": "Forget \"{display}\"?", + "steam.forgetConfirmMessage": "This will remove this account from your Steam account list on this PC.", + "steam.forgotAccount": "Forgot {username}", + "steam.setupPendingLabel": "Steam Account", + "steam.setupWaitingForClient": "Waiting for Steam...", + "steam.setupWaitingForLogin": "Sign in to the Steam account you want to add.", + "steam.setupDetected": "Detected account", + "steam.setupConnected": "Connected", + "steam.setupReady": "Steam account added", + "steam.setupReadyWithProfile": "Added {profile}", + "steam.setupFailed": "Steam setup failed", + "steam.setupFailedMessage": "Accshift could not finish adding this Steam account.", + "riot.captureSession": "Capture current session", + "riot.recaptureSession": "Re-capture session", + "riot.capturedSession": "Captured session for {profile}", + "riot.setupPendingLabel": "Riot Account", + "riot.setupWaitingForClient": "Waiting for Riot Client...", + "riot.setupWaitingForLogin": "Sign in to the Riot account you want to add.", + "riot.setupCapturing": "Saving Riot session...", + "riot.setupReady": "Riot account added", + "riot.setupReadyWithProfile": "Added {profile}", + "riot.setupFailed": "Riot setup failed", + "riot.setupFailedMessage": "Accshift could not finish adding this Riot account.", + "riot.setupStaySignedIn": "Keep 'Stay signed in' enabled.", + "riot.setupDetected": "Detected account", + "riot.setupConnected": "Connected", + "riot.forget": "Forget account", + "riot.forgetConfirmTitle": "Forget \"{display}\"?", + "riot.forgetConfirmMessage": "This removes the local Riot profile and its saved session from Accshift.", + "riot.forgotProfile": "Forgot {profile}", + "battlenet.copyUsername": "Copy username", + "battlenet.copyLabelUsername": "Username", + "battlenet.copyEmail": "Copy email", + "battlenet.copyLabelEmail": "Email", + "battlenet.forget": "Forget", + "battlenet.forgetConfirmTitle": "Forget \"{display}\"?", + "battlenet.forgetConfirmMessage": "This will remove this account from the Battle.net account list on this PC.", + "battlenet.forgotAccount": "Forgot {email}", + "battlenet.setupPendingLabel": "Battle.net Account", + "battlenet.setupWaitingForClient": "Waiting for Battle.net...", + "battlenet.setupWaitingForLogin": "Sign in to the Battle.net account you want to add.", + "battlenet.setupKeepMeLoggedIn": "Keep 'Keep me logged in' enabled when possible.", + "battlenet.setupDetected": "Detected account", + "battlenet.setupConnected": "Connected", + "battlenet.setupReady": "Battle.net account added", + "battlenet.setupReadyWithProfile": "Added {profile}", + "battlenet.setupFailed": "Battle.net setup failed", + "battlenet.setupFailedMessage": "Accshift could not finish adding this Battle.net account.", + "ubisoft.copyLabelUuid": "UUID", + "ubisoft.forget": "Forget", + "ubisoft.forgetConfirmTitle": "Forget \"{display}\"?", + "ubisoft.forgetConfirmMessage": "This will remove this account and its saved session from Accshift.", + "ubisoft.forgotAccount": "Forgot {display}", + "ubisoft.setupPendingLabel": "Ubisoft Account", + "ubisoft.setupWaitingForClient": "Waiting for Ubisoft Connect...", + "ubisoft.setupWaitingForLogin": "Sign in to the Ubisoft account you want to add.", + "ubisoft.setupKeepMeLoggedIn": "Keep 'Remember me' enabled.", + "ubisoft.setupDetected": "Detected account", + "ubisoft.setupConnected": "Connected", + "ubisoft.setupReady": "Ubisoft account added", + "ubisoft.setupReadyWithProfile": "Added {profile}", + "ubisoft.setupFailed": "Ubisoft setup failed", + "ubisoft.setupFailedMessage": "Accshift could not finish adding this Ubisoft account.", + "settings.roblox": "Roblox", + "toast.noRobloxAccountsFound": "No Roblox accounts found. Add an account using Quick Login or paste a cookie.", + "roblox.noAccountsHint": "Press + to add a Roblox account via Quick Login.", + "roblox.sessionExpired": "Session expired", + "roblox.sessionExpiredTooltip": "This account is logged out. Re-add it before switching.", + "roblox.sessionExpiredSwitchError": "Session expired. Re-add this account to switch. Notes and settings are kept.", + "roblox.cookiePasteTitle": "Add account by cookie", + "roblox.cookiePasteHint": "Paste a .ROBLOSECURITY cookie from your browser to add an account directly.", + "settings.showRobloxLastLogin": "Show last login", + "settings.robloxHealthCheck": "Session check", + "settings.robloxHealthCheckHint": "Flags accounts whose Roblox session expired; one network request per launch.", + "roblox.copyLabelUsername": "Username", + "roblox.copyLabelUserId": "User ID", + "roblox.forget": "Forget", + "roblox.forgetConfirmTitle": "Forget \"{display}\"?", + "roblox.forgetConfirmMessage": "This will remove this account and its stored cookie from this PC.", + "roblox.forgotAccount": "Forgot {display}", + "roblox.setupPendingLabel": "Roblox Account", + "roblox.setupWaitingForClient": "Connecting to Roblox...", + "roblox.setupWaitingForLogin": "Open the Roblox app on another device and enter the code below.", + "roblox.setupQuickLoginCode": "Quick Login code", + "roblox.setupQuickLoginHint": "roblox.com/quick-login", + "roblox.copyCode": "Copy code", + "roblox.setupDetected": "Detected account", + "roblox.setupConnected": "Connected", + "roblox.setupReady": "Roblox account added", + "roblox.setupReadyWithProfile": "Added {profile}", + "roblox.setupFailed": "Roblox setup failed", + "roblox.setupFailedMessage": "Accshift could not finish adding this Roblox account.", + "settings.epic": "Epic Games", + "settings.epicPath": "Epic Games Launcher executable", + "settings.showEpicLastLogin": "Show last login", + "toast.noEpicAccountsFound": "No Epic Games accounts found. Sign in to Epic Games Launcher at least once, then refresh.", + "epic.noAccountsHint": "Press + to add an Epic Games account.", + "epic.copyLabelAccountId": "Account ID", + "epic.forget": "Forget", + "epic.forgetConfirmTitle": "Forget \"{display}\"?", + "epic.forgetConfirmMessage": "This will remove this account and its saved session from Accshift.", + "epic.forgotAccount": "Forgot {display}", + "epic.setupPendingLabel": "Epic Games Account", + "epic.setupWaitingForClient": "Waiting for Epic Games Launcher...", + "epic.setupWaitingForLogin": "Sign in to the Epic Games account you want to add.", + "epic.setupKeepMeLoggedIn": "Keep 'Remember me' enabled.", + "epic.setupDetected": "Detected account", + "epic.setupConnected": "Connected", + "epic.setupReady": "Epic Games account added", + "epic.setupReadyWithProfile": "Added {profile}", + "epic.setupFailed": "Epic Games setup failed", + "epic.setupFailedMessage": "Accshift could not finish adding this Epic Games account.", + "settings.gog": "GOG Galaxy", + "settings.gogPath": "GOG Galaxy executable", + "settings.showGogLastLogin": "Show last login", + "toast.noGogAccountsFound": "No GOG Galaxy accounts found. Sign in to GOG Galaxy at least once, then refresh.", + "gog.noAccountsHint": "Press + to add a GOG Galaxy account.", + "gog.copyLabelAccountId": "Account ID", + "gog.forget": "Forget", + "gog.forgetConfirmTitle": "Forget \"{display}\"?", + "gog.forgetConfirmMessage": "This will remove this account and its saved session from Accshift.", + "gog.forgotAccount": "Forgot {display}", + "gog.setupPendingLabel": "GOG Galaxy Account", + "gog.setupWaitingForClient": "Waiting for GOG Galaxy...", + "gog.setupWaitingForLogin": "Sign in to the GOG Galaxy account you want to add.", + "gog.setupKeepMeLoggedIn": "Keep 'Stay signed in' enabled.", + "gog.setupDetected": "Detected account", + "gog.setupConnected": "Connected", + "gog.setupReady": "GOG Galaxy account added", + "gog.setupReadyWithProfile": "Added {profile}", + "gog.setupFailed": "GOG Galaxy setup failed", + "gog.setupFailedMessage": "Accshift could not finish adding this GOG Galaxy account.", + "settings.jagex": "Jagex Launcher", + "settings.jagexPath": "Jagex Launcher executable", + "settings.showJagexLastLogin": "Show last login", + "toast.noJagexAccountsFound": "No Jagex accounts saved yet. Press + to add one through the Jagex Launcher.", + "jagex.noAccountsHint": "Press + to add a Jagex account.", + "jagex.copyLabelAccountId": "Account ID", + "jagex.forget": "Forget", + "jagex.forgetConfirmTitle": "Forget \"{display}\"?", + "jagex.forgetConfirmMessage": "This will remove this account and its saved session from Accshift.", + "jagex.forgotAccount": "Forgot {display}", + "jagex.setupPendingLabel": "Jagex Account", + "jagex.setupWaitingForClient": "Waiting for the Jagex Launcher...", + "jagex.setupWaitingForLogin": "Sign in to the Jagex account you want to add.", + "jagex.setupKeepMeLoggedIn": "Keep 'Remember me' enabled.", + "jagex.setupDetected": "Detected account", + "jagex.setupConnected": "Connected", + "jagex.setupReady": "Jagex account added", + "jagex.setupReadyWithProfile": "Added {profile}", + "jagex.setupFailed": "Jagex setup failed", + "jagex.setupFailedMessage": "Accshift could not finish adding this Jagex account.", + "settings.discord": "Discord", + "settings.discordPath": "Discord executable", + "settings.showDiscordLastLogin": "Show last login", + "toast.noDiscordAccountsFound": "No Discord accounts found. Add a Discord account, then refresh.", + "discord.noAccountsHint": "Press + to add a Discord account.", + "discord.copyLabelAccountId": "Account ID", + "discord.forget": "Forget", + "discord.forgetConfirmTitle": "Forget \"{display}\"?", + "discord.forgetConfirmMessage": "This will remove this account and its saved session from Accshift.", + "discord.forgotAccount": "Forgot {display}", + "discord.setupPendingLabel": "Discord Account", + "discord.setupWaitingForClient": "Waiting for Discord...", + "discord.setupWaitingForLogin": "Sign in to the Discord account you want to add.", + "discord.setupKeepMeLoggedIn": "Stay signed in to keep this account.", + "discord.setupDetected": "Detected account", + "discord.setupConnected": "Connected", + "discord.setupReady": "Discord account added", + "discord.setupReadyWithProfile": "Added {profile}", + "discord.setupFailed": "Discord setup failed", + "discord.setupFailedMessage": "Accshift could not finish adding this Discord account.", + "color.none": "Default", + "color.blue": "Blue", + "color.cyan": "Cyan", + "color.green": "Green", + "color.lime": "Lime", + "color.yellow": "Yellow", + "color.orange": "Orange", + "color.red": "Red", + "color.pink": "Pink", + "color.violet": "Violet", + "color.gray": "Gray", + "language.english": "English", + "language.french": "Français", + "language.spanish": "Español", + "language.portuguese": "Português (Portugal)", + "language.portugueseBrazil": "Português (Brasil)", + "language.russian": "Русский", + "language.chinese": "简体中文", + "theme.dark": "Dark", + "theme.light": "Light", + "theme.midnight": "Midnight", + "theme.glassDark": "Glass Dark", + "theme.glassLight": "Glass Light", + "theme.liquidGlass": "Liquid Glass", + "theme.custom": "Custom", + "settings.themeImport": "Import", + "settings.themeExport": "Export", + "settings.themeDelete": "Delete theme", + "settings.themeImported": "Theme imported", + "settings.themeDeleted": "Theme deleted", + "settings.themeExportCopied": "Theme JSON copied to clipboard", + "settings.themeInvalidJson": "Invalid theme JSON", + "settings.themeCustomize": "Customize", + "settings.themeEdit": "Edit", + "settings.themeCopyName": "{name} copy", + "settings.themeSaved": "Theme saved", + "settings.themeMigrated": "Theme imported and updated to the current format", + "settings.themeInvalidId": "The theme identifier is missing or invalid", + "settings.themeInvalidName": "The theme has no name", + "settings.themeUnsupportedVersion": "This theme needs a newer version of accshift", + "themeGroup.surface": "Surfaces", + "themeGroup.text": "Text", + "themeGroup.semantic": "Status colors", + "themeGroup.shape": "Shape and density", + "themeGroup.typography": "Typography", + "themeGroup.motion": "Motion", + "themeKind.rgbTriplet": "RGB triplet", + "themeKind.hexColor": "hex color", + "themeKind.color": "color", + "themeKind.length": "length", + "themeKind.shadow": "shadow", + "themeKind.choice": "choice", + "themeKind.fontStack": "font", + "themeKind.signedLength": "spacing", + "themeKind.gradient": "gradient", + "themeKind.number": "number", + "themeDensity.compact": "Compact", + "themeDensity.cozy": "Cozy", + "themeDensity.comfortable": "Comfortable", + "themeAvatarShape.circle": "Circle", + "themeAvatarShape.rounded": "Rounded", + "themeAvatarShape.square": "Square", + "themeSmoothing.auto": "Smoothed", + "themeSmoothing.none": "Pixel", + "themeToken.bgRgb": "Window background", + "themeToken.bgCard": "Card background", + "themeToken.bgCardHover": "Card background, hovered", + "themeToken.bgMuted": "Muted background", + "themeToken.bgElevated": "Elevated background", + "themeToken.border": "Borders", + "themeToken.fg": "Main text", + "themeToken.fgMuted": "Secondary text", + "themeToken.fgSubtle": "Subtle text", + "themeToken.afkText": "Away label", + "themeToken.accent": "Accent", + "themeToken.accentFg": "Text on accent", + "themeToken.success": "Success", + "themeToken.warning": "Warning", + "themeToken.danger": "Danger", + "themeToken.radiusSm": "Small radius", + "themeToken.radiusMd": "Medium radius", + "themeToken.radiusLg": "Large radius", + "themeToken.elevationLow": "Low shadow", + "themeToken.elevationMedium": "Medium shadow", + "themeToken.elevationHigh": "High shadow", + "themeToken.density": "Density", + "themeToken.fontUi": "Interface font", + "themeToken.bgImage": "Window gradient", + "themeToken.cardBgImage": "Surface gradient", + "themeToken.borderWidth": "Border width", + "themeToken.borderStyle": "Border style", + "themeToken.avatarShape": "Avatar shape", + "themeToken.focusRing": "Focus ring", + "themeToken.overlayBlur": "Overlay blur", + "themeToken.fontDisplay": "Heading font", + "themeToken.lineHeight": "Line height", + "themeToken.letterSpacing": "Letter spacing", + "themeToken.labelCase": "Label case", + "themeToken.fontSmoothing": "Font smoothing", + "themeToken.motionScale": "Animation speed", + "themeEditor.title": "Theme editor", + "themeEditor.name": "Name", + "themeEditor.author": "Author", + "themeEditor.version": "Version", + "themeEditor.base": "Inherits from", + "themeEditor.colorScheme": "Color scheme", + "themeEditor.glass": "Translucent surfaces", + "themeEditor.inherited": "Inherited", + "themeEditor.builtInBase": "Built-in theme", + "themeEditor.reset": "Reset to the inherited value", + "themeEditor.issues": "Checks", + "themeEditor.noIssues": "Nothing to report", + "themeEditor.fixErrorsFirst": "Fix the refused values before saving", + "themeEditor.save": "Save", + "themeEditor.saveFailed": "Could not save the theme. Try again.", + "themeEditor.export": "Copy", + "themeEditor.issueInvalidValue": "{token}: not a valid {kind}, for example {example}", + "themeEditor.issueUnknownToken": "{token} is not a theme value, it is ignored", + "themeEditor.issueMissingToken": "{token} is not set, the built-in value is used", + "themeEditor.issueUnknownBase": "Base theme {base} does not exist, built-in values are used", + "themeEditor.issueContrast": "{token} on {against}: {ratio} to 1, below the {target} to 1 target", + "themeEditor.beta": "Beta", + "themeEditor.betaNote": "The theme format is still moving: a theme written today may need a pass after an update.", + "themeEditor.customCss": "Custom CSS", + "themeEditor.customCssHelp": "Applied on top of the theme, for what the values above cannot reach. No @import and no url(): a theme never fetches anything.", + "themeEditor.issueUnsafeCss": "{construct} is not allowed in a theme, the CSS is not applied", + "themeEditor.issueCssTooLong": "CSS too long: {length} characters, the limit is {limit}", + "bulkEdit.title": "Bulk edit", + "bulkEdit.selectAccounts": "Select accounts", + "bulkEdit.selectAll": "Select all", + "bulkEdit.deselectAll": "Deselect all", + "bulkEdit.next": "Next", + "bulkEdit.apply": "Apply", + "bulkEdit.settings": "Settings", + "bulkEdit.newsPopup": "Game news popup", + "bulkEdit.doNotDisturb": "Do not disturb", + "bulkEdit.launchOptions": "Launch options per game", + "bulkEdit.unchanged": "Unchanged", + "bulkEdit.addLaunchOption": "Add", + "bulkEdit.selectGame": "Select a game...", + "bulkEdit.launchOptionsPlaceholder": "-fullscreen", + "bulkEdit.activeAccountWarning": "The currently active account is selected. Changes will apply on next Steam restart.", + "bulkEdit.toastSuccess": "Bulk edit: {count} succeeded", + "bulkEdit.toastPartial": "Bulk edit: {succeeded} succeeded, {failed} failed", + "bulkEdit.toastFailed": "Bulk edit failed", + "bulkEdit.noAccountsSelected": "No accounts selected", + "bulkEdit.selected": "{count} selected", + "bulkEdit.loadingGames": "Loading games...", + "bulkEdit.copyUrls": "Copy URLs", + "bulkEdit.urlsCopied": "{count} profile URL(s) copied", + "bulkEdit.cardColor": "Card color", + "bulkEdit.colorApplied": "Color applied to {count} card(s)", + "bulkEdit.colorCleared": "Color cleared on {count} card(s)", + "common.retry": "Retry", + "app.searchNoResults": "No results for \"{query}\"", + "app.folderEmpty": "This folder is empty", + "app.loadErrorGeneric": "Failed to load accounts. See the console for details.", + "toast.dismiss": "Dismiss", + "toast.switchFailed": "Account switch failed. See the console for details.", + "toast.addAccountFailed": "Couldn't add the account. Check the app logs for details.", + "toast.addAccountClientMissing": "{platform} doesn't seem to be installed. Install it, or set its location in Settings.", + "toast.banCheckFailedGeneric": "Ban check failed. See the console for details.", + "toast.renameAccountFailed": "Rename failed: {error}", + "toast.copyFailed": "Couldn't copy to the clipboard", + "toast.refreshFailed": "Refresh failed. See the console for details.", + "dialog.deleteFolderTitle": "Delete \"{name}\"?", + "dialog.deleteFolderMessage": "Subfolders are deleted too. Accounts inside are moved back up, not removed.", + "personas.deleteConfirm": "Click again to delete", + "personas.discardConfirm": "Click again to discard", + "personas.switchConfirmTitle": "Switch persona", + "personas.switchConfirmMessage": "Switch {count} platform(s) to \"{name}\"? Each client will close and relaunch.", + "personas.switchConfirmAction": "Switch", + "personas.deleteTitle": "Delete persona", + "personas.deleteMessage": "Delete \"{name}\"? The accounts themselves are not touched.", + "context.submenuError": "Couldn't load this menu", + "settings.apiKeySaveFailed": "Couldn't save the Steam API key", + "settings.apiKeyInvalidHint": "The key wasn't saved. Check it and try again.", + "settings.apiKeyCleared": "Steam API key cleared", + "settings.apiKeyClearFailed": "Couldn't clear the Steam API key", + "settings.clearApiKey": "Clear key", + "settings.cs2Bridge": "CS2 stats bridge", + "settings.cs2BridgeLink": "Bridge link", + "settings.cs2BridgeLinkPlaceholder": "https://your-server/api/bridge/accshift/", + "settings.cs2BridgeTest": "Test", + "settings.cs2BridgeStatusOk": "Connected · {count} accounts · {ms} ms", + "settings.cs2BridgeStatusFail": "Connection failed", + "settings.cs2BridgeStatusTesting": "Testing...", + "settings.cs2BridgeStatusUnknown": "Not tested", + "settings.cs2BridgeToken": "API token", + "settings.cs2BridgeTokenOptional": "optional, for custom servers", + "settings.cs2BridgeTokenPlaceholder": "Bearer token if your server needs one", + "settings.cs2BridgeSaveFailed": "Couldn't save the CS2 bridge settings", + "settings.cs2BridgeClearToken": "Clear token", + "settings.pathSaveFailed": "Couldn't save the {platform} path", + "settings.pathInvalidHint": "This path couldn't be saved. Check it and try again.", + "settings.pathSteamNeverSignedIn": "Steam found, sign in once so it creates its login history", + "settings.pathNotSteamFolder": "This folder does not look like a Steam installation", + "settings.pathNotADirectory": "The path must be an existing folder", + "settings.pinSaved": "PIN saved", + "settings.pinTakesEffectNextLaunch": "Takes effect the next time the app starts", + "settings.pinRequiredAfterInactivity": "Required after inactivity and when the app starts", + "settings.pinScreenLockOnly": "The PIN locks this window and account switches. It protects nothing on disk.", + "settings.unitSeconds": "seconds", + "settings.unitDays": "days", + "settings.telemetryLoadFailed": "Couldn't load telemetry settings", + "palette.placeholder": "Type a command or account name...", + "palette.noResults": "No matching commands", + "palette.sectionAccounts": "Accounts", + "palette.sectionActions": "Actions", + "palette.sectionNavigation": "Navigation", + "palette.openTab": "Go to {platform}", + "palette.openFolder": "Open folder \"{name}\"", + "palette.backToParent": "Back to parent folder", + "palette.zoomReset": "Reset zoom" +} diff --git a/crates/accshift-app/i18n/es.json b/crates/accshift-app/i18n/es.json new file mode 100644 index 0000000..abf7318 --- /dev/null +++ b/crates/accshift-app/i18n/es.json @@ -0,0 +1,741 @@ +{ + "app.loadingSettings": "Cargando la configuración...", + "app.loading": "Cargando...", + "app.searchPlaceholder": "Buscar cuenta...", + "app.noAccountsFound": "No se encontraron cuentas de {platform}", + "app.noAccountsHint": "Asegúrate de que {platform} esté instalado y de haber iniciado sesión al menos una vez.", + "app.switchingAccount": "Cambiando de cuenta...", + "app.platformRestarting": "{platform} se está reiniciando", + "app.comingSoon": "{platform} - Muy pronto", + "app.platformUnsupportedOs": "{platform} aún no está disponible en este sistema", + "app.noCompatiblePlatforms": "No hay ninguna plataforma compatible en este sistema", + "app.noCompatiblePlatformsHint": "Consulta las novedades de compatibilidad con Windows o usa una máquina compatible.", + "update.ctaAvailable": "Actualización disponible", + "update.ctaInstalling": "Instalando...", + "update.restartToApplyVersion": "Reiniciar para aplicar la actualización {version}", + "update.restartToApply": "Reiniciar para aplicar la actualización", + "update.readyToastVersion": "Actualización {version} lista", + "update.readyToast": "Actualización lista", + "update.restartFailed": "No se pudo reiniciar para aplicar la actualización", + "pin.lockedTitle": "Aplicación bloqueada", + "pin.lockedPrompt": "Escribe el PIN para desbloquear", + "pin.placeholder": "PIN de 4 dígitos", + "pin.unlock": "Desbloquear", + "pin.invalid": "PIN inválido", + "pin.tooManyAttempts": "Demasiados intentos. Vuelve a intentarlo en {seconds} s.", + "common.ok": "OK", + "common.cancel": "Cancelar", + "common.confirm": "Confirmar", + "common.close": "Cerrar", + "common.back": "Atrás", + "common.active": "Activa", + "common.choose": "Elegir...", + "common.none": "Ninguno", + "common.custom": "Personalizado", + "common.configured": "Configurado", + "common.missing": "Falta", + "common.enabled": "Habilitado", + "common.disabled": "Deshabilitado", + "common.on": "Activado", + "common.off": "Desactivado", + "common.add": "Añadir", + "common.visible": "Visible", + "common.hidden": "Oculto", + "common.inline": "En línea", + "common.tooltip": "Emergente", + "titlebar.refresh": "Actualizar", + "titlebar.addAccount": "Añadir cuenta", + "titlebar.settings": "Configuración", + "titlebar.personas": "Identidades", + "titlebar.minimize": "Minimizar", + "titlebar.maximize": "Maximizar", + "titlebar.restore": "Restaurar", + "titlebar.close": "Cerrar", + "view.grid": "Vista de cuadrícula", + "view.list": "Vista de lista", + "settings.title": "Configuración", + "settings.general": "General", + "settings.appearance": "Apariencia", + "settings.language": "Idioma", + "settings.theme": "Tema", + "settings.uiZoom": "Zoom de la interfaz", + "settings.backgroundOpacity": "Opacidad del fondo", + "settings.animations": "Animaciones", + "settings.animationsSystem": "Seguir al sistema", + "settings.animationsOn": "Siempre activas", + "settings.animationsOff": "Desactivadas", + "settings.performance": "Rendimiento", + "settings.suspendGraphicsWhenMinimized": "Suspender gráficos al minimizar", + "settings.minimizeOnAccountSwitch": "Minimizar al cambiar de cuenta", + "settings.platforms": "Plataformas", + "settings.platformSearchPlaceholder": "Buscar plataformas...", + "settings.platformSearchNoResults": "Ninguna plataforma coincide con tu búsqueda.", + "settings.accountDisplay": "Visualización de cuentas", + "settings.startupAndExtras": "Inicio", + "settings.defaultOnStartup": "Plataforma predeterminada al iniciar", + "settings.platformDisabledSuffix": "(desactivada)", + "settings.platformUnsupportedOs": "Sistema no compatible", + "settings.platformNotImplemented": "Planeado", + "settings.platformUserProvided": "Añadida desde tu carpeta de descriptores", + "settings.customPlatforms": "Plataformas personalizadas", + "settings.customPlatformsHint": "Los archivos de descriptor de esta carpeta se leen al iniciar la aplicación.", + "settings.customPlatformsNone": "Ningún descriptor cargado desde esta carpeta.", + "settings.customPlatformsProblems": "Archivos que no se cargaron", + "descriptor.addFromFile": "Añadir desde un archivo", + "descriptor.reload": "Recargar", + "descriptor.openFolder": "Abrir carpeta", + "descriptor.remove": "Eliminar", + "descriptor.removeConfirmTitle": "¿Eliminar {platform}?", + "descriptor.removeConfirmMessage": "Su archivo de descriptor sale de tu carpeta. Las cuentas ya capturadas siguen en el disco.", + "descriptor.previewTitle": "Añadir una plataforma", + "descriptor.previewOs": "Descrita para {list}", + "descriptor.previewRoots": "Carpetas que puede tocar", + "descriptor.previewSteps": "Lo que haría un cambio de cuenta", + "descriptor.previewWarnings": "Avisos", + "descriptor.previewNoPlan": "No se pudo crear una simulación: {problem}", + "descriptor.previewReplaces": "Esto reemplaza el descriptor que ya está en tu carpeta.", + "descriptor.previewNothingWritten": "No se escribe nada hasta que lo añadas.", + "descriptor.replace": "Reemplazar", + "descriptor.installed": "{platform} añadida", + "descriptor.removed": "{platform} eliminada", + "descriptor.actionRead": "leer", + "descriptor.actionCapture": "capturar", + "descriptor.actionRestore": "restaurar", + "descriptor.actionDelete": "eliminar", + "descriptor.actionClose": "cerrar", + "descriptor.actionLaunch": "iniciar", + "settings.dataRefresh": "Actualización de datos", + "settings.avatarRefresh": "Actualizar avatares", + "settings.banCheckDelay": "Intervalo de revisión de baneos", + "settings.refreshNow": "Actualizar ahora", + "settings.zeroEachLaunch": "0 = en cada inicio", + "settings.privacy": "Privacidad", + "settings.inactivityTimeout": "Tiempo de inactividad", + "settings.zeroDisabled": "0 = desactivado", + "settings.streamerMode": "Modo streamer", + "settings.streamerModeHint": "Difumina los datos de la cuenta automáticamente cuando hay software de streaming en ejecución.", + "settings.personasHint": "Mostrar el selector de identidades", + "settings.replayOnboarding": "Repetir la introducción", + "settings.replayOnboardingHint": "Vuelve a mostrar el tour de bienvenida y la elección sobre estadísticas de uso.", + "streamer.title": "Modo streamer", + "streamer.disable": "Desactivar", + "streamer.disablePermanently": "Desactivar permanentemente", + "personas.title": "Identidades", + "personas.create": "Nueva identidad", + "personas.activate": "Cambiar a esta identidad", + "personas.edit": "Editar", + "personas.delete": "Eliminar", + "personas.finish": "Listo", + "personas.switching": "Cambiando...", + "personas.switched": "Cambiado a {name}", + "personas.switchPartial": "{name}: falló en {failed}", + "personas.switchFailed": "No se pudo cambiar a {name}", + "personas.namePlaceholder": "Nombre de la identidad", + "personas.choosePlatforms": "Elige una cuenta para cada plataforma a incluir", + "personas.chooseAccount": "Elige la cuenta de {platform}", + "personas.removeFromPersona": "Quitar de esta identidad", + "personas.customImage": "Elegir una imagen...", + "personas.removeImage": "Quitar imagen", + "personas.imageError": "No se pudo cargar esa imagen.", + "personas.loadingAccounts": "Cargando cuentas...", + "personas.noAccountsOnPlatform": "Aún no hay cuentas en esta plataforma", + "personas.emptyHint": "Un clic cambia cada plataforma incluida a su cuenta.", + "settings.security": "Seguridad", + "settings.pinLockOnAfk": "Bloqueo con PIN al ausentarte", + "settings.pinCode": "Código PIN", + "settings.pinPlaceholder": "4 dígitos", + "settings.pinCurrentCode": "PIN actual", + "settings.pinCurrentHint": "Introduce el PIN actual para cambiar o desactivar el bloqueo.", + "settings.telemetry": "Estadísticas de uso (anónimas)", + "settings.telemetryModeA": "Estadísticas básicas anónimas", + "settings.telemetryModeAHint": "Cuenta los inicios y qué funciones se usan. Un ID local aleatorio evita contar instalaciones duplicadas; no se envía ningún dato personal.", + "settings.telemetryModeB": "Estadísticas ampliadas con UUID local (opcional)", + "settings.telemetryModeBHint": "Añade un UUID aleatorio guardado solo en esta máquina. Nos permite medir la retención y qué funciones importan. Tampoco se envía ningún dato personal.", + "settings.telemetryExport": "Exportar mis datos", + "settings.telemetryExported": "Datos exportados al portapapeles", + "settings.telemetryExportFailed": "Error al exportar", + "settings.telemetryEnableFailed": "No se pudieron activar las estadísticas ampliadas", + "settings.telemetryDisableFailed": "No se pudieron desactivar las estadísticas ampliadas", + "settings.telemetryDeletePending": "Las estadísticas ampliadas están desactivadas; el borrado en el servidor necesitará otro intento", + "settings.telemetryDeletePendingHint": "El borrado en el servidor está pendiente. El ID local antiguo se conserva solo para que puedas reintentar el borrado o exportar los datos asociados.", + "settings.telemetryRetryDelete": "Reintentar el borrado de datos", + "settings.telemetryDeleteCompleted": "Datos del servidor eliminados", + "settings.telemetryDeleteRetryFailed": "El borrado en el servidor sigue pendiente", + "settings.telemetryLearnMore": "¿Qué datos se envían?", + "onboarding.welcome.title": "Te damos la bienvenida a Accshift", + "onboarding.welcome.version": "v{version}", + "onboarding.welcome.compatibleWith": "Tu sistema operativo es compatible con:", + "onboarding.welcome.detectedPlatforms": "Estas son las plataformas detectadas en tu equipo:", + "onboarding.welcome.next": "Dame un tour", + "onboarding.welcome.skip": "Omitir", + "onboarding.features.hint": "Esta parte de la app no está visible ahora mismo.", + "onboarding.features.addAccount.label": "Añadir una cuenta", + "onboarding.features.addAccount.body": "El botón + de arriba a la derecha añade una cuenta a la plataforma actual. Inicia sesión una vez y Accshift la guarda.", + "onboarding.features.switch.label": "Cambiar de cuenta", + "onboarding.features.switch.body": "Haz clic en una tarjeta y ya estás en esa cuenta. Sinceramente, eso es toda la app.", + "onboarding.features.contextMenu.label": "Clic derecho para más acciones", + "onboarding.features.contextMenu.body": "Haz clic derecho en una tarjeta para renombrarla, cambiar su color, moverla a una carpeta o eliminarla.", + "onboarding.features.platformSwitch.label": "Cambiar de plataforma", + "onboarding.features.platformSwitch.body": "Lo mismo con Riot, Battle.net, Epic y las demás. Las pestañas de arriba cambian de plataforma.", + "onboarding.features.next": "Siguiente", + "onboarding.features.back": "Atrás", + "onboarding.features.step": "Paso {step}", + "onboarding.features.mockRename": "Renombrar", + "onboarding.features.mockSetColor": "Elegir color", + "onboarding.features.mockMoveToFolder": "Mover a carpeta", + "onboarding.features.mockDelete": "Eliminar", + "onboarding.features.mockAccount": "Cuenta {number}", + "onboarding.telemetry.title": "¡VENDE TU ALMA A ACCSHIFT!", + "onboarding.telemetry.intro": "Accshift cuenta unas pocas estadísticas de uso anónimas para saber qué arreglar primero. Nada personal sale de tu máquina, la app funciona igual de todos modos y puedes cambiar de idea cuando quieras en Configuración.", + "onboarding.telemetry.question": "¿Trato hecho?", + "onboarding.telemetry.gifAlt": "Meme de oferta de intercambio", + "onboarding.telemetry.basic": "¡NO! Ya está bien", + "onboarding.telemetry.basicDefault": "(predeterminado)", + "onboarding.telemetry.basicHint": "Solo contadores anónimos y nada más: sin perfil, sin identificador que te siga.", + "onboarding.telemetry.deal": "TRATO", + "onboarding.telemetry.dealHint": "Añade un UUID aparte para las estadísticas ampliadas, guardado solo en tu máquina. Seguimos sin saber quién eres, pero ayudas muchísimo. ¡Gracias!", + "onboarding.telemetry.optOutNote": "Elijas lo que elijas, puedes cambiarlo luego en Configuración -> Privacidad. :c", + "onboarding.telemetry.refuse": "No gracias, apágalo todo", + "onboarding.telemetry.refuseHint": "No se mide ni se envía nada, ni siquiera los contadores anónimos.", + "onboarding.telemetry.back": "Atrás", + "onboarding.persistFailed": "No se pudo guardar esa elección. Inténtalo de nuevo.", + "onboarding.persistLater": "Preguntarme en el próximo inicio", + "settings.steam": "Steam", + "settings.riot": "Riot", + "settings.battleNet": "Battle.net", + "settings.ubisoft": "Ubisoft Connect", + "settings.runSteamAsAdmin": "Ejecutar Steam como administrador", + "settings.showUsernames": "Mostrar nombres de usuario", + "settings.showSteamLastLogin": "Mostrar última conexión de Steam", + "settings.showRiotLastLogin": "Mostrar última conexión de Riot", + "settings.showBattleNetLastLogin": "Mostrar última conexión de Battle.net", + "settings.showUbisoftLastLogin": "Mostrar última conexión de Ubisoft", + "settings.showNotesUnderCards": "Mostrar notas bajo las tarjetas", + "settings.expandedFolders": "Expandir todas las carpetas", + "list.rootSection": "Raíz", + "settings.steamLaunch": "Cambio", + "settings.steamInstallation": "Instalación", + "settings.steamApiAndData": "API y datos", + "settings.launchOptions": "Opciones de inicio", + "settings.shutdownMode": "Modo de cierre", + "settings.shutdownModeGraceful": "Ordenado", + "settings.shutdownModeForce": "Forzar cierre", + "settings.autoDetected": "Detectado automáticamente", + "settings.steamFolder": "Carpeta de Steam", + "settings.steamFolderRegistry": "Registro", + "settings.riotClientPath": "Ejecutable del cliente de Riot", + "settings.battleNetPath": "Ejecutable de Battle.net", + "settings.ubisoftPath": "Carpeta de Ubisoft Connect", + "settings.steamWebApiKey": "Clave de la API web de Steam", + "settings.getKey": "Obtener clave", + "settings.pasteApiKey": "Pega tu clave de la API web de Steam", + "settings.saved": "Configuración guardada", + "settings.openApiKeyFailed": "No se pudo abrir la página de la clave API de Steam", + "settings.integrations": "Integraciones", + "settings.deepLinks": "Permitir enlaces accshift://", + "settings.deepLinksHint": "Deja que otras apps cambien de cuenta por ti: marcadores, Stream Deck, scripts.", + "settings.cliEnabled": "Permitir la CLI de accshift", + "settings.cliEnabledHint": "Deja que la herramienta de línea de comandos liste y cambie de cuenta. Desactivado, todos los comandos accshift se niegan a ejecutarse, diag incluido; la aplicación en sí no se ve afectada.", + "settings.help": "Abrir la wiki de accshift", + "settings.helpShort": "Ayuda", + "settings.openHelpFailed": "No se pudo abrir la wiki", + "settings.logs": "Registros", + "settings.openLogs": "Abrir la carpeta de registros", + "settings.openLogsFailed": "No se pudo abrir la carpeta de registros", + "context.loading": "Cargando...", + "context.noGamesFound": "No se encontraron juegos", + "context.menu.newFolder": "Nueva carpeta", + "context.menu.refresh": "Actualizar", + "context.menu.rename": "Renombrar", + "context.menu.copy": "Copiar...", + "context.menu.deleteFolder": "Eliminar carpeta", + "context.menu.folderColor": "Color de la carpeta", + "context.menu.moveToFolder": "Mover a una carpeta", + "context.menu.moveToRoot": "Sin carpeta", + "context.menu.editCardAndColor": "Editar tarjeta y color", + "context.menu.cardColor": "Color de la tarjeta", + "context.menu.addNote": "Añadir nota", + "context.menu.editNote": "Editar nota", + "platform.rename": "Renombrar", + "platform.renameTitle": "Renombrar cuenta", + "platform.renamePlaceholder": "Nombre de la cuenta", + "platform.newAccountPending": "Esperando...", + "platform.setupBusy": "Hay otra operación de accshift en curso. Esperando a que termine.", + "platform.renameNewAccount": "Ponle nombre a esta cuenta", + "platform.copyLabelAccountId": "ID de cuenta", + "platform.forget": "Olvidar", + "platform.forgetConfirmTitle": "¿Olvidar \"{display}\"?", + "platform.forgetConfirmMessage": "Esto quitará esta cuenta y su sesión guardada de Accshift.", + "platform.forgotAccount": "{display} olvidada", + "dialog.newFolderTitle": "Nueva carpeta", + "dialog.renameFolderTitle": "Renombrar carpeta", + "dialog.folderNamePlaceholder": "Nombre de la carpeta", + "dialog.cardNoteTitle": "Nota de la tarjeta", + "dialog.cardNotePlaceholder": "Escribe una nota breve", + "card.noteAttached": "Nota adjunta", + "card.clickAgainToSwitch": "Haz clic otra vez para cambiar", + "card.extensionWarnings": "Advertencias", + "card.extensionNote": "Nota", + "card.cs2Section": "CS2", + "card.cs2Level": "Nivel {level}", + "card.cs2CaseEarned": "Caja semanal obtenida", + "card.cs2CaseNotEarned": "Caja semanal pendiente", + "list.selectAccountPreview": "Selecciona una cuenta para verla", + "preview.noteLabel": "Nota", + "preview.currentlyActive": "Activa actualmente", + "preview.switchAgain": "Cambiar de nuevo", + "preview.switchAccount": "Cambiar de cuenta", + "ban.community": "Baneo de comunidad", + "ban.vac.single": "{count} baneo VAC", + "ban.vac.multiple": "{count} baneos VAC", + "ban.game.single": "{count} baneo de juego", + "ban.game.multiple": "{count} baneos de juego", + "time.unknown": "desconocido", + "time.justNow": "ahora mismo", + "time.minuteAgo": "hace {count} minuto", + "time.minutesAgo": "hace {count} minutos", + "time.hourAgo": "hace {count} hora", + "time.hoursAgo": "hace {count} horas", + "time.dayAgo": "hace {count} día", + "time.daysAgo": "hace {count} días", + "time.neverConnected": "nunca conectado", + "time.compactWrapper": "({value})", + "toast.copied": "{label} copiado", + "toast.legacyConfigMigrated": "Configuración migrada al nuevo formato", + "toast.legacyConfigMigrationFailed": "No se pudo migrar la configuración antigua, revisa los registros", + "toast.failedLoadSettingsPanel": "No se pudo cargar el panel de configuración", + "toast.banChecking": "Comprobando baneos...", + "toast.banCheckSummary.single": "Revisión de baneos: {count} cuenta con baneos", + "toast.banCheckSummary.multiple": "Revisión de baneos: {count} cuentas con baneos", + "toast.banCheckFailed": "Falló la revisión de baneos: {error}", + "toast.avatarRefreshComplete": "Actualización de avatares terminada para {count} cuentas", + "toast.banRefreshComplete": "Actualización de baneos terminada para {count} cuentas", + "toast.banRefreshNoApiKey": "Actualización de baneos omitida: añade una clave de Steam Web API en los ajustes", + "toast.refreshPartial": "Actualización terminada para {ok} de {count} cuentas", + "toast.noSteamAccountsFound": "No se encontraron cuentas de Steam. Inicia sesión en Steam al menos una vez y actualiza.", + "toast.accountsRefreshed.single": "{count} cuenta actualizada", + "toast.accountsRefreshed.multiple": "{count} cuentas actualizadas", + "toast.steamFolderNotFound": "No se encontró la carpeta de Steam. Configúrala manualmente en Configuración > Carpeta de Steam.", + "toast.steamElevated": "Steam se está ejecutando como administrador. Activa 'Ejecutar Steam como administrador' en Configuración > Steam.", + "toast.noRiotProfilesFound": "Aún no hay perfiles de Riot. Haz clic en + para añadir uno.", + "toast.noBattleNetAccountsFound": "No se encontraron cuentas de Battle.net. Inicia sesión en Battle.net al menos una vez y actualiza.", + "toast.noUbisoftAccountsFound": "No se encontraron cuentas de Ubisoft. Inicia sesión en Ubisoft Connect al menos una vez y actualiza.", + "toast.deepLinkInvalid": "Enlace accshift:// no reconocido", + "toast.deepLinkDisabled": "Los enlaces accshift:// están desactivados en Configuración", + "toast.deepLinkUnknownPlatform": "Plataforma desconocida en el enlace: {platform}", + "toast.deepLinkPlatformDisabled": "{platform} está desactivado en Configuración", + "toast.deepLinkAccountNotFound": "Ninguna cuenta de {platform} coincide con \"{account}\"", + "toast.deepLinkSwitched": "Cambiado a {account}", + "dialog.deepLinkSwitchTitle": "¿Cambiar de cuenta?", + "dialog.deepLinkSwitchMessage": "Un enlace accshift:// pide cambiar tu cuenta de {platform} a \"{account}\". ¿Continuar?", + "dialog.deepLinkSwitchConfirm": "Cambiar", + "steam.launchOnline": "Cambiar en línea", + "steam.launchInvisible": "Cambiar invisible", + "steam.switchAndRunGame": "Cambiar + iniciar {game}", + "steam.switchAndRunPick": "Cambiar + iniciar un juego...", + "steam.runGame": "Iniciar {game}", + "steam.runPick": "Iniciar un juego...", + "steam.switchAndRunNoGames": "No se encontraron juegos", + "steam.clearDefaultGame": "Quitar el juego predeterminado", + "steam.copySteamId64": "Copiar SteamID64", + "steam.copyFriendCode": "Copiar código de amigo", + "steam.copyProfileUrl": "Copiar URL del perfil", + "steam.copyLabelUsername": "Nombre de usuario", + "steam.copyLabelSteamId64": "SteamID64", + "steam.copyLabelFriendCode": "Código de amigo", + "steam.copyLabelProfileUrl": "URL del perfil", + "steam.openUserdataFolder": "Abrir la carpeta userdata", + "steam.clearIntegratedBrowserCache": "Reiniciar la sesión del navegador", + "steam.clearIntegratedBrowserCacheConfirmTitle": "¿Reiniciar la sesión del navegador de Steam?", + "steam.clearIntegratedBrowserCacheConfirmMessage": "Esto cierra Steam y borra la caché del navegador integrado que usan la Tienda y la Comunidad.", + "steam.clearedIntegratedBrowserCache": "Sesión del navegador de Steam reiniciada", + "steam.copySettingsFrom": "Copiar la configuración de", + "steam.copiedSettingsToCurrent": "Configuración de {game} copiada a la cuenta actual", + "steam.forget": "Olvidar", + "steam.forgetConfirmTitle": "¿Olvidar \"{display}\"?", + "steam.forgetConfirmMessage": "Esto quitará esta cuenta de tu lista de cuentas de Steam en este PC.", + "steam.forgotAccount": "{username} olvidada", + "steam.setupPendingLabel": "Cuenta de Steam", + "steam.setupWaitingForClient": "Esperando a Steam...", + "steam.setupWaitingForLogin": "Inicia sesión en la cuenta de Steam que quieres añadir.", + "steam.setupDetected": "Cuenta detectada", + "steam.setupConnected": "Conectado", + "steam.setupReady": "Cuenta de Steam añadida", + "steam.setupReadyWithProfile": "{profile} añadido", + "steam.setupFailed": "Falló la configuración de Steam", + "steam.setupFailedMessage": "Accshift no pudo terminar de añadir esta cuenta de Steam.", + "riot.captureSession": "Capturar la sesión actual", + "riot.recaptureSession": "Volver a capturar la sesión", + "riot.capturedSession": "Sesión capturada para {profile}", + "riot.setupPendingLabel": "Cuenta de Riot", + "riot.setupWaitingForClient": "Esperando al Riot Client...", + "riot.setupWaitingForLogin": "Inicia sesión en la cuenta de Riot que quieres añadir.", + "riot.setupCapturing": "Guardando la sesión de Riot...", + "riot.setupReady": "Cuenta de Riot añadida", + "riot.setupReadyWithProfile": "{profile} añadido", + "riot.setupFailed": "Falló la configuración de Riot", + "riot.setupFailedMessage": "Accshift no pudo terminar de añadir esta cuenta de Riot.", + "riot.setupStaySignedIn": "Mantén activada la opción 'Stay signed in'.", + "riot.setupDetected": "Cuenta detectada", + "riot.setupConnected": "Conectado", + "riot.forget": "Olvidar cuenta", + "riot.forgetConfirmTitle": "¿Olvidar \"{display}\"?", + "riot.forgetConfirmMessage": "Esto elimina el perfil local de Riot y su sesión guardada de Accshift.", + "riot.forgotProfile": "{profile} olvidado", + "battlenet.copyUsername": "Copiar nombre de usuario", + "battlenet.copyLabelUsername": "Nombre de usuario", + "battlenet.copyEmail": "Copiar correo", + "battlenet.copyLabelEmail": "Correo", + "battlenet.forget": "Olvidar", + "battlenet.forgetConfirmTitle": "¿Olvidar \"{display}\"?", + "battlenet.forgetConfirmMessage": "Esto quitará esta cuenta de la lista de cuentas de Battle.net en este PC.", + "battlenet.forgotAccount": "{email} olvidada", + "battlenet.setupPendingLabel": "Cuenta de Battle.net", + "battlenet.setupWaitingForClient": "Esperando a Battle.net...", + "battlenet.setupWaitingForLogin": "Inicia sesión en la cuenta de Battle.net que quieres añadir.", + "battlenet.setupKeepMeLoggedIn": "Mantén activada la opción 'Keep me logged in' cuando sea posible.", + "battlenet.setupDetected": "Cuenta detectada", + "battlenet.setupConnected": "Conectado", + "battlenet.setupReady": "Cuenta de Battle.net añadida", + "battlenet.setupReadyWithProfile": "{profile} añadido", + "battlenet.setupFailed": "Falló la configuración de Battle.net", + "battlenet.setupFailedMessage": "Accshift no pudo terminar de añadir esta cuenta de Battle.net.", + "ubisoft.copyLabelUuid": "UUID", + "ubisoft.forget": "Olvidar", + "ubisoft.forgetConfirmTitle": "¿Olvidar \"{display}\"?", + "ubisoft.forgetConfirmMessage": "Esto quitará esta cuenta y su sesión guardada de Accshift.", + "ubisoft.forgotAccount": "{display} olvidada", + "ubisoft.setupPendingLabel": "Cuenta de Ubisoft", + "ubisoft.setupWaitingForClient": "Esperando a Ubisoft Connect...", + "ubisoft.setupWaitingForLogin": "Inicia sesión en la cuenta de Ubisoft que quieres añadir.", + "ubisoft.setupKeepMeLoggedIn": "Mantén activada la opción 'Remember me'.", + "ubisoft.setupDetected": "Cuenta detectada", + "ubisoft.setupConnected": "Conectado", + "ubisoft.setupReady": "Cuenta de Ubisoft añadida", + "ubisoft.setupReadyWithProfile": "{profile} añadido", + "ubisoft.setupFailed": "Falló la configuración de Ubisoft", + "ubisoft.setupFailedMessage": "Accshift no pudo terminar de añadir esta cuenta de Ubisoft.", + "settings.roblox": "Roblox", + "toast.noRobloxAccountsFound": "No se encontraron cuentas de Roblox. Añade una cuenta con Quick Login o pega una cookie.", + "roblox.noAccountsHint": "Pulsa + para añadir una cuenta de Roblox con Quick Login.", + "roblox.sessionExpired": "Sesión caducada", + "roblox.sessionExpiredTooltip": "Esta cuenta cerró sesión. Vuelve a añadirla antes de cambiar.", + "roblox.sessionExpiredSwitchError": "Sesión caducada. Vuelve a añadir esta cuenta para cambiar. Las notas y la configuración se conservan.", + "roblox.cookiePasteTitle": "Añadir cuenta con cookie", + "roblox.cookiePasteHint": "Pega una cookie .ROBLOSECURITY de tu navegador para añadir una cuenta directamente.", + "settings.showRobloxLastLogin": "Mostrar última conexión", + "settings.robloxHealthCheck": "Comprobación de sesión", + "settings.robloxHealthCheckHint": "Marca las cuentas cuya sesión de Roblox haya caducado; una petición de red por inicio.", + "roblox.copyLabelUsername": "Nombre de usuario", + "roblox.copyLabelUserId": "ID de usuario", + "roblox.forget": "Olvidar", + "roblox.forgetConfirmTitle": "¿Olvidar \"{display}\"?", + "roblox.forgetConfirmMessage": "Esto quitará esta cuenta y su cookie guardada de este PC.", + "roblox.forgotAccount": "{display} olvidada", + "roblox.setupPendingLabel": "Cuenta de Roblox", + "roblox.setupWaitingForClient": "Conectando con Roblox...", + "roblox.setupWaitingForLogin": "Abre la app de Roblox en otro dispositivo e introduce el código de abajo.", + "roblox.setupQuickLoginCode": "Código de Quick Login", + "roblox.setupQuickLoginHint": "roblox.com/quick-login", + "roblox.copyCode": "Copiar código", + "roblox.setupDetected": "Cuenta detectada", + "roblox.setupConnected": "Conectado", + "roblox.setupReady": "Cuenta de Roblox añadida", + "roblox.setupReadyWithProfile": "{profile} añadido", + "roblox.setupFailed": "Falló la configuración de Roblox", + "roblox.setupFailedMessage": "Accshift no pudo terminar de añadir esta cuenta de Roblox.", + "settings.epic": "Epic Games", + "settings.epicPath": "Ejecutable de Epic Games Launcher", + "settings.showEpicLastLogin": "Mostrar última conexión", + "toast.noEpicAccountsFound": "No se encontraron cuentas de Epic Games. Inicia sesión en Epic Games Launcher al menos una vez y actualiza.", + "epic.noAccountsHint": "Pulsa + para añadir una cuenta de Epic Games.", + "epic.copyLabelAccountId": "ID de cuenta", + "epic.forget": "Olvidar", + "epic.forgetConfirmTitle": "¿Olvidar \"{display}\"?", + "epic.forgetConfirmMessage": "Esto quitará esta cuenta y su sesión guardada de Accshift.", + "epic.forgotAccount": "{display} olvidada", + "epic.setupPendingLabel": "Cuenta de Epic Games", + "epic.setupWaitingForClient": "Esperando a Epic Games Launcher...", + "epic.setupWaitingForLogin": "Inicia sesión en la cuenta de Epic Games que quieres añadir.", + "epic.setupKeepMeLoggedIn": "Mantén activada la opción 'Remember me'.", + "epic.setupDetected": "Cuenta detectada", + "epic.setupConnected": "Conectado", + "epic.setupReady": "Cuenta de Epic Games añadida", + "epic.setupReadyWithProfile": "{profile} añadido", + "epic.setupFailed": "Falló la configuración de Epic Games", + "epic.setupFailedMessage": "Accshift no pudo terminar de añadir esta cuenta de Epic Games.", + "settings.gog": "GOG Galaxy", + "settings.gogPath": "Ejecutable de GOG Galaxy", + "settings.showGogLastLogin": "Mostrar última conexión", + "toast.noGogAccountsFound": "No se encontraron cuentas de GOG Galaxy. Inicia sesión en GOG Galaxy al menos una vez y actualiza.", + "gog.noAccountsHint": "Pulsa + para añadir una cuenta de GOG Galaxy.", + "gog.copyLabelAccountId": "ID de cuenta", + "gog.forget": "Olvidar", + "gog.forgetConfirmTitle": "¿Olvidar \"{display}\"?", + "gog.forgetConfirmMessage": "Esto quitará esta cuenta y su sesión guardada de Accshift.", + "gog.forgotAccount": "{display} olvidada", + "gog.setupPendingLabel": "Cuenta de GOG Galaxy", + "gog.setupWaitingForClient": "Esperando a GOG Galaxy...", + "gog.setupWaitingForLogin": "Inicia sesión en la cuenta de GOG Galaxy que quieres añadir.", + "gog.setupKeepMeLoggedIn": "Mantén activada la opción 'Stay signed in'.", + "gog.setupDetected": "Cuenta detectada", + "gog.setupConnected": "Conectado", + "gog.setupReady": "Cuenta de GOG Galaxy añadida", + "gog.setupReadyWithProfile": "{profile} añadido", + "gog.setupFailed": "Falló la configuración de GOG Galaxy", + "gog.setupFailedMessage": "Accshift no pudo terminar de añadir esta cuenta de GOG Galaxy.", + "settings.jagex": "Jagex Launcher", + "settings.jagexPath": "Ejecutable de Jagex Launcher", + "settings.showJagexLastLogin": "Mostrar última conexión", + "toast.noJagexAccountsFound": "Aún no hay cuentas de Jagex guardadas. Pulsa + para añadir una con el Jagex Launcher.", + "jagex.noAccountsHint": "Pulsa + para añadir una cuenta de Jagex.", + "jagex.copyLabelAccountId": "ID de cuenta", + "jagex.forget": "Olvidar", + "jagex.forgetConfirmTitle": "¿Olvidar \"{display}\"?", + "jagex.forgetConfirmMessage": "Esto quitará esta cuenta y su sesión guardada de Accshift.", + "jagex.forgotAccount": "{display} olvidada", + "jagex.setupPendingLabel": "Cuenta de Jagex", + "jagex.setupWaitingForClient": "Esperando al Jagex Launcher...", + "jagex.setupWaitingForLogin": "Inicia sesión en la cuenta de Jagex que quieres añadir.", + "jagex.setupKeepMeLoggedIn": "Mantén activada la opción 'Remember me'.", + "jagex.setupDetected": "Cuenta detectada", + "jagex.setupConnected": "Conectado", + "jagex.setupReady": "Cuenta de Jagex añadida", + "jagex.setupReadyWithProfile": "{profile} añadido", + "jagex.setupFailed": "Falló la configuración de Jagex", + "jagex.setupFailedMessage": "Accshift no pudo terminar de añadir esta cuenta de Jagex.", + "settings.discord": "Discord", + "settings.discordPath": "Ejecutable de Discord", + "settings.showDiscordLastLogin": "Mostrar última conexión", + "toast.noDiscordAccountsFound": "No se encontraron cuentas de Discord. Añade una cuenta de Discord y actualiza.", + "discord.noAccountsHint": "Pulsa + para añadir una cuenta de Discord.", + "discord.copyLabelAccountId": "ID de cuenta", + "discord.forget": "Olvidar", + "discord.forgetConfirmTitle": "¿Olvidar \"{display}\"?", + "discord.forgetConfirmMessage": "Esto quitará esta cuenta y su sesión guardada de Accshift.", + "discord.forgotAccount": "{display} olvidada", + "discord.setupPendingLabel": "Cuenta de Discord", + "discord.setupWaitingForClient": "Esperando a Discord...", + "discord.setupWaitingForLogin": "Inicia sesión en la cuenta de Discord que quieres añadir.", + "discord.setupKeepMeLoggedIn": "Mantén la sesión iniciada para conservar esta cuenta.", + "discord.setupDetected": "Cuenta detectada", + "discord.setupConnected": "Conectado", + "discord.setupReady": "Cuenta de Discord añadida", + "discord.setupReadyWithProfile": "{profile} añadido", + "discord.setupFailed": "Falló la configuración de Discord", + "discord.setupFailedMessage": "Accshift no pudo terminar de añadir esta cuenta de Discord.", + "color.none": "Predeterminado", + "color.blue": "Azul", + "color.cyan": "Cian", + "color.green": "Verde", + "color.lime": "Lima", + "color.yellow": "Amarillo", + "color.orange": "Naranja", + "color.red": "Rojo", + "color.pink": "Rosa", + "color.violet": "Violeta", + "color.gray": "Gris", + "language.english": "English", + "language.french": "Français", + "language.spanish": "Español", + "language.portuguese": "Português (Portugal)", + "language.portugueseBrazil": "Português (Brasil)", + "language.russian": "Русский", + "language.chinese": "简体中文", + "theme.dark": "Oscuro", + "theme.light": "Claro", + "theme.midnight": "Medianoche", + "theme.glassDark": "Cristal oscuro", + "theme.glassLight": "Cristal claro", + "theme.liquidGlass": "Cristal líquido", + "theme.custom": "Personalizado", + "settings.themeImport": "Importar", + "settings.themeExport": "Exportar", + "settings.themeDelete": "Eliminar tema", + "settings.themeImported": "Tema importado", + "settings.themeDeleted": "Tema eliminado", + "settings.themeExportCopied": "JSON del tema copiado al portapapeles", + "settings.themeCustomize": "Personalizar", + "settings.themeEdit": "Editar", + "settings.themeCopyName": "{name} copia", + "settings.themeSaved": "Tema guardado", + "settings.themeMigrated": "Tema importado y actualizado al formato actual", + "settings.themeInvalidId": "El identificador del tema falta o no es válido", + "settings.themeInvalidName": "El tema no tiene nombre", + "settings.themeUnsupportedVersion": "Este tema necesita una versión más reciente de accshift", + "themeGroup.surface": "Superficies", + "themeGroup.text": "Texto", + "themeGroup.semantic": "Colores de estado", + "themeGroup.shape": "Forma y densidad", + "themeGroup.typography": "Tipografía", + "themeGroup.motion": "Movimiento", + "themeKind.rgbTriplet": "triplete RGB", + "themeKind.hexColor": "color hexadecimal", + "themeKind.color": "color", + "themeKind.length": "longitud", + "themeKind.shadow": "sombra", + "themeKind.choice": "opción", + "themeKind.fontStack": "fuente", + "themeKind.signedLength": "espaciado", + "themeKind.gradient": "degradado", + "themeKind.number": "número", + "themeDensity.compact": "Compacta", + "themeDensity.cozy": "Normal", + "themeDensity.comfortable": "Amplia", + "themeAvatarShape.circle": "Redondo", + "themeAvatarShape.rounded": "Redondeado", + "themeAvatarShape.square": "Cuadrado", + "themeSmoothing.auto": "Suavizado", + "themeSmoothing.none": "Píxel", + "themeToken.bgRgb": "Fondo de la ventana", + "themeToken.bgCard": "Fondo de las tarjetas", + "themeToken.bgCardHover": "Fondo de las tarjetas al pasar el cursor", + "themeToken.bgMuted": "Fondo atenuado", + "themeToken.bgElevated": "Fondo elevado", + "themeToken.border": "Bordes", + "themeToken.fg": "Texto principal", + "themeToken.fgMuted": "Texto secundario", + "themeToken.fgSubtle": "Texto sutil", + "themeToken.afkText": "Etiqueta de ausente", + "themeToken.accent": "Acento", + "themeToken.accentFg": "Texto sobre el acento", + "themeToken.success": "Éxito", + "themeToken.warning": "Advertencia", + "themeToken.danger": "Peligro", + "themeToken.radiusSm": "Radio pequeño", + "themeToken.radiusMd": "Radio medio", + "themeToken.radiusLg": "Radio grande", + "themeToken.elevationLow": "Sombra suave", + "themeToken.elevationMedium": "Sombra media", + "themeToken.elevationHigh": "Sombra fuerte", + "themeToken.density": "Densidad", + "themeToken.fontUi": "Fuente de la interfaz", + "themeToken.bgImage": "Degradado de la ventana", + "themeToken.cardBgImage": "Degradado de las superficies", + "themeToken.borderWidth": "Grosor de los bordes", + "themeToken.borderStyle": "Estilo de los bordes", + "themeToken.avatarShape": "Forma de los avatares", + "themeToken.focusRing": "Contorno de foco", + "themeToken.overlayBlur": "Desenfoque del fondo", + "themeToken.fontDisplay": "Fuente de los títulos", + "themeToken.lineHeight": "Altura de línea", + "themeToken.letterSpacing": "Espaciado entre letras", + "themeToken.labelCase": "Mayúsculas de las etiquetas", + "themeToken.fontSmoothing": "Suavizado del texto", + "themeToken.motionScale": "Velocidad de las animaciones", + "themeEditor.title": "Editor de temas", + "themeEditor.name": "Nombre", + "themeEditor.author": "Autor", + "themeEditor.version": "Versión", + "themeEditor.base": "Hereda de", + "themeEditor.colorScheme": "Modo de color", + "themeEditor.glass": "Superficies translúcidas", + "themeEditor.inherited": "Heredado", + "themeEditor.builtInBase": "Tema integrado", + "themeEditor.reset": "Volver al valor heredado", + "themeEditor.issues": "Comprobaciones", + "themeEditor.noIssues": "Nada que señalar", + "themeEditor.fixErrorsFirst": "Corrige los valores rechazados antes de guardar", + "themeEditor.save": "Guardar", + "themeEditor.saveFailed": "No se pudo guardar el tema. Inténtalo de nuevo.", + "themeEditor.export": "Copiar", + "themeEditor.issueInvalidValue": "{token}: {kind} no válido, por ejemplo {example}", + "themeEditor.issueUnknownToken": "{token} no es un valor de tema, se ignora", + "themeEditor.issueMissingToken": "{token} no está definido, se usa el valor integrado", + "themeEditor.issueUnknownBase": "El tema base {base} no existe, se usan los valores integrados", + "themeEditor.issueContrast": "{token} sobre {against}: {ratio} a 1, por debajo del objetivo de {target} a 1", + "themeEditor.beta": "Beta", + "themeEditor.betaNote": "El formato de tema aún cambia: un tema escrito hoy puede necesitar un ajuste tras una actualización.", + "themeEditor.customCss": "CSS personalizado", + "themeEditor.customCssHelp": "Se aplica sobre el tema, para lo que los valores de arriba no alcanzan. Sin @import ni url(): un tema nunca descarga nada.", + "themeEditor.issueUnsafeCss": "{construct} no está permitido en un tema, el CSS no se aplica", + "themeEditor.issueCssTooLong": "CSS demasiado largo: {length} caracteres, el límite es {limit}", + "settings.themeInvalidJson": "JSON de tema inválido", + "bulkEdit.title": "Edición masiva", + "bulkEdit.selectAccounts": "Seleccionar cuentas", + "bulkEdit.selectAll": "Seleccionar todo", + "bulkEdit.deselectAll": "Deseleccionar todo", + "bulkEdit.next": "Siguiente", + "bulkEdit.apply": "Aplicar", + "bulkEdit.settings": "Configuración", + "bulkEdit.newsPopup": "Ventana de noticias del juego", + "bulkEdit.doNotDisturb": "No molestar", + "bulkEdit.launchOptions": "Opciones de inicio por juego", + "bulkEdit.unchanged": "Sin cambios", + "bulkEdit.addLaunchOption": "Añadir", + "bulkEdit.selectGame": "Selecciona un juego...", + "bulkEdit.launchOptionsPlaceholder": "-fullscreen", + "bulkEdit.activeAccountWarning": "La cuenta activa está seleccionada. Los cambios se aplicarán al reiniciar Steam.", + "bulkEdit.toastSuccess": "Edición masiva: {count} con éxito", + "bulkEdit.toastPartial": "Edición masiva: {succeeded} con éxito, {failed} con error", + "bulkEdit.toastFailed": "Falló la edición masiva", + "bulkEdit.noAccountsSelected": "Ninguna cuenta seleccionada", + "bulkEdit.selected": "{count} seleccionadas", + "bulkEdit.loadingGames": "Cargando juegos...", + "bulkEdit.copyUrls": "Copiar URL", + "bulkEdit.urlsCopied": "{count} URL de perfil copiada(s)", + "bulkEdit.cardColor": "Color de tarjeta", + "bulkEdit.colorApplied": "Color aplicado a {count} tarjeta(s)", + "bulkEdit.colorCleared": "Color eliminado de {count} tarjeta(s)", + "common.retry": "Reintentar", + "app.searchNoResults": "Sin resultados para \"{query}\"", + "app.folderEmpty": "Esta carpeta está vacía", + "app.loadErrorGeneric": "No se pudieron cargar las cuentas. Consulta la consola para más detalles.", + "toast.dismiss": "Descartar", + "toast.switchFailed": "Falló el cambio de cuenta. Consulta la consola para más detalles.", + "toast.addAccountFailed": "No se pudo añadir la cuenta. Revisa los registros de la app para más detalles.", + "toast.addAccountClientMissing": "{platform} no parece estar instalado. Instálalo o indica su ubicación en Configuración.", + "toast.banCheckFailedGeneric": "Falló la revisión de baneos. Consulta la consola para más detalles.", + "toast.renameAccountFailed": "Falló el cambio de nombre: {error}", + "toast.copyFailed": "No se pudo copiar al portapapeles", + "toast.refreshFailed": "Falló la actualización. Consulta la consola para más detalles.", + "dialog.deleteFolderTitle": "¿Eliminar \"{name}\"?", + "dialog.deleteFolderMessage": "Las subcarpetas también se eliminan. Las cuentas que hay dentro suben un nivel, no se borran.", + "personas.deleteConfirm": "Haz clic de nuevo para eliminar", + "personas.discardConfirm": "Haz clic de nuevo para descartar", + "personas.switchConfirmTitle": "Cambiar de identidad", + "personas.switchConfirmMessage": "¿Cambiar {count} plataforma(s) a \"{name}\"? Cada cliente se cerrará y se reiniciará.", + "personas.switchConfirmAction": "Cambiar", + "personas.deleteTitle": "Eliminar identidad", + "personas.deleteMessage": "¿Eliminar \"{name}\"? Las cuentas en sí no se tocan.", + "context.submenuError": "No se pudo cargar este menú", + "settings.apiKeySaveFailed": "No se pudo guardar la clave API de Steam", + "settings.apiKeyInvalidHint": "La clave no se guardó. Revísala e inténtalo de nuevo.", + "settings.apiKeyCleared": "Clave API de Steam borrada", + "settings.apiKeyClearFailed": "No se pudo borrar la clave API de Steam", + "settings.clearApiKey": "Borrar clave", + "settings.cs2Bridge": "Puente de estadísticas de CS2", + "settings.cs2BridgeLink": "Enlace del puente", + "settings.cs2BridgeLinkPlaceholder": "https://tu-servidor/api/bridge/accshift/", + "settings.cs2BridgeTest": "Probar", + "settings.cs2BridgeStatusOk": "Conectado · {count} cuentas · {ms} ms", + "settings.cs2BridgeStatusFail": "Falló la conexión", + "settings.cs2BridgeStatusTesting": "Probando...", + "settings.cs2BridgeStatusUnknown": "Sin probar", + "settings.cs2BridgeToken": "Token de API", + "settings.cs2BridgeTokenOptional": "opcional, para servidores propios", + "settings.cs2BridgeTokenPlaceholder": "Token Bearer si tu servidor lo necesita", + "settings.cs2BridgeSaveFailed": "No se pudo guardar la configuración del puente CS2", + "settings.cs2BridgeClearToken": "Borrar token", + "settings.pathSaveFailed": "No se pudo guardar la ruta de {platform}", + "settings.pathInvalidHint": "No se pudo guardar esta ruta. Revísala e inténtalo de nuevo.", + "settings.pathSteamNeverSignedIn": "Steam encontrado, inicia sesión una vez para que cree su historial de inicio de sesión", + "settings.pathNotSteamFolder": "Esta carpeta no parece una instalación de Steam", + "settings.pathNotADirectory": "La ruta debe ser una carpeta existente", + "settings.pinSaved": "PIN guardado", + "settings.pinTakesEffectNextLaunch": "Se aplica la próxima vez que se abra la app", + "settings.pinRequiredAfterInactivity": "Obligatorio tras la inactividad y al abrir la app", + "settings.pinScreenLockOnly": "El PIN bloquea esta ventana y los cambios de cuenta. No protege nada en el disco.", + "settings.unitSeconds": "segundos", + "settings.unitDays": "días", + "settings.telemetryLoadFailed": "No se pudo cargar la configuración de telemetría", + "palette.placeholder": "Escribe un comando o el nombre de una cuenta...", + "palette.noResults": "Ningún comando coincide", + "palette.sectionAccounts": "Cuentas", + "palette.sectionActions": "Acciones", + "palette.sectionNavigation": "Navegación", + "palette.openTab": "Ir a {platform}", + "palette.openFolder": "Abrir la carpeta \"{name}\"", + "palette.backToParent": "Volver a la carpeta superior", + "palette.zoomReset": "Restablecer el zoom" +} diff --git a/crates/accshift-app/i18n/fr.json b/crates/accshift-app/i18n/fr.json new file mode 100644 index 0000000..e23efe9 --- /dev/null +++ b/crates/accshift-app/i18n/fr.json @@ -0,0 +1,741 @@ +{ + "app.loadingSettings": "Chargement des paramètres...", + "app.loading": "Chargement...", + "app.searchPlaceholder": "Rechercher un compte...", + "app.noAccountsFound": "Aucun compte {platform} trouvé", + "app.noAccountsHint": "Vérifie que {platform} est installé et que tu t'es connecté au moins une fois.", + "app.switchingAccount": "Changement de compte...", + "app.platformRestarting": "{platform} redémarre", + "app.comingSoon": "{platform} - Bientôt disponible", + "app.platformUnsupportedOs": "{platform} n'est pas encore disponible sur cet OS", + "app.noCompatiblePlatforms": "Aucune plateforme compatible n'est disponible sur cet OS", + "app.noCompatiblePlatformsHint": "Vérifie les mises à jour de support Windows ou utilise une machine compatible.", + "update.ctaAvailable": "Mise à jour dispo", + "update.ctaInstalling": "Installation...", + "update.restartToApplyVersion": "Redémarrer pour appliquer la mise à jour {version}", + "update.restartToApply": "Redémarrer pour appliquer la mise à jour", + "update.readyToastVersion": "Mise à jour {version} prête", + "update.readyToast": "Mise à jour prête", + "update.restartFailed": "Impossible de redémarrer pour appliquer la mise à jour", + "pin.lockedTitle": "Application verrouillée", + "pin.lockedPrompt": "Entre le PIN pour déverrouiller", + "pin.placeholder": "PIN 4 chiffres", + "pin.unlock": "Déverrouiller", + "pin.invalid": "PIN invalide", + "pin.tooManyAttempts": "Trop de tentatives. Réessayez dans {seconds} s.", + "common.ok": "OK", + "common.cancel": "Annuler", + "common.confirm": "Confirmer", + "common.close": "Fermer", + "common.back": "Retour", + "common.active": "Actif", + "common.choose": "Choisir...", + "common.none": "Aucun", + "common.custom": "Personnalisé", + "common.configured": "Configuré", + "common.missing": "Manquant", + "common.enabled": "Activé", + "common.disabled": "Désactivé", + "common.on": "Oui", + "common.off": "Non", + "common.add": "Ajouter", + "common.visible": "Visible", + "common.hidden": "Masqué", + "common.inline": "Sous la carte", + "common.tooltip": "Infobulle", + "titlebar.refresh": "Rafraîchir", + "titlebar.addAccount": "Ajouter un compte", + "titlebar.settings": "Paramètres", + "titlebar.personas": "Personas", + "titlebar.minimize": "Réduire", + "titlebar.maximize": "Agrandir", + "titlebar.restore": "Restaurer", + "titlebar.close": "Fermer", + "view.grid": "Vue grille", + "view.list": "Vue liste", + "settings.title": "Paramètres", + "settings.general": "Général", + "settings.appearance": "Apparence", + "settings.language": "Langue", + "settings.theme": "Thème", + "settings.uiZoom": "Zoom UI", + "settings.backgroundOpacity": "Opacité du fond", + "settings.animations": "Animations", + "settings.animationsSystem": "Suivre le système", + "settings.animationsOn": "Toujours actives", + "settings.animationsOff": "Désactivées", + "settings.performance": "Performance", + "settings.suspendGraphicsWhenMinimized": "Couper le rendu quand minimisé", + "settings.minimizeOnAccountSwitch": "Minimiser lors d'un changement de compte", + "settings.platforms": "Plateformes", + "settings.platformSearchPlaceholder": "Rechercher une plateforme...", + "settings.platformSearchNoResults": "Aucune plateforme ne correspond à la recherche.", + "settings.accountDisplay": "Affichage des comptes", + "settings.startupAndExtras": "Démarrage", + "settings.defaultOnStartup": "Plateforme par défaut au démarrage", + "settings.platformDisabledSuffix": "(désactivé)", + "settings.platformUnsupportedOs": "OS non supporté", + "settings.platformNotImplemented": "Prévu", + "settings.platformUserProvided": "Ajoutée depuis votre dossier de descripteurs", + "settings.customPlatforms": "Plateformes personnalisées", + "settings.customPlatformsHint": "Les fichiers de descripteur de ce dossier sont lus au démarrage de l'application.", + "settings.customPlatformsNone": "Aucun descripteur chargé depuis ce dossier.", + "settings.customPlatformsProblems": "Fichiers non chargés", + "descriptor.addFromFile": "Ajouter depuis un fichier", + "descriptor.reload": "Recharger", + "descriptor.openFolder": "Ouvrir le dossier", + "descriptor.remove": "Supprimer", + "descriptor.removeConfirmTitle": "Supprimer {platform} ?", + "descriptor.removeConfirmMessage": "Son fichier de descripteur quitte votre dossier. Les comptes déjà capturés restent sur le disque.", + "descriptor.previewTitle": "Ajouter une plateforme", + "descriptor.previewOs": "Décrite pour {list}", + "descriptor.previewRoots": "Dossiers auxquels elle peut toucher", + "descriptor.previewSteps": "Ce que ferait un changement de compte", + "descriptor.previewWarnings": "Avertissements", + "descriptor.previewNoPlan": "Impossible de construire un essai à blanc : {problem}", + "descriptor.previewReplaces": "Ceci remplace le descripteur déjà présent dans votre dossier.", + "descriptor.previewNothingWritten": "Rien n'est écrit tant que vous n'avez pas ajouté.", + "descriptor.replace": "Remplacer", + "descriptor.installed": "{platform} ajoutée", + "descriptor.removed": "{platform} supprimée", + "descriptor.actionRead": "lire", + "descriptor.actionCapture": "capturer", + "descriptor.actionRestore": "restaurer", + "descriptor.actionDelete": "supprimer", + "descriptor.actionClose": "fermer", + "descriptor.actionLaunch": "lancer", + "settings.dataRefresh": "Rafraîchissement des données", + "settings.avatarRefresh": "Rafraîchissement avatar", + "settings.banCheckDelay": "Délai vérification ban", + "settings.refreshNow": "Rafraîchir maintenant", + "settings.zeroEachLaunch": "0 = à chaque lancement", + "settings.privacy": "Confidentialité", + "settings.inactivityTimeout": "Délai d'inactivité", + "settings.zeroDisabled": "0 = désactivé", + "settings.streamerMode": "Mode streamer", + "settings.streamerModeHint": "Floute automatiquement les infos des comptes quand un logiciel de streaming tourne.", + "settings.personasHint": "Afficher le sélecteur d'identités personas", + "settings.replayOnboarding": "Refaire l'onboarding", + "settings.replayOnboardingHint": "Réaffiche la visite guidée et le choix des statistiques d'usage.", + "streamer.title": "Mode streamer", + "streamer.disable": "Désactiver", + "streamer.disablePermanently": "Désactiver définitivement", + "personas.title": "Personas", + "personas.create": "Nouveau persona", + "personas.activate": "Basculer vers ce persona", + "personas.edit": "Modifier", + "personas.delete": "Supprimer", + "personas.finish": "Terminer", + "personas.switching": "Bascule en cours...", + "personas.switched": "Basculé vers {name}", + "personas.switchPartial": "{name} : échec sur {failed}", + "personas.switchFailed": "Impossible de basculer vers {name}", + "personas.namePlaceholder": "Nom du persona", + "personas.choosePlatforms": "Choisis un compte pour chaque plateforme à inclure", + "personas.chooseAccount": "Choisis le compte {platform}", + "personas.removeFromPersona": "Retirer de ce persona", + "personas.customImage": "Choisir une image...", + "personas.removeImage": "Retirer l'image", + "personas.imageError": "Impossible de charger cette image.", + "personas.loadingAccounts": "Chargement des comptes...", + "personas.noAccountsOnPlatform": "Aucun compte sur cette plateforme pour l'instant", + "personas.emptyHint": "Un clic bascule chaque plateforme incluse vers son compte.", + "settings.security": "Sécurité", + "settings.pinLockOnAfk": "Verrou PIN en AFK", + "settings.pinCode": "Code PIN", + "settings.pinPlaceholder": "4 chiffres", + "settings.pinCurrentCode": "PIN actuel", + "settings.pinCurrentHint": "Saisissez le PIN actuel pour le modifier ou désactiver le verrou.", + "settings.telemetry": "Statistiques d'utilisation (anonymes)", + "settings.telemetryModeA": "Stats anonymes de base", + "settings.telemetryModeAHint": "Compte les lancements et les fonctionnalités utilisées. Un identifiant local aléatoire évite les doublons d'installation ; aucune donnée personnelle n'est envoyée.", + "settings.telemetryModeB": "Stats avancées avec UUID local (opt-in)", + "settings.telemetryModeBHint": "Ajoute un UUID aléatoire stocké uniquement sur ta machine. Permet de mesurer la rétention et quelles fonctionnalités comptent. Toujours aucune donnée personnelle envoyée.", + "settings.telemetryExport": "Exporter mes données", + "settings.telemetryExported": "Données copiées dans le presse-papiers", + "settings.telemetryExportFailed": "Échec de l'export", + "settings.telemetryEnableFailed": "Impossible d'activer les stats avancées", + "settings.telemetryDisableFailed": "Impossible de désactiver les stats avancées", + "settings.telemetryDeletePending": "Les stats avancées sont désactivées ; il faudra réessayer l'effacement serveur", + "settings.telemetryDeletePendingHint": "L'effacement serveur est en attente. L'ancien identifiant local est conservé uniquement pour réessayer l'effacement ou exporter les données associées.", + "settings.telemetryRetryDelete": "Réessayer l'effacement", + "settings.telemetryDeleteCompleted": "Données serveur effacées", + "settings.telemetryDeleteRetryFailed": "L'effacement serveur est toujours en attente", + "settings.telemetryLearnMore": "Quelles données sont envoyées ?", + "onboarding.welcome.title": "Bienvenue dans Accshift", + "onboarding.welcome.version": "v{version}", + "onboarding.welcome.compatibleWith": "Ton système d'exploitation est compatible avec :", + "onboarding.welcome.detectedPlatforms": "Voici les plateformes détectées sur ta machine :", + "onboarding.welcome.next": "Fais-moi visiter", + "onboarding.welcome.skip": "Passer", + "onboarding.features.hint": "Cette partie de l'app n'est pas visible pour l'instant.", + "onboarding.features.addAccount.label": "Ajouter un compte", + "onboarding.features.addAccount.body": "Le bouton + en haut à droite ajoute un compte sur la plateforme active. Tu te connectes une fois, Accshift le garde.", + "onboarding.features.switch.label": "Changer de compte", + "onboarding.features.switch.body": "Clique sur une carte et t'es sur ce compte. C'est toute l'app, honnêtement.", + "onboarding.features.contextMenu.label": "Clic droit pour les actions", + "onboarding.features.contextMenu.body": "Clic droit sur une carte pour la renommer, changer sa couleur, la ranger dans un dossier ou la supprimer.", + "onboarding.features.platformSwitch.label": "Changer de plateforme", + "onboarding.features.platformSwitch.body": "Pareil pour Riot, Battle.net, Epic et les autres. Les onglets en haut changent de plateforme.", + "onboarding.features.next": "Suivant", + "onboarding.features.back": "Retour", + "onboarding.features.step": "Étape {step}", + "onboarding.features.mockRename": "Renommer", + "onboarding.features.mockSetColor": "Définir la couleur", + "onboarding.features.mockMoveToFolder": "Déplacer vers un dossier", + "onboarding.features.mockDelete": "Supprimer", + "onboarding.features.mockAccount": "Compte {number}", + "onboarding.telemetry.title": "VENDS TON ÂME À ACCSHIFT !", + "onboarding.telemetry.intro": "Accshift compte quelques stats d'utilisation anonymes pour que je sache quoi corriger en premier. Rien de personnel ne quitte ta machine, l'app marche pareil dans tous les cas, et tu peux changer d'avis quand tu veux dans les paramètres.", + "onboarding.telemetry.question": "Marché conclu ?", + "onboarding.telemetry.gifAlt": "Mème Trade Offer", + "onboarding.telemetry.basic": "NON ! ça suffit", + "onboarding.telemetry.basicDefault": "(par défaut)", + "onboarding.telemetry.basicHint": "Des compteurs anonymes et rien d'autre : aucun profil, aucun identifiant qui te suit.", + "onboarding.telemetry.deal": "MARCHÉ CONCLU", + "onboarding.telemetry.dealHint": "Ajoute un UUID distinct pour les stats avancées, stocké uniquement sur ta machine. Je ne sais toujours pas qui tu es, mais tu aides beaucoup. Merci !", + "onboarding.telemetry.optOutNote": "Quel que soit ton choix, tu pourras le changer plus tard dans Paramètres -> Confidentialité. :c", + "onboarding.telemetry.refuse": "Non merci, tout couper", + "onboarding.telemetry.refuseHint": "Rien n'est mesuré, rien n'est envoyé, pas même les compteurs anonymes.", + "onboarding.telemetry.back": "Retour", + "onboarding.persistFailed": "Impossible d'enregistrer ce choix. Réessaie.", + "onboarding.persistLater": "Me redemander au prochain lancement", + "settings.steam": "Steam", + "settings.riot": "Riot", + "settings.battleNet": "Battle.net", + "settings.ubisoft": "Ubisoft Connect", + "settings.runSteamAsAdmin": "Lancer Steam en admin", + "settings.showUsernames": "Afficher les usernames", + "settings.showSteamLastLogin": "Afficher la dernière connexion Steam", + "settings.showRiotLastLogin": "Afficher la dernière connexion Riot", + "settings.showBattleNetLastLogin": "Afficher la dernière connexion Battle.net", + "settings.showUbisoftLastLogin": "Afficher la dernière connexion Ubisoft", + "settings.showNotesUnderCards": "Afficher les notes sous les cartes", + "settings.expandedFolders": "Tout dérouler les dossiers", + "list.rootSection": "Racine", + "settings.steamLaunch": "Bascule", + "settings.steamInstallation": "Installation", + "settings.steamApiAndData": "API & données", + "settings.launchOptions": "Options de lancement", + "settings.shutdownMode": "Mode d'arrêt", + "settings.shutdownModeGraceful": "Gracieux", + "settings.shutdownModeForce": "Force kill", + "settings.autoDetected": "Détection auto", + "settings.steamFolder": "Dossier Steam", + "settings.steamFolderRegistry": "Registre", + "settings.riotClientPath": "Exécutable du client Riot", + "settings.battleNetPath": "Exécutable Battle.net", + "settings.ubisoftPath": "Dossier Ubisoft Connect", + "settings.steamWebApiKey": "Clé API Steam Web", + "settings.getKey": "Obtenir la clé", + "settings.pasteApiKey": "Colle ta clé API Steam Web", + "settings.saved": "Paramètres enregistrés", + "settings.openApiKeyFailed": "Impossible d'ouvrir la page de clé API Steam", + "settings.integrations": "Intégrations", + "settings.deepLinks": "Autoriser les liens accshift://", + "settings.deepLinksHint": "Laisse d'autres applications changer de compte pour toi : favoris, Stream Deck, scripts.", + "settings.cliEnabled": "Autoriser le CLI accshift", + "settings.cliEnabledHint": "Laisse l'outil en ligne de commande lister et changer de compte. Désactivé, toute commande accshift refuse de s'exécuter, diag compris ; l'application elle-même n'est pas concernée.", + "settings.help": "Ouvrir le wiki accshift", + "settings.helpShort": "Aide", + "settings.openHelpFailed": "Impossible d'ouvrir le wiki", + "settings.logs": "Logs", + "settings.openLogs": "Ouvrir le dossier des logs", + "settings.openLogsFailed": "Impossible d'ouvrir le dossier des logs", + "context.loading": "Chargement...", + "context.noGamesFound": "Aucun jeu trouvé", + "context.menu.newFolder": "Nouveau dossier", + "context.menu.refresh": "Rafraîchir", + "context.menu.rename": "Renommer", + "context.menu.copy": "Copier...", + "context.menu.deleteFolder": "Supprimer le dossier", + "context.menu.folderColor": "Couleur du dossier", + "context.menu.moveToFolder": "Déplacer vers un dossier", + "context.menu.moveToRoot": "Aucun dossier", + "context.menu.editCardAndColor": "Modifier carte et couleur", + "context.menu.cardColor": "Couleur de carte", + "context.menu.addNote": "Ajouter une note", + "context.menu.editNote": "Modifier la note", + "platform.rename": "Renommer", + "platform.renameTitle": "Renommer le compte", + "platform.renamePlaceholder": "Nom du compte", + "platform.newAccountPending": "En attente...", + "platform.setupBusy": "Une autre opération accshift est en cours. En attente de sa fin.", + "platform.renameNewAccount": "Nommer ce compte", + "platform.copyLabelAccountId": "ID de compte", + "platform.forget": "Oublier", + "platform.forgetConfirmTitle": "Oublier \"{display}\" ?", + "platform.forgetConfirmMessage": "Ce compte et sa session sauvegardée seront retirés d'Accshift.", + "platform.forgotAccount": "{display} oublié", + "dialog.newFolderTitle": "Nouveau dossier", + "dialog.renameFolderTitle": "Renommer le dossier", + "dialog.folderNamePlaceholder": "Nom du dossier", + "dialog.cardNoteTitle": "Note de carte", + "dialog.cardNotePlaceholder": "Écris une note courte", + "card.noteAttached": "Note attachée", + "card.clickAgainToSwitch": "Cliquez encore pour changer", + "card.extensionWarnings": "Alertes", + "card.extensionNote": "Note", + "card.cs2Section": "CS2", + "card.cs2Level": "Niveau {level}", + "card.cs2CaseEarned": "Caisse hebdo obtenue", + "card.cs2CaseNotEarned": "Caisse hebdo à faire", + "list.selectAccountPreview": "Sélectionne un compte pour l'aperçu", + "preview.noteLabel": "Note", + "preview.currentlyActive": "Compte actuellement actif", + "preview.switchAgain": "Rebasculer", + "preview.switchAccount": "Changer de compte", + "ban.community": "Ban communautaire", + "ban.vac.single": "{count} ban VAC", + "ban.vac.multiple": "{count} bans VAC", + "ban.game.single": "{count} ban jeu", + "ban.game.multiple": "{count} bans jeu", + "time.unknown": "inconnu", + "time.justNow": "à l'instant", + "time.minuteAgo": "il y a {count} minute", + "time.minutesAgo": "il y a {count} minutes", + "time.hourAgo": "il y a {count} heure", + "time.hoursAgo": "il y a {count} heures", + "time.dayAgo": "il y a {count} jour", + "time.daysAgo": "il y a {count} jours", + "time.neverConnected": "jamais connecté", + "time.compactWrapper": "({value})", + "toast.copied": "{label} copié", + "toast.legacyConfigMigrated": "Paramètres migrés vers le nouveau format", + "toast.legacyConfigMigrationFailed": "La migration des anciens paramètres a échoué", + "toast.failedLoadSettingsPanel": "Impossible de charger le panneau paramètres", + "toast.banChecking": "Vérification des bans...", + "toast.banCheckSummary.single": "Vérification: {count} compte avec ban", + "toast.banCheckSummary.multiple": "Vérification: {count} comptes avec ban", + "toast.banCheckFailed": "Échec de la vérification des bans: {error}", + "toast.avatarRefreshComplete": "Rafraîchissement avatar terminé pour {count} comptes", + "toast.banRefreshComplete": "Rafraîchissement des bans terminé pour {count} comptes", + "toast.banRefreshNoApiKey": "Rafraîchissement des bans ignoré : ajoute une clé Steam Web API dans les paramètres", + "toast.refreshPartial": "Rafraîchissement terminé pour {ok} comptes sur {count}", + "toast.noSteamAccountsFound": "Aucun compte Steam trouvé. Connecte-toi à Steam au moins une fois puis rafraîchis.", + "toast.accountsRefreshed.single": "{count} compte rafraîchi", + "toast.accountsRefreshed.multiple": "{count} comptes rafraîchis", + "toast.steamFolderNotFound": "Dossier Steam introuvable. Configure-le manuellement dans Paramètres > Dossier Steam.", + "toast.steamElevated": "Steam tourne en admin. Active 'Lancer Steam en admin' dans Paramètres > Steam.", + "toast.noRiotProfilesFound": "Aucun profil Riot pour l'instant. Clique sur + pour en ajouter un.", + "toast.noBattleNetAccountsFound": "Aucun compte Battle.net trouvé. Connecte-toi à Battle.net au moins une fois puis rafraîchis.", + "toast.noUbisoftAccountsFound": "Aucun compte Ubisoft trouvé. Connecte-toi à Ubisoft Connect au moins une fois puis rafraîchis.", + "toast.deepLinkInvalid": "Lien accshift:// non reconnu", + "toast.deepLinkDisabled": "Les liens accshift:// sont désactivés dans les paramètres", + "toast.deepLinkUnknownPlatform": "Plateforme inconnue dans le lien : {platform}", + "toast.deepLinkPlatformDisabled": "{platform} est désactivé dans les paramètres", + "toast.deepLinkAccountNotFound": "Aucun compte {platform} correspondant à \"{account}\"", + "toast.deepLinkSwitched": "Basculé sur {account}", + "dialog.deepLinkSwitchTitle": "Changer de compte ?", + "dialog.deepLinkSwitchMessage": "Un lien accshift:// demande à basculer votre compte {platform} sur « {account} ». Continuer ?", + "dialog.deepLinkSwitchConfirm": "Basculer", + "steam.launchOnline": "Basculer en ligne", + "steam.launchInvisible": "Basculer en invisible", + "steam.switchAndRunGame": "Basculer + lancer {game}", + "steam.switchAndRunPick": "Basculer + lancer un jeu…", + "steam.runGame": "Lancer {game}", + "steam.runPick": "Lancer un jeu…", + "steam.switchAndRunNoGames": "Aucun jeu trouvé", + "steam.clearDefaultGame": "Oublier le jeu par défaut", + "steam.copySteamId64": "Copier SteamID64", + "steam.copyFriendCode": "Copier le code ami", + "steam.copyProfileUrl": "Copier l'URL du profil", + "steam.copyLabelUsername": "Login", + "steam.copyLabelSteamId64": "SteamID64", + "steam.copyLabelFriendCode": "Code ami", + "steam.copyLabelProfileUrl": "URL du profil", + "steam.openUserdataFolder": "Ouvrir le dossier userdata", + "steam.clearIntegratedBrowserCache": "Réinitialiser la session navigateur", + "steam.clearIntegratedBrowserCacheConfirmTitle": "Réinitialiser la session navigateur Steam ?", + "steam.clearIntegratedBrowserCacheConfirmMessage": "Cela ferme Steam et vide le cache du navigateur intégré utilisé par le magasin et la communauté.", + "steam.clearedIntegratedBrowserCache": "Session navigateur Steam réinitialisée", + "steam.copySettingsFrom": "Copier les paramètres depuis", + "steam.copiedSettingsToCurrent": "Paramètres de {game} copiés vers le compte courant", + "steam.forget": "Oublier", + "steam.forgetConfirmTitle": "Oublier \"{display}\" ?", + "steam.forgetConfirmMessage": "Ce compte sera retiré de ta liste de comptes Steam sur ce PC.", + "steam.forgotAccount": "{username} oublié", + "steam.setupPendingLabel": "Compte Steam", + "steam.setupWaitingForClient": "En attente de Steam...", + "steam.setupWaitingForLogin": "Connecte-toi au compte Steam que tu veux ajouter.", + "steam.setupDetected": "Compte détecté", + "steam.setupConnected": "Connecté", + "steam.setupReady": "Compte Steam ajouté", + "steam.setupReadyWithProfile": "{profile} ajouté", + "steam.setupFailed": "Échec de la configuration Steam", + "steam.setupFailedMessage": "Accshift n'a pas pu finaliser l'ajout de ce compte Steam.", + "riot.captureSession": "Capturer la session actuelle", + "riot.recaptureSession": "Recapturer la session", + "riot.capturedSession": "Session capturée pour {profile}", + "riot.setupPendingLabel": "Compte Riot", + "riot.setupWaitingForClient": "En attente du client Riot...", + "riot.setupWaitingForLogin": "Connecte-toi au compte Riot que tu veux ajouter.", + "riot.setupCapturing": "Sauvegarde de la session Riot...", + "riot.setupReady": "Compte Riot ajouté", + "riot.setupReadyWithProfile": "{profile} ajouté", + "riot.setupFailed": "Échec de la configuration Riot", + "riot.setupFailedMessage": "Accshift n'a pas pu finaliser l'ajout de ce compte Riot.", + "riot.setupStaySignedIn": "Garde 'Rester connecté' activé.", + "riot.setupDetected": "Compte détecté", + "riot.setupConnected": "Connecté", + "riot.forget": "Oublier le compte", + "riot.forgetConfirmTitle": "Oublier \"{display}\" ?", + "riot.forgetConfirmMessage": "Cela retire le profil Riot local et sa session sauvegardée d'Accshift.", + "riot.forgotProfile": "{profile} oublié", + "battlenet.copyUsername": "Copier le pseudo", + "battlenet.copyLabelUsername": "Pseudo", + "battlenet.copyEmail": "Copier l'email", + "battlenet.copyLabelEmail": "Email", + "battlenet.forget": "Oublier", + "battlenet.forgetConfirmTitle": "Oublier \"{display}\" ?", + "battlenet.forgetConfirmMessage": "Ce compte sera retiré de la liste de comptes Battle.net sur ce PC.", + "battlenet.forgotAccount": "{email} oublié", + "battlenet.setupPendingLabel": "Compte Battle.net", + "battlenet.setupWaitingForClient": "En attente de Battle.net...", + "battlenet.setupWaitingForLogin": "Connecte-toi au compte Battle.net que tu veux ajouter.", + "battlenet.setupKeepMeLoggedIn": "Garde 'Rester connecté' activé si possible.", + "battlenet.setupDetected": "Compte détecté", + "battlenet.setupConnected": "Connecté", + "battlenet.setupReady": "Compte Battle.net ajouté", + "battlenet.setupReadyWithProfile": "{profile} ajouté", + "battlenet.setupFailed": "Échec de la configuration Battle.net", + "battlenet.setupFailedMessage": "Accshift n'a pas pu finaliser l'ajout de ce compte Battle.net.", + "ubisoft.copyLabelUuid": "UUID", + "ubisoft.forget": "Oublier", + "ubisoft.forgetConfirmTitle": "Oublier \"{display}\" ?", + "ubisoft.forgetConfirmMessage": "Ce compte et sa session sauvegardée seront retirés d'Accshift.", + "ubisoft.forgotAccount": "{display} oublié", + "ubisoft.setupPendingLabel": "Compte Ubisoft", + "ubisoft.setupWaitingForClient": "En attente d'Ubisoft Connect...", + "ubisoft.setupWaitingForLogin": "Connecte-toi au compte Ubisoft que tu veux ajouter.", + "ubisoft.setupKeepMeLoggedIn": "Garde 'Se souvenir de moi' activé.", + "ubisoft.setupDetected": "Compte détecté", + "ubisoft.setupConnected": "Connecté", + "ubisoft.setupReady": "Compte Ubisoft ajouté", + "ubisoft.setupReadyWithProfile": "{profile} ajouté", + "ubisoft.setupFailed": "Échec de la configuration Ubisoft", + "ubisoft.setupFailedMessage": "Accshift n'a pas pu finaliser l'ajout de ce compte Ubisoft.", + "settings.roblox": "Roblox", + "toast.noRobloxAccountsFound": "Aucun compte Roblox trouvé. Ajoute un compte via Quick Login ou colle un cookie.", + "roblox.noAccountsHint": "Appuie sur + pour ajouter un compte Roblox via Quick Login.", + "roblox.sessionExpired": "Session expirée", + "roblox.sessionExpiredTooltip": "Ce compte est déconnecté. Reconnecte-le avant de switcher.", + "roblox.sessionExpiredSwitchError": "Session expirée. Re-lie ce compte pour switcher. Notes et réglages conservés.", + "roblox.cookiePasteTitle": "Ajouter un compte par cookie", + "roblox.cookiePasteHint": "Colle un cookie .ROBLOSECURITY depuis ton navigateur pour ajouter un compte directement.", + "settings.showRobloxLastLogin": "Afficher la dernière connexion", + "settings.robloxHealthCheck": "Vérification de session", + "settings.robloxHealthCheckHint": "Signale les comptes dont la session Roblox a expiré ; une requête réseau par lancement.", + "roblox.copyLabelUsername": "Pseudo", + "roblox.copyLabelUserId": "ID utilisateur", + "roblox.forget": "Oublier", + "roblox.forgetConfirmTitle": "Oublier \"{display}\" ?", + "roblox.forgetConfirmMessage": "Ce compte et son cookie stocké seront supprimés de ce PC.", + "roblox.forgotAccount": "{display} oublié", + "roblox.setupPendingLabel": "Compte Roblox", + "roblox.setupWaitingForClient": "Connexion à Roblox...", + "roblox.setupWaitingForLogin": "Ouvre l'app Roblox sur un autre appareil et entre le code ci-dessous.", + "roblox.setupQuickLoginCode": "Code Quick Login", + "roblox.setupQuickLoginHint": "roblox.com/quick-login", + "roblox.copyCode": "Copier le code", + "roblox.setupDetected": "Compte détecté", + "roblox.setupConnected": "Connecté", + "roblox.setupReady": "Compte Roblox ajouté", + "roblox.setupReadyWithProfile": "{profile} ajouté", + "roblox.setupFailed": "Échec de la configuration Roblox", + "roblox.setupFailedMessage": "Accshift n'a pas pu finaliser l'ajout de ce compte Roblox.", + "settings.epic": "Epic Games", + "settings.epicPath": "Exécutable Epic Games Launcher", + "settings.showEpicLastLogin": "Afficher la dernière connexion", + "toast.noEpicAccountsFound": "Aucun compte Epic Games trouvé. Connecte-toi à Epic Games Launcher au moins une fois puis rafraîchis.", + "epic.noAccountsHint": "Appuie sur + pour ajouter un compte Epic Games.", + "epic.copyLabelAccountId": "ID de compte", + "epic.forget": "Oublier", + "epic.forgetConfirmTitle": "Oublier \"{display}\" ?", + "epic.forgetConfirmMessage": "Ce compte et sa session sauvegardée seront retirés d'Accshift.", + "epic.forgotAccount": "{display} oublié", + "epic.setupPendingLabel": "Compte Epic Games", + "epic.setupWaitingForClient": "En attente d'Epic Games Launcher...", + "epic.setupWaitingForLogin": "Connecte-toi au compte Epic Games que tu veux ajouter.", + "epic.setupKeepMeLoggedIn": "Garde 'Se souvenir de moi' activé.", + "epic.setupDetected": "Compte détecté", + "epic.setupConnected": "Connecté", + "epic.setupReady": "Compte Epic Games ajouté", + "epic.setupReadyWithProfile": "{profile} ajouté", + "epic.setupFailed": "Échec de la configuration Epic Games", + "epic.setupFailedMessage": "Accshift n'a pas pu finaliser l'ajout de ce compte Epic Games.", + "settings.gog": "GOG Galaxy", + "settings.gogPath": "Exécutable GOG Galaxy", + "settings.showGogLastLogin": "Afficher la dernière connexion", + "toast.noGogAccountsFound": "Aucun compte GOG Galaxy trouvé. Connecte-toi à GOG Galaxy au moins une fois puis rafraîchis.", + "gog.noAccountsHint": "Appuie sur + pour ajouter un compte GOG Galaxy.", + "gog.copyLabelAccountId": "ID de compte", + "gog.forget": "Oublier", + "gog.forgetConfirmTitle": "Oublier \"{display}\" ?", + "gog.forgetConfirmMessage": "Ce compte et sa session sauvegardée seront retirés d'Accshift.", + "gog.forgotAccount": "{display} oublié", + "gog.setupPendingLabel": "Compte GOG Galaxy", + "gog.setupWaitingForClient": "En attente de GOG Galaxy...", + "gog.setupWaitingForLogin": "Connecte-toi au compte GOG Galaxy que tu veux ajouter.", + "gog.setupKeepMeLoggedIn": "Garde 'Rester connecté' activé.", + "gog.setupDetected": "Compte détecté", + "gog.setupConnected": "Connecté", + "gog.setupReady": "Compte GOG Galaxy ajouté", + "gog.setupReadyWithProfile": "{profile} ajouté", + "gog.setupFailed": "Échec de la configuration GOG Galaxy", + "gog.setupFailedMessage": "Accshift n'a pas pu finaliser l'ajout de ce compte GOG Galaxy.", + "settings.jagex": "Jagex Launcher", + "settings.jagexPath": "Exécutable Jagex Launcher", + "settings.showJagexLastLogin": "Afficher la dernière connexion", + "toast.noJagexAccountsFound": "Aucun compte Jagex enregistré. Appuie sur + pour en ajouter un via le Jagex Launcher.", + "jagex.noAccountsHint": "Appuie sur + pour ajouter un compte Jagex.", + "jagex.copyLabelAccountId": "ID de compte", + "jagex.forget": "Oublier", + "jagex.forgetConfirmTitle": "Oublier \"{display}\" ?", + "jagex.forgetConfirmMessage": "Ce compte et sa session sauvegardée seront retirés d'Accshift.", + "jagex.forgotAccount": "{display} oublié", + "jagex.setupPendingLabel": "Compte Jagex", + "jagex.setupWaitingForClient": "En attente du Jagex Launcher...", + "jagex.setupWaitingForLogin": "Connecte-toi au compte Jagex que tu veux ajouter.", + "jagex.setupKeepMeLoggedIn": "Garde 'Se souvenir de moi' activé.", + "jagex.setupDetected": "Compte détecté", + "jagex.setupConnected": "Connecté", + "jagex.setupReady": "Compte Jagex ajouté", + "jagex.setupReadyWithProfile": "{profile} ajouté", + "jagex.setupFailed": "Échec de la configuration Jagex", + "jagex.setupFailedMessage": "Accshift n'a pas pu finaliser l'ajout de ce compte Jagex.", + "settings.discord": "Discord", + "settings.discordPath": "Exécutable Discord", + "settings.showDiscordLastLogin": "Afficher la dernière connexion", + "toast.noDiscordAccountsFound": "Aucun compte Discord trouvé. Ajoute un compte Discord puis rafraîchis.", + "discord.noAccountsHint": "Appuie sur + pour ajouter un compte Discord.", + "discord.copyLabelAccountId": "ID de compte", + "discord.forget": "Oublier", + "discord.forgetConfirmTitle": "Oublier \"{display}\" ?", + "discord.forgetConfirmMessage": "Ce compte et sa session sauvegardée seront retirés d'Accshift.", + "discord.forgotAccount": "{display} oublié", + "discord.setupPendingLabel": "Compte Discord", + "discord.setupWaitingForClient": "En attente de Discord...", + "discord.setupWaitingForLogin": "Connecte-toi au compte Discord que tu veux ajouter.", + "discord.setupKeepMeLoggedIn": "Reste connecté pour conserver ce compte.", + "discord.setupDetected": "Compte détecté", + "discord.setupConnected": "Connecté", + "discord.setupReady": "Compte Discord ajouté", + "discord.setupReadyWithProfile": "{profile} ajouté", + "discord.setupFailed": "Échec de la configuration Discord", + "discord.setupFailedMessage": "Accshift n'a pas pu finaliser l'ajout de ce compte Discord.", + "color.none": "Défaut", + "color.blue": "Bleu", + "color.cyan": "Cyan", + "color.green": "Vert", + "color.lime": "Citron vert", + "color.yellow": "Jaune", + "color.orange": "Orange", + "color.red": "Rouge", + "color.pink": "Rose", + "color.violet": "Violet", + "color.gray": "Gris", + "language.english": "English", + "language.french": "Français", + "language.spanish": "Español", + "language.portuguese": "Português (Portugal)", + "language.portugueseBrazil": "Português (Brasil)", + "language.russian": "Русский", + "language.chinese": "简体中文", + "theme.dark": "Sombre", + "theme.light": "Clair", + "theme.midnight": "Minuit", + "theme.glassDark": "Verre sombre", + "theme.glassLight": "Verre clair", + "theme.liquidGlass": "Verre liquide", + "theme.custom": "Personnalisé", + "settings.themeImport": "Importer", + "settings.themeExport": "Exporter", + "settings.themeDelete": "Supprimer le thème", + "settings.themeImported": "Thème importé", + "settings.themeDeleted": "Thème supprimé", + "settings.themeExportCopied": "JSON du thème copié dans le presse-papiers", + "settings.themeInvalidJson": "JSON de thème invalide", + "settings.themeCustomize": "Personnaliser", + "settings.themeEdit": "Modifier", + "settings.themeCopyName": "{name} copie", + "settings.themeSaved": "Thème enregistré", + "settings.themeMigrated": "Thème importé et mis au format actuel", + "settings.themeInvalidId": "L'identifiant du thème est absent ou invalide", + "settings.themeInvalidName": "Le thème n'a pas de nom", + "settings.themeUnsupportedVersion": "Ce thème nécessite une version plus récente d'accshift", + "themeGroup.surface": "Surfaces", + "themeGroup.text": "Texte", + "themeGroup.semantic": "Couleurs d'état", + "themeGroup.shape": "Forme et densité", + "themeGroup.typography": "Typographie", + "themeGroup.motion": "Mouvement", + "themeKind.rgbTriplet": "triplet RVB", + "themeKind.hexColor": "couleur hexadécimale", + "themeKind.color": "couleur", + "themeKind.length": "longueur", + "themeKind.shadow": "ombre", + "themeKind.choice": "choix", + "themeKind.fontStack": "police", + "themeKind.signedLength": "espacement", + "themeKind.gradient": "dégradé", + "themeKind.number": "nombre", + "themeDensity.compact": "Compact", + "themeDensity.cozy": "Normal", + "themeDensity.comfortable": "Confortable", + "themeAvatarShape.circle": "Rond", + "themeAvatarShape.rounded": "Arrondi", + "themeAvatarShape.square": "Carré", + "themeSmoothing.auto": "Lissé", + "themeSmoothing.none": "Pixel", + "themeToken.bgRgb": "Fond de la fenêtre", + "themeToken.bgCard": "Fond des cartes", + "themeToken.bgCardHover": "Fond des cartes au survol", + "themeToken.bgMuted": "Fond atténué", + "themeToken.bgElevated": "Fond en relief", + "themeToken.border": "Bordures", + "themeToken.fg": "Texte principal", + "themeToken.fgMuted": "Texte secondaire", + "themeToken.fgSubtle": "Texte discret", + "themeToken.afkText": "Mention absent", + "themeToken.accent": "Accent", + "themeToken.accentFg": "Texte sur l'accent", + "themeToken.success": "Succès", + "themeToken.warning": "Avertissement", + "themeToken.danger": "Danger", + "themeToken.radiusSm": "Petit arrondi", + "themeToken.radiusMd": "Arrondi moyen", + "themeToken.radiusLg": "Grand arrondi", + "themeToken.elevationLow": "Ombre légère", + "themeToken.elevationMedium": "Ombre moyenne", + "themeToken.elevationHigh": "Ombre forte", + "themeToken.density": "Densité", + "themeToken.fontUi": "Police de l'interface", + "themeToken.bgImage": "Dégradé de la fenêtre", + "themeToken.cardBgImage": "Dégradé des surfaces", + "themeToken.borderWidth": "Épaisseur des bordures", + "themeToken.borderStyle": "Style des bordures", + "themeToken.avatarShape": "Forme des avatars", + "themeToken.focusRing": "Contour de focus", + "themeToken.overlayBlur": "Flou d'arrière-plan", + "themeToken.fontDisplay": "Police des titres", + "themeToken.lineHeight": "Hauteur de ligne", + "themeToken.letterSpacing": "Interlettrage", + "themeToken.labelCase": "Casse des libellés", + "themeToken.fontSmoothing": "Lissage du texte", + "themeToken.motionScale": "Vitesse des animations", + "themeEditor.title": "Éditeur de thème", + "themeEditor.name": "Nom", + "themeEditor.author": "Auteur", + "themeEditor.version": "Version", + "themeEditor.base": "Hérite de", + "themeEditor.colorScheme": "Mode de couleurs", + "themeEditor.glass": "Surfaces translucides", + "themeEditor.inherited": "Hérité", + "themeEditor.builtInBase": "Thème intégré", + "themeEditor.reset": "Revenir à la valeur héritée", + "themeEditor.issues": "Vérifications", + "themeEditor.noIssues": "Rien à signaler", + "themeEditor.fixErrorsFirst": "Corrigez les valeurs refusées avant d'enregistrer", + "themeEditor.save": "Enregistrer", + "themeEditor.saveFailed": "Impossible d'enregistrer le thème. Réessaie.", + "themeEditor.export": "Copier", + "themeEditor.issueInvalidValue": "{token} : {kind} invalide, par exemple {example}", + "themeEditor.issueUnknownToken": "{token} n'est pas une valeur de thème, elle est ignorée", + "themeEditor.issueMissingToken": "{token} n'est pas défini, la valeur intégrée est utilisée", + "themeEditor.issueUnknownBase": "Le thème de base {base} n'existe pas, les valeurs intégrées sont utilisées", + "themeEditor.issueContrast": "{token} sur {against} : {ratio} pour 1, sous la cible de {target} pour 1", + "themeEditor.beta": "Bêta", + "themeEditor.betaNote": "Le format de thème bouge encore : un thème écrit aujourd'hui peut demander une retouche après une mise à jour.", + "themeEditor.customCss": "CSS personnalisé", + "themeEditor.customCssHelp": "Appliqué par-dessus le thème, pour ce que les valeurs ci-dessus n'atteignent pas. Ni @import ni url() : un thème ne va rien chercher à l'extérieur.", + "themeEditor.issueUnsafeCss": "{construct} est interdit dans un thème, le CSS n'est pas appliqué", + "themeEditor.issueCssTooLong": "CSS trop long : {length} caractères, la limite est {limit}", + "bulkEdit.title": "Édition en masse", + "bulkEdit.selectAccounts": "Sélectionner les comptes", + "bulkEdit.selectAll": "Tout sélectionner", + "bulkEdit.deselectAll": "Tout désélectionner", + "bulkEdit.next": "Suivant", + "bulkEdit.apply": "Appliquer", + "bulkEdit.settings": "Paramètres", + "bulkEdit.newsPopup": "Popup d'actualités jeux", + "bulkEdit.doNotDisturb": "Ne pas déranger", + "bulkEdit.launchOptions": "Options de lancement par jeu", + "bulkEdit.unchanged": "Inchangé", + "bulkEdit.addLaunchOption": "Ajouter", + "bulkEdit.selectGame": "Sélectionner un jeu...", + "bulkEdit.launchOptionsPlaceholder": "-fullscreen", + "bulkEdit.activeAccountWarning": "Le compte actuellement actif est sélectionné. Les modifications seront prises en compte au prochain redémarrage de Steam.", + "bulkEdit.toastSuccess": "Édition en masse : {count} réussi(s)", + "bulkEdit.toastPartial": "Édition en masse : {succeeded} réussi(s), {failed} échoué(s)", + "bulkEdit.toastFailed": "Échec de l'édition en masse", + "bulkEdit.noAccountsSelected": "Aucun compte sélectionné", + "bulkEdit.selected": "{count} sélectionné(s)", + "bulkEdit.loadingGames": "Chargement des jeux...", + "bulkEdit.copyUrls": "Copier les URLs", + "bulkEdit.urlsCopied": "{count} URL(s) de profil copiée(s)", + "bulkEdit.cardColor": "Couleur de carte", + "bulkEdit.colorApplied": "Couleur appliquée à {count} carte(s)", + "bulkEdit.colorCleared": "Couleur retirée de {count} carte(s)", + "common.retry": "Réessayer", + "app.searchNoResults": "Aucun résultat pour « {query} »", + "app.folderEmpty": "Ce dossier est vide", + "app.loadErrorGeneric": "Impossible de charger les comptes. Regarde la console pour les détails.", + "toast.dismiss": "Fermer", + "toast.switchFailed": "Le changement de compte a échoué. Regarde la console pour les détails.", + "toast.addAccountFailed": "Impossible d'ajouter le compte. Consulte les logs de l'application pour les détails.", + "toast.addAccountClientMissing": "{platform} ne semble pas installé. Installe-le, ou renseigne son emplacement dans les Paramètres.", + "toast.banCheckFailedGeneric": "La vérification des bans a échoué. Regarde la console pour les détails.", + "toast.renameAccountFailed": "Renommage échoué : {error}", + "toast.copyFailed": "Impossible de copier dans le presse-papiers", + "toast.refreshFailed": "Le rafraîchissement a échoué. Regarde la console pour les détails.", + "dialog.deleteFolderTitle": "Supprimer « {name} » ?", + "dialog.deleteFolderMessage": "Les sous-dossiers sont supprimés aussi. Les comptes qu'ils contiennent remontent d'un niveau, ils ne sont pas supprimés.", + "personas.deleteConfirm": "Clique encore pour supprimer", + "personas.discardConfirm": "Clique encore pour abandonner", + "personas.switchConfirmTitle": "Basculer de persona", + "personas.switchConfirmMessage": "Basculer {count} plateforme(s) vers « {name} » ? Chaque client va fermer et se relancer.", + "personas.switchConfirmAction": "Basculer", + "personas.deleteTitle": "Supprimer le persona", + "personas.deleteMessage": "Supprimer « {name} » ? Les comptes eux-mêmes ne sont pas touchés.", + "context.submenuError": "Impossible de charger ce menu", + "settings.apiKeySaveFailed": "Impossible d'enregistrer la clé API Steam", + "settings.apiKeyInvalidHint": "La clé n'a pas été enregistrée. Vérifie-la et réessaie.", + "settings.apiKeyCleared": "Clé API Steam effacée", + "settings.apiKeyClearFailed": "Impossible d'effacer la clé API Steam", + "settings.clearApiKey": "Effacer la clé", + "settings.cs2Bridge": "Pont stats CS2", + "settings.cs2BridgeLink": "Lien du pont", + "settings.cs2BridgeLinkPlaceholder": "https://ton-serveur/api/bridge/accshift/", + "settings.cs2BridgeTest": "Tester", + "settings.cs2BridgeStatusOk": "Connecté · {count} comptes · {ms} ms", + "settings.cs2BridgeStatusFail": "Connexion échouée", + "settings.cs2BridgeStatusTesting": "Test en cours...", + "settings.cs2BridgeStatusUnknown": "Pas testé", + "settings.cs2BridgeToken": "Token API", + "settings.cs2BridgeTokenOptional": "optionnel, pour serveurs custom", + "settings.cs2BridgeTokenPlaceholder": "Token Bearer si ton serveur en demande un", + "settings.cs2BridgeSaveFailed": "Impossible d'enregistrer les réglages du pont CS2", + "settings.cs2BridgeClearToken": "Effacer le token", + "settings.pathSaveFailed": "Impossible d'enregistrer le chemin {platform}", + "settings.pathInvalidHint": "Ce chemin n'a pas pu être enregistré. Vérifie-le et réessaie.", + "settings.pathSteamNeverSignedIn": "Steam trouvé, connecte-toi une fois pour qu'il crée son historique de connexion", + "settings.pathNotSteamFolder": "Ce dossier ne ressemble pas à une installation Steam", + "settings.pathNotADirectory": "Le chemin doit être un dossier existant", + "settings.pinSaved": "Code PIN enregistré", + "settings.pinTakesEffectNextLaunch": "Prend effet au prochain lancement de l'app", + "settings.pinRequiredAfterInactivity": "Demandé après inactivité et au lancement de l'app", + "settings.pinScreenLockOnly": "Le PIN verrouille cette fenêtre et les changements de compte. Il ne protège rien sur le disque.", + "settings.unitSeconds": "secondes", + "settings.unitDays": "jours", + "settings.telemetryLoadFailed": "Impossible de charger les paramètres de télémétrie", + "palette.placeholder": "Tape une commande ou un nom de compte...", + "palette.noResults": "Aucune commande ne correspond", + "palette.sectionAccounts": "Comptes", + "palette.sectionActions": "Actions", + "palette.sectionNavigation": "Navigation", + "palette.openTab": "Aller à {platform}", + "palette.openFolder": "Ouvrir le dossier « {name} »", + "palette.backToParent": "Revenir au dossier parent", + "palette.zoomReset": "Réinitialiser le zoom" +} diff --git a/crates/accshift-app/i18n/pt-br.json b/crates/accshift-app/i18n/pt-br.json new file mode 100644 index 0000000..7182943 --- /dev/null +++ b/crates/accshift-app/i18n/pt-br.json @@ -0,0 +1,741 @@ +{ + "app.loadingSettings": "Carregando configurações...", + "app.loading": "Carregando...", + "app.searchPlaceholder": "Buscar conta...", + "app.noAccountsFound": "Nenhuma conta {platform} encontrada", + "app.noAccountsHint": "Verifique se o {platform} está instalado e se você já fez login pelo menos uma vez.", + "app.switchingAccount": "Trocando de conta...", + "app.platformRestarting": "{platform} está reiniciando", + "app.comingSoon": "{platform} - Em breve", + "app.platformUnsupportedOs": "{platform} ainda não está disponível neste sistema", + "app.noCompatiblePlatforms": "Nenhuma plataforma compatível está disponível neste sistema", + "app.noCompatiblePlatformsHint": "Acompanhe as novidades de suporte no Windows ou use uma máquina compatível.", + "update.ctaAvailable": "Atualização disponível", + "update.ctaInstalling": "Instalando...", + "update.restartToApplyVersion": "Reiniciar para aplicar a atualização {version}", + "update.restartToApply": "Reiniciar para aplicar a atualização", + "update.readyToastVersion": "Atualização {version} pronta", + "update.readyToast": "Atualização pronta", + "update.restartFailed": "Não foi possível reiniciar para aplicar a atualização", + "pin.lockedTitle": "Aplicativo bloqueado", + "pin.lockedPrompt": "Digite o PIN para desbloquear", + "pin.placeholder": "PIN de 4 dígitos", + "pin.unlock": "Desbloquear", + "pin.invalid": "PIN inválido", + "pin.tooManyAttempts": "Muitas tentativas. Tente novamente em {seconds} s.", + "common.ok": "OK", + "common.cancel": "Cancelar", + "common.confirm": "Confirmar", + "common.close": "Fechar", + "common.back": "Voltar", + "common.active": "Ativa", + "common.choose": "Escolher...", + "common.none": "Nenhum", + "common.custom": "Personalizado", + "common.configured": "Configurado", + "common.missing": "Ausente", + "common.enabled": "Ativado", + "common.disabled": "Desativado", + "common.on": "Ligado", + "common.off": "Desligado", + "common.add": "Adicionar", + "common.visible": "Visível", + "common.hidden": "Oculto", + "common.inline": "Na linha", + "common.tooltip": "Dica", + "titlebar.refresh": "Atualizar", + "titlebar.addAccount": "Adicionar conta", + "titlebar.settings": "Configurações", + "titlebar.personas": "Personas", + "titlebar.minimize": "Minimizar", + "titlebar.maximize": "Maximizar", + "titlebar.restore": "Restaurar", + "titlebar.close": "Fechar", + "view.grid": "Visualização em grade", + "view.list": "Visualização em lista", + "settings.title": "Configurações", + "settings.general": "Geral", + "settings.appearance": "Aparência", + "settings.language": "Idioma", + "settings.theme": "Tema", + "settings.uiZoom": "Zoom da interface", + "settings.backgroundOpacity": "Opacidade do fundo", + "settings.animations": "Animações", + "settings.animationsSystem": "Seguir o sistema", + "settings.animationsOn": "Sempre ativas", + "settings.animationsOff": "Desativadas", + "settings.performance": "Desempenho", + "settings.suspendGraphicsWhenMinimized": "Suspender gráficos ao minimizar", + "settings.minimizeOnAccountSwitch": "Minimizar ao trocar de conta", + "settings.platforms": "Plataformas", + "settings.platformSearchPlaceholder": "Buscar plataformas...", + "settings.platformSearchNoResults": "Nenhuma plataforma corresponde à sua busca.", + "settings.accountDisplay": "Exibição das contas", + "settings.startupAndExtras": "Inicialização", + "settings.defaultOnStartup": "Plataforma padrão ao iniciar", + "settings.platformDisabledSuffix": "(desativada)", + "settings.platformUnsupportedOs": "Sistema não suportado", + "settings.platformNotImplemented": "Planejada", + "settings.platformUserProvided": "Adicionada a partir da sua pasta de descritores", + "settings.customPlatforms": "Plataformas personalizadas", + "settings.customPlatformsHint": "Os arquivos de descritor desta pasta são lidos ao iniciar o aplicativo.", + "settings.customPlatformsNone": "Nenhum descritor carregado desta pasta.", + "settings.customPlatformsProblems": "Arquivos que não carregaram", + "descriptor.addFromFile": "Adicionar de um arquivo", + "descriptor.reload": "Recarregar", + "descriptor.openFolder": "Abrir pasta", + "descriptor.remove": "Remover", + "descriptor.removeConfirmTitle": "Remover {platform}?", + "descriptor.removeConfirmMessage": "O arquivo de descritor sai da sua pasta. As contas já capturadas continuam no disco.", + "descriptor.previewTitle": "Adicionar uma plataforma", + "descriptor.previewOs": "Descrita para {list}", + "descriptor.previewRoots": "Pastas em que pode mexer", + "descriptor.previewSteps": "O que uma troca de conta faria", + "descriptor.previewWarnings": "Avisos", + "descriptor.previewNoPlan": "Não foi possível montar uma simulação: {problem}", + "descriptor.previewReplaces": "Isso substitui o descritor que já está na sua pasta.", + "descriptor.previewNothingWritten": "Nada é gravado até você adicionar.", + "descriptor.replace": "Substituir", + "descriptor.installed": "{platform} adicionada", + "descriptor.removed": "{platform} removida", + "descriptor.actionRead": "ler", + "descriptor.actionCapture": "capturar", + "descriptor.actionRestore": "restaurar", + "descriptor.actionDelete": "remover", + "descriptor.actionClose": "fechar", + "descriptor.actionLaunch": "iniciar", + "settings.dataRefresh": "Atualização de dados", + "settings.avatarRefresh": "Atualizar avatares", + "settings.banCheckDelay": "Intervalo da checagem de banimentos", + "settings.refreshNow": "Atualizar agora", + "settings.zeroEachLaunch": "0 = a cada inicialização", + "settings.privacy": "Privacidade", + "settings.inactivityTimeout": "Tempo de inatividade", + "settings.zeroDisabled": "0 = desativado", + "settings.streamerMode": "Modo streamer", + "settings.streamerModeHint": "Desfoca os detalhes das contas automaticamente quando um software de transmissão está em execução.", + "settings.personasHint": "Mostrar o seletor de identidades personas", + "settings.replayOnboarding": "Rever a introdução", + "settings.replayOnboardingHint": "Mostra de novo o tour de boas-vindas e a escolha de estatísticas de uso.", + "streamer.title": "Modo streamer", + "streamer.disable": "Desativar", + "streamer.disablePermanently": "Desativar permanentemente", + "personas.title": "Personas", + "personas.create": "Nova persona", + "personas.activate": "Trocar para esta persona", + "personas.edit": "Editar", + "personas.delete": "Excluir", + "personas.finish": "Concluir", + "personas.switching": "Trocando...", + "personas.switched": "Trocado para {name}", + "personas.switchPartial": "{name}: falhou em {failed}", + "personas.switchFailed": "Não foi possível trocar para {name}", + "personas.namePlaceholder": "Nome da persona", + "personas.choosePlatforms": "Escolha uma conta para cada plataforma incluída", + "personas.chooseAccount": "Escolha a conta {platform}", + "personas.removeFromPersona": "Remover desta persona", + "personas.customImage": "Escolher uma imagem...", + "personas.removeImage": "Remover imagem", + "personas.imageError": "Não foi possível carregar essa imagem.", + "personas.loadingAccounts": "Carregando contas...", + "personas.noAccountsOnPlatform": "Ainda não há contas nesta plataforma", + "personas.emptyHint": "Um clique troca cada plataforma incluída para a conta dela.", + "settings.security": "Segurança", + "settings.pinLockOnAfk": "Bloqueio por PIN ao ficar ausente", + "settings.pinCode": "Código PIN", + "settings.pinPlaceholder": "4 dígitos", + "settings.pinCurrentCode": "PIN atual", + "settings.pinCurrentHint": "Digite o PIN atual para alterar ou desativar o bloqueio.", + "settings.telemetry": "Estatísticas de uso (anônimas)", + "settings.telemetryModeA": "Estatísticas básicas anônimas", + "settings.telemetryModeAHint": "Conta as inicializações e quais recursos são usados. Um ID local aleatório evita contar a mesma instalação duas vezes; nenhum dado pessoal é enviado.", + "settings.telemetryModeB": "Estatísticas avançadas com UUID local (opcional)", + "settings.telemetryModeBHint": "Adiciona um UUID aleatório guardado apenas nesta máquina. Permite medir a retenção e quais recursos importam. Continua sem enviar nenhum dado pessoal.", + "settings.telemetryExport": "Exportar meus dados", + "settings.telemetryExported": "Dados exportados para a área de transferência", + "settings.telemetryExportFailed": "Falha na exportação", + "settings.telemetryEnableFailed": "Não foi possível ativar as estatísticas avançadas", + "settings.telemetryDisableFailed": "Não foi possível desativar as estatísticas avançadas", + "settings.telemetryDeletePending": "As estatísticas avançadas estão desativadas; a exclusão no servidor vai precisar de outra tentativa", + "settings.telemetryDeletePendingHint": "A exclusão no servidor está pendente. O ID local antigo é mantido só para você tentar de novo ou exportar os dados associados.", + "settings.telemetryRetryDelete": "Tentar excluir os dados de novo", + "settings.telemetryDeleteCompleted": "Dados do servidor excluídos", + "settings.telemetryDeleteRetryFailed": "A exclusão no servidor continua pendente", + "settings.telemetryLearnMore": "Quais dados são enviados?", + "onboarding.welcome.title": "Bem-vindo ao Accshift", + "onboarding.welcome.version": "v{version}", + "onboarding.welcome.compatibleWith": "Seu sistema operacional é compatível com:", + "onboarding.welcome.detectedPlatforms": "Estas são as plataformas detectadas no seu computador:", + "onboarding.welcome.next": "Quero conhecer", + "onboarding.welcome.skip": "Pular", + "onboarding.features.hint": "Esta parte do app não está visível agora.", + "onboarding.features.addAccount.label": "Adicionar uma conta", + "onboarding.features.addAccount.body": "O botão + no canto superior direito adiciona uma conta à plataforma atual. Faça login uma vez e o Accshift guarda a conta.", + "onboarding.features.switch.label": "Trocar de conta", + "onboarding.features.switch.body": "Clique em um card e você já está nessa conta. Sinceramente, o app inteiro é isso.", + "onboarding.features.contextMenu.label": "Botão direito para mais ações", + "onboarding.features.contextMenu.body": "Clique com o botão direito em um card para renomear, mudar a cor, mover para uma pasta ou excluir.", + "onboarding.features.platformSwitch.label": "Trocar de plataforma", + "onboarding.features.platformSwitch.body": "Funciona igual para Riot, Battle.net, Epic e o resto. As abas de cima trocam de plataforma.", + "onboarding.features.next": "Avançar", + "onboarding.features.back": "Voltar", + "onboarding.features.step": "Etapa {step}", + "onboarding.features.mockRename": "Renomear", + "onboarding.features.mockSetColor": "Definir cor", + "onboarding.features.mockMoveToFolder": "Mover para pasta", + "onboarding.features.mockDelete": "Excluir", + "onboarding.features.mockAccount": "Conta {number}", + "onboarding.telemetry.title": "VENDA SUA ALMA PARA O ACCSHIFT!", + "onboarding.telemetry.intro": "O Accshift conta algumas estatísticas de uso anônimas para eu saber o que consertar primeiro. Nada pessoal sai da sua máquina, o app funciona igual de qualquer jeito, e você pode mudar de ideia quando quiser nas configurações.", + "onboarding.telemetry.question": "Fechado?", + "onboarding.telemetry.gifAlt": "Meme da proposta de troca", + "onboarding.telemetry.basic": "NÃO! Já chega", + "onboarding.telemetry.basicDefault": "(padrão)", + "onboarding.telemetry.basicHint": "Só contadores anônimos e nada mais: sem perfil, sem identificador que te siga.", + "onboarding.telemetry.deal": "FECHADO", + "onboarding.telemetry.dealHint": "Adiciona um UUID separado para as estatísticas avançadas, guardado só na sua máquina. Continuo sem ideia de quem você é, mas você ajuda muito. Obrigado!", + "onboarding.telemetry.optOutNote": "Escolha o que escolher, dá para mudar depois em Configurações -> Privacidade. :c", + "onboarding.telemetry.refuse": "Não, obrigado, desligue tudo", + "onboarding.telemetry.refuseHint": "Nada é medido nem enviado, nem os contadores anônimos.", + "onboarding.telemetry.back": "Voltar", + "onboarding.persistFailed": "Não foi possível salvar essa escolha. Tente de novo.", + "onboarding.persistLater": "Perguntar na próxima abertura", + "settings.steam": "Steam", + "settings.riot": "Riot", + "settings.battleNet": "Battle.net", + "settings.ubisoft": "Ubisoft Connect", + "settings.runSteamAsAdmin": "Executar o Steam como admin", + "settings.showUsernames": "Mostrar nomes de usuário", + "settings.showSteamLastLogin": "Mostrar último login do Steam", + "settings.showRiotLastLogin": "Mostrar último login da Riot", + "settings.showBattleNetLastLogin": "Mostrar último login da Battle.net", + "settings.showUbisoftLastLogin": "Mostrar último login da Ubisoft", + "settings.showNotesUnderCards": "Mostrar notas sob os cards", + "settings.expandedFolders": "Expandir todas as pastas", + "list.rootSection": "Raiz", + "settings.steamLaunch": "Troca", + "settings.steamInstallation": "Instalação", + "settings.steamApiAndData": "API e dados", + "settings.launchOptions": "Opções de inicialização", + "settings.shutdownMode": "Modo de encerramento", + "settings.shutdownModeGraceful": "Suave", + "settings.shutdownModeForce": "Forçar encerramento", + "settings.autoDetected": "Detectado automaticamente", + "settings.steamFolder": "Pasta do Steam", + "settings.steamFolderRegistry": "Registro", + "settings.riotClientPath": "Executável do Riot Client", + "settings.battleNetPath": "Executável da Battle.net", + "settings.ubisoftPath": "Pasta do Ubisoft Connect", + "settings.steamWebApiKey": "Chave da Steam Web API", + "settings.getKey": "Obter chave", + "settings.pasteApiKey": "Cole sua chave da Steam Web API", + "settings.saved": "Configurações salvas", + "settings.openApiKeyFailed": "Não foi possível abrir a página da chave da API Steam", + "settings.integrations": "Integrações", + "settings.deepLinks": "Permitir links accshift://", + "settings.deepLinksHint": "Deixe outros apps trocarem de conta por você: favoritos, Stream Deck, scripts.", + "settings.cliEnabled": "Permitir a CLI do accshift", + "settings.cliEnabledHint": "Deixa a ferramenta de linha de comando listar e trocar de contas. Desativado, todo comando accshift se recusa a rodar, diag incluído; o aplicativo em si não é afetado.", + "settings.help": "Abrir a wiki do accshift", + "settings.helpShort": "Ajuda", + "settings.openHelpFailed": "Não foi possível abrir a wiki", + "settings.logs": "Logs", + "settings.openLogs": "Abrir a pasta de logs", + "settings.openLogsFailed": "Não foi possível abrir a pasta de logs", + "context.loading": "Carregando...", + "context.noGamesFound": "Nenhum jogo encontrado", + "context.menu.newFolder": "Nova pasta", + "context.menu.refresh": "Atualizar", + "context.menu.rename": "Renomear", + "context.menu.copy": "Copiar...", + "context.menu.deleteFolder": "Excluir pasta", + "context.menu.folderColor": "Cor da pasta", + "context.menu.moveToFolder": "Mover para uma pasta", + "context.menu.moveToRoot": "Sem pasta", + "context.menu.editCardAndColor": "Editar card e cor", + "context.menu.cardColor": "Cor do card", + "context.menu.addNote": "Adicionar nota", + "context.menu.editNote": "Editar nota", + "platform.rename": "Renomear", + "platform.renameTitle": "Renomear conta", + "platform.renamePlaceholder": "Nome da conta", + "platform.newAccountPending": "Aguardando...", + "platform.setupBusy": "Outra operação do accshift está em andamento. Aguardando que ela termine.", + "platform.renameNewAccount": "Dê um nome a esta conta", + "platform.copyLabelAccountId": "ID da conta", + "platform.forget": "Esquecer", + "platform.forgetConfirmTitle": "Esquecer \"{display}\"?", + "platform.forgetConfirmMessage": "Isso vai remover esta conta e a sessão salva do Accshift.", + "platform.forgotAccount": "{display} esquecida", + "dialog.newFolderTitle": "Nova pasta", + "dialog.renameFolderTitle": "Renomear pasta", + "dialog.folderNamePlaceholder": "Nome da pasta", + "dialog.cardNoteTitle": "Nota do card", + "dialog.cardNotePlaceholder": "Escreva uma nota curta", + "card.noteAttached": "Nota anexada", + "card.clickAgainToSwitch": "Clique de novo para trocar", + "card.extensionWarnings": "Avisos", + "card.extensionNote": "Nota", + "card.cs2Section": "CS2", + "card.cs2Level": "Nível {level}", + "card.cs2CaseEarned": "Caixa semanal obtida", + "card.cs2CaseNotEarned": "Caixa semanal pendente", + "list.selectAccountPreview": "Selecione uma conta para pré-visualizar", + "preview.noteLabel": "Nota", + "preview.currentlyActive": "Ativa no momento", + "preview.switchAgain": "Trocar de novo", + "preview.switchAccount": "Trocar de conta", + "ban.community": "Banimento da comunidade", + "ban.vac.single": "{count} banimento VAC", + "ban.vac.multiple": "{count} banimentos VAC", + "ban.game.single": "{count} banimento de jogo", + "ban.game.multiple": "{count} banimentos de jogo", + "time.unknown": "desconhecido", + "time.justNow": "agora mesmo", + "time.minuteAgo": "há {count} minuto", + "time.minutesAgo": "há {count} minutos", + "time.hourAgo": "há {count} hora", + "time.hoursAgo": "há {count} horas", + "time.dayAgo": "há {count} dia", + "time.daysAgo": "há {count} dias", + "time.neverConnected": "nunca conectado", + "time.compactWrapper": "({value})", + "toast.copied": "{label} copiado", + "toast.legacyConfigMigrated": "Configurações migradas para o novo formato", + "toast.legacyConfigMigrationFailed": "Falha ao migrar as configurações antigas, veja os logs", + "toast.failedLoadSettingsPanel": "Falha ao carregar o painel de configurações", + "toast.banChecking": "Checando banimentos...", + "toast.banCheckSummary.single": "Checagem de banimentos: {count} conta com banimentos", + "toast.banCheckSummary.multiple": "Checagem de banimentos: {count} contas com banimentos", + "toast.banCheckFailed": "Falha na checagem de banimentos: {error}", + "toast.avatarRefreshComplete": "Atualização de avatares concluída para {count} contas", + "toast.banRefreshComplete": "Atualização de banimentos concluída para {count} contas", + "toast.banRefreshNoApiKey": "Atualização de banimentos ignorada: adicione uma chave da Steam Web API nas configurações", + "toast.refreshPartial": "Atualização concluída para {ok} de {count} contas", + "toast.noSteamAccountsFound": "Nenhuma conta Steam encontrada. Entre no Steam pelo menos uma vez e atualize.", + "toast.accountsRefreshed.single": "{count} conta atualizada", + "toast.accountsRefreshed.multiple": "{count} contas atualizadas", + "toast.steamFolderNotFound": "A pasta do Steam não foi encontrada. Defina manualmente em Configurações > Pasta do Steam.", + "toast.steamElevated": "O Steam está rodando como admin. Ative 'Executar o Steam como admin' em Configurações > Steam.", + "toast.noRiotProfilesFound": "Nenhum perfil Riot ainda. Clique em + para adicionar um.", + "toast.noBattleNetAccountsFound": "Nenhuma conta Battle.net encontrada. Entre na Battle.net pelo menos uma vez e atualize.", + "toast.noUbisoftAccountsFound": "Nenhuma conta Ubisoft encontrada. Entre no Ubisoft Connect pelo menos uma vez e atualize.", + "toast.deepLinkInvalid": "Link accshift:// não reconhecido", + "toast.deepLinkDisabled": "Os links accshift:// estão desativados nas configurações", + "toast.deepLinkUnknownPlatform": "Plataforma desconhecida no link: {platform}", + "toast.deepLinkPlatformDisabled": "{platform} está desativada nas configurações", + "toast.deepLinkAccountNotFound": "Nenhuma conta {platform} corresponde a \"{account}\"", + "toast.deepLinkSwitched": "Trocado para {account}", + "dialog.deepLinkSwitchTitle": "Trocar de conta?", + "dialog.deepLinkSwitchMessage": "Um link accshift:// está pedindo para trocar sua conta {platform} para \"{account}\". Continuar?", + "dialog.deepLinkSwitchConfirm": "Trocar", + "steam.launchOnline": "Trocar online", + "steam.launchInvisible": "Trocar invisível", + "steam.switchAndRunGame": "Trocar + jogar {game}", + "steam.switchAndRunPick": "Trocar + jogar um jogo…", + "steam.runGame": "Jogar {game}", + "steam.runPick": "Jogar um jogo…", + "steam.switchAndRunNoGames": "Nenhum jogo encontrado", + "steam.clearDefaultGame": "Limpar jogo padrão", + "steam.copySteamId64": "Copiar SteamID64", + "steam.copyFriendCode": "Copiar código de amigo", + "steam.copyProfileUrl": "Copiar URL do perfil", + "steam.copyLabelUsername": "Nome de usuário", + "steam.copyLabelSteamId64": "SteamID64", + "steam.copyLabelFriendCode": "Código de amigo", + "steam.copyLabelProfileUrl": "URL do perfil", + "steam.openUserdataFolder": "Abrir a pasta userdata", + "steam.clearIntegratedBrowserCache": "Redefinir sessão do navegador", + "steam.clearIntegratedBrowserCacheConfirmTitle": "Redefinir a sessão do navegador do Steam?", + "steam.clearIntegratedBrowserCacheConfirmMessage": "Isso fecha o Steam e limpa o cache do navegador integrado usado pela Loja e pela Comunidade.", + "steam.clearedIntegratedBrowserCache": "Sessão do navegador do Steam redefinida", + "steam.copySettingsFrom": "Copiar configurações de", + "steam.copiedSettingsToCurrent": "Configurações de {game} copiadas para a conta atual", + "steam.forget": "Esquecer", + "steam.forgetConfirmTitle": "Esquecer \"{display}\"?", + "steam.forgetConfirmMessage": "Isso vai remover esta conta da lista de contas Steam deste PC.", + "steam.forgotAccount": "{username} esquecida", + "steam.setupPendingLabel": "Conta Steam", + "steam.setupWaitingForClient": "Aguardando o Steam...", + "steam.setupWaitingForLogin": "Entre na conta Steam que você quer adicionar.", + "steam.setupDetected": "Conta detectada", + "steam.setupConnected": "Conectado", + "steam.setupReady": "Conta Steam adicionada", + "steam.setupReadyWithProfile": "{profile} adicionada", + "steam.setupFailed": "Falha na configuração do Steam", + "steam.setupFailedMessage": "O Accshift não conseguiu terminar de adicionar esta conta Steam.", + "riot.captureSession": "Capturar a sessão atual", + "riot.recaptureSession": "Recapturar sessão", + "riot.capturedSession": "Sessão capturada para {profile}", + "riot.setupPendingLabel": "Conta Riot", + "riot.setupWaitingForClient": "Aguardando o Riot Client...", + "riot.setupWaitingForLogin": "Entre na conta Riot que você quer adicionar.", + "riot.setupCapturing": "Salvando a sessão Riot...", + "riot.setupReady": "Conta Riot adicionada", + "riot.setupReadyWithProfile": "{profile} adicionada", + "riot.setupFailed": "Falha na configuração da Riot", + "riot.setupFailedMessage": "O Accshift não conseguiu terminar de adicionar esta conta Riot.", + "riot.setupStaySignedIn": "Mantenha 'Continuar conectado' ativado.", + "riot.setupDetected": "Conta detectada", + "riot.setupConnected": "Conectado", + "riot.forget": "Esquecer conta", + "riot.forgetConfirmTitle": "Esquecer \"{display}\"?", + "riot.forgetConfirmMessage": "Isso remove o perfil Riot local e a sessão salva do Accshift.", + "riot.forgotProfile": "{profile} esquecido", + "battlenet.copyUsername": "Copiar nome de usuário", + "battlenet.copyLabelUsername": "Nome de usuário", + "battlenet.copyEmail": "Copiar e-mail", + "battlenet.copyLabelEmail": "E-mail", + "battlenet.forget": "Esquecer", + "battlenet.forgetConfirmTitle": "Esquecer \"{display}\"?", + "battlenet.forgetConfirmMessage": "Isso vai remover esta conta da lista de contas Battle.net deste PC.", + "battlenet.forgotAccount": "{email} esquecida", + "battlenet.setupPendingLabel": "Conta Battle.net", + "battlenet.setupWaitingForClient": "Aguardando a Battle.net...", + "battlenet.setupWaitingForLogin": "Entre na conta Battle.net que você quer adicionar.", + "battlenet.setupKeepMeLoggedIn": "Mantenha 'Continuar conectado' ativado quando possível.", + "battlenet.setupDetected": "Conta detectada", + "battlenet.setupConnected": "Conectado", + "battlenet.setupReady": "Conta Battle.net adicionada", + "battlenet.setupReadyWithProfile": "{profile} adicionada", + "battlenet.setupFailed": "Falha na configuração da Battle.net", + "battlenet.setupFailedMessage": "O Accshift não conseguiu terminar de adicionar esta conta Battle.net.", + "ubisoft.copyLabelUuid": "UUID", + "ubisoft.forget": "Esquecer", + "ubisoft.forgetConfirmTitle": "Esquecer \"{display}\"?", + "ubisoft.forgetConfirmMessage": "Isso vai remover esta conta e a sessão salva do Accshift.", + "ubisoft.forgotAccount": "{display} esquecida", + "ubisoft.setupPendingLabel": "Conta Ubisoft", + "ubisoft.setupWaitingForClient": "Aguardando o Ubisoft Connect...", + "ubisoft.setupWaitingForLogin": "Entre na conta Ubisoft que você quer adicionar.", + "ubisoft.setupKeepMeLoggedIn": "Mantenha 'Lembrar de mim' ativado.", + "ubisoft.setupDetected": "Conta detectada", + "ubisoft.setupConnected": "Conectado", + "ubisoft.setupReady": "Conta Ubisoft adicionada", + "ubisoft.setupReadyWithProfile": "{profile} adicionada", + "ubisoft.setupFailed": "Falha na configuração da Ubisoft", + "ubisoft.setupFailedMessage": "O Accshift não conseguiu terminar de adicionar esta conta Ubisoft.", + "settings.roblox": "Roblox", + "toast.noRobloxAccountsFound": "Nenhuma conta Roblox encontrada. Adicione uma conta pelo Quick Login ou cole um cookie.", + "roblox.noAccountsHint": "Pressione + para adicionar uma conta Roblox pelo Quick Login.", + "roblox.sessionExpired": "Sessão expirada", + "roblox.sessionExpiredTooltip": "Esta conta está desconectada. Adicione de novo antes de trocar.", + "roblox.sessionExpiredSwitchError": "Sessão expirada. Adicione esta conta de novo para trocar. As notas e configurações são mantidas.", + "roblox.cookiePasteTitle": "Adicionar conta por cookie", + "roblox.cookiePasteHint": "Cole um cookie .ROBLOSECURITY do seu navegador para adicionar uma conta direto.", + "settings.showRobloxLastLogin": "Mostrar último login", + "settings.robloxHealthCheck": "Checagem de sessão", + "settings.robloxHealthCheckHint": "Sinaliza as contas cuja sessão Roblox expirou; uma requisição de rede por inicialização.", + "roblox.copyLabelUsername": "Nome de usuário", + "roblox.copyLabelUserId": "ID do usuário", + "roblox.forget": "Esquecer", + "roblox.forgetConfirmTitle": "Esquecer \"{display}\"?", + "roblox.forgetConfirmMessage": "Isso vai remover esta conta e o cookie guardado deste PC.", + "roblox.forgotAccount": "{display} esquecida", + "roblox.setupPendingLabel": "Conta Roblox", + "roblox.setupWaitingForClient": "Conectando ao Roblox...", + "roblox.setupWaitingForLogin": "Abra o app do Roblox em outro dispositivo e digite o código abaixo.", + "roblox.setupQuickLoginCode": "Código do Quick Login", + "roblox.setupQuickLoginHint": "roblox.com/quick-login", + "roblox.copyCode": "Copiar código", + "roblox.setupDetected": "Conta detectada", + "roblox.setupConnected": "Conectado", + "roblox.setupReady": "Conta Roblox adicionada", + "roblox.setupReadyWithProfile": "{profile} adicionada", + "roblox.setupFailed": "Falha na configuração do Roblox", + "roblox.setupFailedMessage": "O Accshift não conseguiu terminar de adicionar esta conta Roblox.", + "settings.epic": "Epic Games", + "settings.epicPath": "Executável do Epic Games Launcher", + "settings.showEpicLastLogin": "Mostrar último login", + "toast.noEpicAccountsFound": "Nenhuma conta Epic Games encontrada. Entre no Epic Games Launcher pelo menos uma vez e atualize.", + "epic.noAccountsHint": "Pressione + para adicionar uma conta Epic Games.", + "epic.copyLabelAccountId": "ID da conta", + "epic.forget": "Esquecer", + "epic.forgetConfirmTitle": "Esquecer \"{display}\"?", + "epic.forgetConfirmMessage": "Isso vai remover esta conta e a sessão salva do Accshift.", + "epic.forgotAccount": "{display} esquecida", + "epic.setupPendingLabel": "Conta Epic Games", + "epic.setupWaitingForClient": "Aguardando o Epic Games Launcher...", + "epic.setupWaitingForLogin": "Entre na conta Epic Games que você quer adicionar.", + "epic.setupKeepMeLoggedIn": "Mantenha 'Lembrar de mim' ativado.", + "epic.setupDetected": "Conta detectada", + "epic.setupConnected": "Conectado", + "epic.setupReady": "Conta Epic Games adicionada", + "epic.setupReadyWithProfile": "{profile} adicionada", + "epic.setupFailed": "Falha na configuração da Epic Games", + "epic.setupFailedMessage": "O Accshift não conseguiu terminar de adicionar esta conta Epic Games.", + "settings.gog": "GOG Galaxy", + "settings.gogPath": "Executável do GOG Galaxy", + "settings.showGogLastLogin": "Mostrar último login", + "toast.noGogAccountsFound": "Nenhuma conta GOG Galaxy encontrada. Entre no GOG Galaxy pelo menos uma vez e atualize.", + "gog.noAccountsHint": "Pressione + para adicionar uma conta GOG Galaxy.", + "gog.copyLabelAccountId": "ID da conta", + "gog.forget": "Esquecer", + "gog.forgetConfirmTitle": "Esquecer \"{display}\"?", + "gog.forgetConfirmMessage": "Isso vai remover esta conta e a sessão salva do Accshift.", + "gog.forgotAccount": "{display} esquecida", + "gog.setupPendingLabel": "Conta GOG Galaxy", + "gog.setupWaitingForClient": "Aguardando o GOG Galaxy...", + "gog.setupWaitingForLogin": "Entre na conta GOG Galaxy que você quer adicionar.", + "gog.setupKeepMeLoggedIn": "Mantenha 'Continuar conectado' ativado.", + "gog.setupDetected": "Conta detectada", + "gog.setupConnected": "Conectado", + "gog.setupReady": "Conta GOG Galaxy adicionada", + "gog.setupReadyWithProfile": "{profile} adicionada", + "gog.setupFailed": "Falha na configuração do GOG Galaxy", + "gog.setupFailedMessage": "O Accshift não conseguiu terminar de adicionar esta conta GOG Galaxy.", + "settings.jagex": "Jagex Launcher", + "settings.jagexPath": "Executável do Jagex Launcher", + "settings.showJagexLastLogin": "Mostrar último login", + "toast.noJagexAccountsFound": "Nenhuma conta Jagex salva ainda. Pressione + para adicionar uma pelo Jagex Launcher.", + "jagex.noAccountsHint": "Pressione + para adicionar uma conta Jagex.", + "jagex.copyLabelAccountId": "ID da conta", + "jagex.forget": "Esquecer", + "jagex.forgetConfirmTitle": "Esquecer \"{display}\"?", + "jagex.forgetConfirmMessage": "Isso vai remover esta conta e a sessão salva do Accshift.", + "jagex.forgotAccount": "{display} esquecida", + "jagex.setupPendingLabel": "Conta Jagex", + "jagex.setupWaitingForClient": "Aguardando o Jagex Launcher...", + "jagex.setupWaitingForLogin": "Entre na conta Jagex que você quer adicionar.", + "jagex.setupKeepMeLoggedIn": "Mantenha 'Lembrar de mim' ativado.", + "jagex.setupDetected": "Conta detectada", + "jagex.setupConnected": "Conectado", + "jagex.setupReady": "Conta Jagex adicionada", + "jagex.setupReadyWithProfile": "{profile} adicionada", + "jagex.setupFailed": "Falha na configuração da Jagex", + "jagex.setupFailedMessage": "O Accshift não conseguiu terminar de adicionar esta conta Jagex.", + "settings.discord": "Discord", + "settings.discordPath": "Executável do Discord", + "settings.showDiscordLastLogin": "Mostrar último login", + "toast.noDiscordAccountsFound": "Nenhuma conta Discord encontrada. Adicione uma conta Discord e atualize.", + "discord.noAccountsHint": "Pressione + para adicionar uma conta Discord.", + "discord.copyLabelAccountId": "ID da conta", + "discord.forget": "Esquecer", + "discord.forgetConfirmTitle": "Esquecer \"{display}\"?", + "discord.forgetConfirmMessage": "Isso vai remover esta conta e a sessão salva do Accshift.", + "discord.forgotAccount": "{display} esquecida", + "discord.setupPendingLabel": "Conta Discord", + "discord.setupWaitingForClient": "Aguardando o Discord...", + "discord.setupWaitingForLogin": "Entre na conta Discord que você quer adicionar.", + "discord.setupKeepMeLoggedIn": "Continue conectado para manter esta conta.", + "discord.setupDetected": "Conta detectada", + "discord.setupConnected": "Conectado", + "discord.setupReady": "Conta Discord adicionada", + "discord.setupReadyWithProfile": "{profile} adicionada", + "discord.setupFailed": "Falha na configuração do Discord", + "discord.setupFailedMessage": "O Accshift não conseguiu terminar de adicionar esta conta Discord.", + "color.none": "Padrão", + "color.blue": "Azul", + "color.cyan": "Ciano", + "color.green": "Verde", + "color.lime": "Lima", + "color.yellow": "Amarelo", + "color.orange": "Laranja", + "color.red": "Vermelho", + "color.pink": "Rosa", + "color.violet": "Violeta", + "color.gray": "Cinza", + "language.english": "English", + "language.french": "Français", + "language.spanish": "Español", + "language.portuguese": "Português (Portugal)", + "language.portugueseBrazil": "Português (Brasil)", + "language.russian": "Русский", + "language.chinese": "简体中文", + "theme.dark": "Escuro", + "theme.light": "Claro", + "theme.midnight": "Meia-noite", + "theme.glassDark": "Vidro escuro", + "theme.glassLight": "Vidro claro", + "theme.liquidGlass": "Vidro líquido", + "theme.custom": "Personalizado", + "settings.themeImport": "Importar", + "settings.themeExport": "Exportar", + "settings.themeDelete": "Excluir tema", + "settings.themeImported": "Tema importado", + "settings.themeDeleted": "Tema excluído", + "settings.themeExportCopied": "JSON do tema copiado para a área de transferência", + "settings.themeInvalidJson": "JSON de tema inválido", + "settings.themeCustomize": "Personalizar", + "settings.themeEdit": "Editar", + "settings.themeCopyName": "{name} cópia", + "settings.themeSaved": "Tema salvo", + "settings.themeMigrated": "Tema importado e atualizado para o formato atual", + "settings.themeInvalidId": "O identificador do tema está faltando ou é inválido", + "settings.themeInvalidName": "O tema não tem nome", + "settings.themeUnsupportedVersion": "Este tema precisa de uma versão mais recente do accshift", + "themeGroup.surface": "Superfícies", + "themeGroup.text": "Texto", + "themeGroup.semantic": "Cores de status", + "themeGroup.shape": "Forma e densidade", + "themeGroup.typography": "Tipografia", + "themeGroup.motion": "Movimento", + "themeKind.rgbTriplet": "trio RGB", + "themeKind.hexColor": "cor hexadecimal", + "themeKind.color": "cor", + "themeKind.length": "comprimento", + "themeKind.shadow": "sombra", + "themeKind.choice": "opção", + "themeKind.fontStack": "fonte", + "themeKind.signedLength": "espaçamento", + "themeKind.gradient": "gradiente", + "themeKind.number": "número", + "themeDensity.compact": "Compacta", + "themeDensity.cozy": "Normal", + "themeDensity.comfortable": "Ampla", + "themeAvatarShape.circle": "Redondo", + "themeAvatarShape.rounded": "Arredondado", + "themeAvatarShape.square": "Quadrado", + "themeSmoothing.auto": "Suavizado", + "themeSmoothing.none": "Pixel", + "themeToken.bgRgb": "Fundo da janela", + "themeToken.bgCard": "Fundo dos cartões", + "themeToken.bgCardHover": "Fundo dos cartões ao passar o cursor", + "themeToken.bgMuted": "Fundo atenuado", + "themeToken.bgElevated": "Fundo elevado", + "themeToken.border": "Bordas", + "themeToken.fg": "Texto principal", + "themeToken.fgMuted": "Texto secundário", + "themeToken.fgSubtle": "Texto discreto", + "themeToken.afkText": "Etiqueta de ausente", + "themeToken.accent": "Destaque", + "themeToken.accentFg": "Texto sobre o destaque", + "themeToken.success": "Sucesso", + "themeToken.warning": "Aviso", + "themeToken.danger": "Perigo", + "themeToken.radiusSm": "Raio pequeno", + "themeToken.radiusMd": "Raio médio", + "themeToken.radiusLg": "Raio grande", + "themeToken.elevationLow": "Sombra leve", + "themeToken.elevationMedium": "Sombra média", + "themeToken.elevationHigh": "Sombra forte", + "themeToken.density": "Densidade", + "themeToken.fontUi": "Fonte da interface", + "themeToken.bgImage": "Gradiente da janela", + "themeToken.cardBgImage": "Gradiente das superfícies", + "themeToken.borderWidth": "Espessura das bordas", + "themeToken.borderStyle": "Estilo das bordas", + "themeToken.avatarShape": "Forma dos avatares", + "themeToken.focusRing": "Contorno de foco", + "themeToken.overlayBlur": "Desfoque do fundo", + "themeToken.fontDisplay": "Fonte dos títulos", + "themeToken.lineHeight": "Altura da linha", + "themeToken.letterSpacing": "Espaçamento entre letras", + "themeToken.labelCase": "Maiúsculas dos rótulos", + "themeToken.fontSmoothing": "Suavização do texto", + "themeToken.motionScale": "Velocidade das animações", + "themeEditor.title": "Editor de temas", + "themeEditor.name": "Nome", + "themeEditor.author": "Autor", + "themeEditor.version": "Versão", + "themeEditor.base": "Herda de", + "themeEditor.colorScheme": "Modo de cor", + "themeEditor.glass": "Superfícies translúcidas", + "themeEditor.inherited": "Herdado", + "themeEditor.builtInBase": "Tema integrado", + "themeEditor.reset": "Voltar ao valor herdado", + "themeEditor.issues": "Verificações", + "themeEditor.noIssues": "Nada a relatar", + "themeEditor.fixErrorsFirst": "Corrija os valores recusados antes de salvar", + "themeEditor.save": "Salvar", + "themeEditor.saveFailed": "Não foi possível salvar o tema. Tente de novo.", + "themeEditor.export": "Copiar", + "themeEditor.issueInvalidValue": "{token}: {kind} inválido, por exemplo {example}", + "themeEditor.issueUnknownToken": "{token} não é um valor de tema, ele é ignorado", + "themeEditor.issueMissingToken": "{token} não está definido, o valor integrado é usado", + "themeEditor.issueUnknownBase": "O tema base {base} não existe, os valores integrados são usados", + "themeEditor.issueContrast": "{token} sobre {against}: {ratio} para 1, abaixo da meta de {target} para 1", + "themeEditor.beta": "Beta", + "themeEditor.betaNote": "O formato de tema ainda muda: um tema escrito hoje pode precisar de um ajuste depois de uma atualização.", + "themeEditor.customCss": "CSS personalizado", + "themeEditor.customCssHelp": "Aplicado sobre o tema, para o que os valores acima não alcançam. Sem @import nem url(): um tema nunca baixa nada.", + "themeEditor.issueUnsafeCss": "{construct} não é permitido em um tema, o CSS não é aplicado", + "themeEditor.issueCssTooLong": "CSS muito longo: {length} caracteres, o limite é {limit}", + "bulkEdit.title": "Edição em massa", + "bulkEdit.selectAccounts": "Selecionar contas", + "bulkEdit.selectAll": "Selecionar tudo", + "bulkEdit.deselectAll": "Desmarcar tudo", + "bulkEdit.next": "Avançar", + "bulkEdit.apply": "Aplicar", + "bulkEdit.settings": "Configurações", + "bulkEdit.newsPopup": "Pop-up de notícias do jogo", + "bulkEdit.doNotDisturb": "Não perturbe", + "bulkEdit.launchOptions": "Opções de inicialização por jogo", + "bulkEdit.unchanged": "Sem alteração", + "bulkEdit.addLaunchOption": "Adicionar", + "bulkEdit.selectGame": "Selecione um jogo...", + "bulkEdit.launchOptionsPlaceholder": "-fullscreen", + "bulkEdit.activeAccountWarning": "A conta ativa no momento está selecionada. As mudanças serão aplicadas no próximo reinício do Steam.", + "bulkEdit.toastSuccess": "Edição em massa: {count} com sucesso", + "bulkEdit.toastPartial": "Edição em massa: {succeeded} com sucesso, {failed} com falha", + "bulkEdit.toastFailed": "Falha na edição em massa", + "bulkEdit.noAccountsSelected": "Nenhuma conta selecionada", + "bulkEdit.selected": "{count} selecionadas", + "bulkEdit.loadingGames": "Carregando jogos...", + "bulkEdit.copyUrls": "Copiar URLs", + "bulkEdit.urlsCopied": "{count} URL(s) de perfil copiada(s)", + "bulkEdit.cardColor": "Cor do card", + "bulkEdit.colorApplied": "Cor aplicada a {count} card(s)", + "bulkEdit.colorCleared": "Cor removida de {count} card(s)", + "common.retry": "Tentar de novo", + "app.searchNoResults": "Nenhum resultado para \"{query}\"", + "app.folderEmpty": "Esta pasta está vazia", + "app.loadErrorGeneric": "Falha ao carregar as contas. Veja o console para mais detalhes.", + "toast.dismiss": "Dispensar", + "toast.switchFailed": "Falha ao trocar de conta. Veja o console para mais detalhes.", + "toast.addAccountFailed": "Não foi possível adicionar a conta. Veja os logs do app para mais detalhes.", + "toast.addAccountClientMissing": "O {platform} não parece estar instalado. Instale-o ou defina o local dele nas configurações.", + "toast.banCheckFailedGeneric": "Falha na checagem de banimentos. Veja o console para mais detalhes.", + "toast.renameAccountFailed": "Falha ao renomear: {error}", + "toast.copyFailed": "Não foi possível copiar para a área de transferência", + "toast.refreshFailed": "Falha ao atualizar. Veja o console para mais detalhes.", + "dialog.deleteFolderTitle": "Excluir \"{name}\"?", + "dialog.deleteFolderMessage": "As subpastas também são excluídas. As contas de dentro sobem um nível, não são removidas.", + "personas.deleteConfirm": "Clique de novo para excluir", + "personas.discardConfirm": "Clique de novo para descartar", + "personas.switchConfirmTitle": "Trocar de persona", + "personas.switchConfirmMessage": "Trocar {count} plataforma(s) para \"{name}\"? Cada cliente vai fechar e reabrir.", + "personas.switchConfirmAction": "Trocar", + "personas.deleteTitle": "Excluir persona", + "personas.deleteMessage": "Excluir \"{name}\"? As contas em si não são afetadas.", + "context.submenuError": "Não foi possível carregar este menu", + "settings.apiKeySaveFailed": "Não foi possível salvar a chave da API Steam", + "settings.apiKeyInvalidHint": "A chave não foi salva. Verifique e tente de novo.", + "settings.apiKeyCleared": "Chave da API Steam removida", + "settings.apiKeyClearFailed": "Não foi possível remover a chave da API Steam", + "settings.clearApiKey": "Limpar chave", + "settings.cs2Bridge": "Bridge de estatísticas do CS2", + "settings.cs2BridgeLink": "Link da bridge", + "settings.cs2BridgeLinkPlaceholder": "https://seu-servidor/api/bridge/accshift/", + "settings.cs2BridgeTest": "Testar", + "settings.cs2BridgeStatusOk": "Conectado · {count} contas · {ms} ms", + "settings.cs2BridgeStatusFail": "Falha na conexão", + "settings.cs2BridgeStatusTesting": "Testando...", + "settings.cs2BridgeStatusUnknown": "Não testado", + "settings.cs2BridgeToken": "Token da API", + "settings.cs2BridgeTokenOptional": "opcional, para servidores personalizados", + "settings.cs2BridgeTokenPlaceholder": "Token Bearer, se o seu servidor precisar", + "settings.cs2BridgeSaveFailed": "Não foi possível salvar as configurações da bridge do CS2", + "settings.cs2BridgeClearToken": "Limpar token", + "settings.pathSaveFailed": "Não foi possível salvar o caminho do {platform}", + "settings.pathInvalidHint": "Não foi possível salvar este caminho. Verifique e tente de novo.", + "settings.pathSteamNeverSignedIn": "Steam encontrado, entre uma vez para que ele crie o histórico de login", + "settings.pathNotSteamFolder": "Esta pasta não parece uma instalação do Steam", + "settings.pathNotADirectory": "O caminho precisa ser uma pasta existente", + "settings.pinSaved": "PIN salvo", + "settings.pinTakesEffectNextLaunch": "Tem efeito na próxima vez que o app iniciar", + "settings.pinRequiredAfterInactivity": "Exigido após inatividade e quando o app inicia", + "settings.pinScreenLockOnly": "O PIN bloqueia esta janela e as trocas de conta. Ele não protege nada no disco.", + "settings.unitSeconds": "segundos", + "settings.unitDays": "dias", + "settings.telemetryLoadFailed": "Não foi possível carregar as configurações de telemetria", + "palette.placeholder": "Digite um comando ou nome de conta...", + "palette.noResults": "Nenhum comando correspondente", + "palette.sectionAccounts": "Contas", + "palette.sectionActions": "Ações", + "palette.sectionNavigation": "Navegação", + "palette.openTab": "Ir para {platform}", + "palette.openFolder": "Abrir a pasta \"{name}\"", + "palette.backToParent": "Voltar para a pasta anterior", + "palette.zoomReset": "Redefinir zoom" +} diff --git a/crates/accshift-app/i18n/pt.json b/crates/accshift-app/i18n/pt.json new file mode 100644 index 0000000..29b2540 --- /dev/null +++ b/crates/accshift-app/i18n/pt.json @@ -0,0 +1,741 @@ +{ + "app.loadingSettings": "A carregar definições...", + "app.loading": "A carregar...", + "app.searchPlaceholder": "Procurar conta...", + "app.noAccountsFound": "Nenhuma conta {platform} encontrada", + "app.noAccountsHint": "Confirma que {platform} está instalado e que já iniciaste sessão pelo menos uma vez.", + "app.switchingAccount": "A mudar de conta...", + "app.platformRestarting": "{platform} está a reiniciar", + "app.comingSoon": "{platform} - Brevemente", + "app.platformUnsupportedOs": "{platform} ainda não está disponível neste SO", + "app.noCompatiblePlatforms": "Nenhuma plataforma compatível está disponível neste SO", + "app.noCompatiblePlatformsHint": "Vê as atualizações de suporte para Windows ou usa uma máquina compatível.", + "update.ctaAvailable": "Atualização disponível", + "update.ctaInstalling": "A instalar...", + "update.restartToApplyVersion": "Reiniciar para aplicar a atualização {version}", + "update.restartToApply": "Reiniciar para aplicar a atualização", + "update.readyToastVersion": "Atualização {version} pronta", + "update.readyToast": "Atualização pronta", + "update.restartFailed": "Não foi possível reiniciar para aplicar a atualização", + "pin.lockedTitle": "Aplicação bloqueada", + "pin.lockedPrompt": "Introduz o PIN para desbloquear", + "pin.placeholder": "PIN de 4 dígitos", + "pin.unlock": "Desbloquear", + "pin.invalid": "PIN inválido", + "pin.tooManyAttempts": "Demasiadas tentativas. Tente novamente dentro de {seconds} s.", + "common.ok": "OK", + "common.cancel": "Cancelar", + "common.confirm": "Confirmar", + "common.close": "Fechar", + "common.back": "Voltar", + "common.active": "Ativa", + "common.choose": "Escolher...", + "common.none": "Nenhuma", + "common.custom": "Personalizado", + "common.configured": "Configurado", + "common.missing": "Em falta", + "common.enabled": "Ativado", + "common.disabled": "Desativado", + "common.on": "Ligado", + "common.off": "Desligado", + "common.add": "Adicionar", + "common.visible": "Visível", + "common.hidden": "Oculto", + "common.inline": "No cartão", + "common.tooltip": "Dica", + "titlebar.refresh": "Atualizar", + "titlebar.addAccount": "Adicionar conta", + "titlebar.settings": "Definições", + "titlebar.personas": "Personas", + "titlebar.minimize": "Minimizar", + "titlebar.maximize": "Maximizar", + "titlebar.restore": "Restaurar", + "titlebar.close": "Fechar", + "view.grid": "Vista de grelha", + "view.list": "Vista de lista", + "settings.title": "Definições", + "settings.general": "Geral", + "settings.appearance": "Aspeto", + "settings.language": "Idioma", + "settings.theme": "Tema", + "settings.uiZoom": "Zoom da interface", + "settings.backgroundOpacity": "Opacidade do fundo", + "settings.animations": "Animações", + "settings.animationsSystem": "Seguir o sistema", + "settings.animationsOn": "Sempre ativas", + "settings.animationsOff": "Desligadas", + "settings.performance": "Desempenho", + "settings.suspendGraphicsWhenMinimized": "Suspender gráficos quando minimizado", + "settings.minimizeOnAccountSwitch": "Minimizar ao mudar de conta", + "settings.platforms": "Plataformas", + "settings.platformSearchPlaceholder": "Procurar plataformas...", + "settings.platformSearchNoResults": "Nenhuma plataforma corresponde à pesquisa.", + "settings.accountDisplay": "Apresentação das contas", + "settings.startupAndExtras": "Arranque", + "settings.defaultOnStartup": "Plataforma predefinida no arranque", + "settings.platformDisabledSuffix": "(desativada)", + "settings.platformUnsupportedOs": "SO não suportado", + "settings.platformNotImplemented": "Planeado", + "settings.platformUserProvided": "Adicionada a partir da sua pasta de descritores", + "settings.customPlatforms": "Plataformas personalizadas", + "settings.customPlatformsHint": "Os ficheiros de descritor desta pasta são lidos ao iniciar a aplicação.", + "settings.customPlatformsNone": "Nenhum descritor carregado a partir desta pasta.", + "settings.customPlatformsProblems": "Ficheiros que não carregaram", + "descriptor.addFromFile": "Adicionar a partir de um ficheiro", + "descriptor.reload": "Recarregar", + "descriptor.openFolder": "Abrir pasta", + "descriptor.remove": "Remover", + "descriptor.removeConfirmTitle": "Remover {platform}?", + "descriptor.removeConfirmMessage": "O ficheiro de descritor sai da sua pasta. As contas já capturadas ficam no disco.", + "descriptor.previewTitle": "Adicionar uma plataforma", + "descriptor.previewOs": "Descrita para {list}", + "descriptor.previewRoots": "Pastas em que pode tocar", + "descriptor.previewSteps": "O que uma troca de conta faria", + "descriptor.previewWarnings": "Avisos", + "descriptor.previewNoPlan": "Não foi possível criar uma simulação: {problem}", + "descriptor.previewReplaces": "Isto substitui o descritor que já está na sua pasta.", + "descriptor.previewNothingWritten": "Nada é escrito até que o adicione.", + "descriptor.replace": "Substituir", + "descriptor.installed": "{platform} adicionada", + "descriptor.removed": "{platform} removida", + "descriptor.actionRead": "ler", + "descriptor.actionCapture": "capturar", + "descriptor.actionRestore": "restaurar", + "descriptor.actionDelete": "remover", + "descriptor.actionClose": "fechar", + "descriptor.actionLaunch": "iniciar", + "settings.dataRefresh": "Atualização de dados", + "settings.avatarRefresh": "Atualização de avatares", + "settings.banCheckDelay": "Intervalo da verificação de bans", + "settings.refreshNow": "Atualizar agora", + "settings.zeroEachLaunch": "0 = a cada arranque", + "settings.privacy": "Privacidade", + "settings.inactivityTimeout": "Tempo de inatividade", + "settings.zeroDisabled": "0 = desativado", + "settings.streamerMode": "Modo streamer", + "settings.streamerModeHint": "Desfoca automaticamente os dados das contas quando há software de streaming a correr.", + "settings.personasHint": "Mostrar o seletor de identidades personas", + "settings.replayOnboarding": "Repetir a introdução", + "settings.replayOnboardingHint": "Mostra outra vez a visita guiada e a escolha das estatísticas de utilização.", + "streamer.title": "Modo streamer", + "streamer.disable": "Desativar", + "streamer.disablePermanently": "Desativar permanentemente", + "personas.title": "Personas", + "personas.create": "Nova persona", + "personas.activate": "Mudar para esta persona", + "personas.edit": "Editar", + "personas.delete": "Eliminar", + "personas.finish": "Concluir", + "personas.switching": "A mudar...", + "personas.switched": "Mudou para {name}", + "personas.switchPartial": "{name}: falhou em {failed}", + "personas.switchFailed": "Não foi possível mudar para {name}", + "personas.namePlaceholder": "Nome da persona", + "personas.choosePlatforms": "Escolhe uma conta para cada plataforma a incluir", + "personas.chooseAccount": "Escolhe a conta {platform}", + "personas.removeFromPersona": "Remover desta persona", + "personas.customImage": "Escolher uma imagem...", + "personas.removeImage": "Remover imagem", + "personas.imageError": "Não foi possível carregar essa imagem.", + "personas.loadingAccounts": "A carregar contas...", + "personas.noAccountsOnPlatform": "Ainda não há contas nesta plataforma", + "personas.emptyHint": "Um clique muda todas as plataformas incluídas para a respetiva conta.", + "settings.security": "Segurança", + "settings.pinLockOnAfk": "Bloqueio por PIN em inatividade", + "settings.pinCode": "Código PIN", + "settings.pinPlaceholder": "4 dígitos", + "settings.pinCurrentCode": "PIN atual", + "settings.pinCurrentHint": "Introduza o PIN atual para alterar ou desativar o bloqueio.", + "settings.telemetry": "Estatísticas de utilização (anónimas)", + "settings.telemetryModeA": "Estatísticas anónimas básicas", + "settings.telemetryModeAHint": "Conta os arranques e as funcionalidades utilizadas. Um ID local aleatório evita contagens duplicadas de instalações; não são enviados dados pessoais.", + "settings.telemetryModeB": "Estatísticas avançadas com UUID local (opcional)", + "settings.telemetryModeBHint": "Adiciona um UUID aleatório guardado apenas nesta máquina. Permite medir a retenção e saber que funcionalidades importam. Continuam a não ser enviados dados pessoais.", + "settings.telemetryExport": "Exportar os meus dados", + "settings.telemetryExported": "Dados copiados para a área de transferência", + "settings.telemetryExportFailed": "A exportação falhou", + "settings.telemetryEnableFailed": "Não foi possível ativar as estatísticas avançadas", + "settings.telemetryDisableFailed": "Não foi possível desativar as estatísticas avançadas", + "settings.telemetryDeletePending": "As estatísticas avançadas estão desligadas; a eliminação no servidor precisa de outra tentativa", + "settings.telemetryDeletePendingHint": "A eliminação no servidor está pendente. O ID local antigo é mantido apenas para poderes repetir a eliminação ou exportar os dados associados.", + "settings.telemetryRetryDelete": "Repetir a eliminação dos dados", + "settings.telemetryDeleteCompleted": "Dados do servidor eliminados", + "settings.telemetryDeleteRetryFailed": "A eliminação no servidor continua pendente", + "settings.telemetryLearnMore": "Que dados são enviados?", + "onboarding.welcome.title": "Bem-vindo ao Accshift", + "onboarding.welcome.version": "v{version}", + "onboarding.welcome.compatibleWith": "O teu sistema operativo é compatível com:", + "onboarding.welcome.detectedPlatforms": "Estas são as plataformas detetadas no teu computador:", + "onboarding.welcome.next": "Mostra-me a aplicação", + "onboarding.welcome.skip": "Ignorar", + "onboarding.features.hint": "Esta parte da aplicação não está visível neste momento.", + "onboarding.features.addAccount.label": "Adicionar uma conta", + "onboarding.features.addAccount.body": "O botão + no canto superior direito adiciona uma conta à plataforma atual. Inicias sessão uma vez, o Accshift guarda-a.", + "onboarding.features.switch.label": "Mudar de conta", + "onboarding.features.switch.body": "Clica num cartão e ficas nessa conta. É basicamente toda a aplicação, sinceramente.", + "onboarding.features.contextMenu.label": "Clique direito para as ações", + "onboarding.features.contextMenu.body": "Clica com o botão direito num cartão para o renomear, mudar a cor, movê-lo para uma pasta ou eliminá-lo.", + "onboarding.features.platformSwitch.label": "Mudar de plataforma", + "onboarding.features.platformSwitch.body": "O mesmo para Riot, Battle.net, Epic e as restantes. Os separadores no topo mudam de plataforma.", + "onboarding.features.next": "Seguinte", + "onboarding.features.back": "Voltar", + "onboarding.features.step": "Passo {step}", + "onboarding.features.mockRename": "Renomear", + "onboarding.features.mockSetColor": "Definir cor", + "onboarding.features.mockMoveToFolder": "Mover para pasta", + "onboarding.features.mockDelete": "Eliminar", + "onboarding.features.mockAccount": "Conta {number}", + "onboarding.telemetry.title": "VENDE A TUA ALMA AO ACCSHIFT!", + "onboarding.telemetry.intro": "O Accshift conta algumas estatísticas de utilização anónimas para eu saber o que corrigir primeiro. Nada de pessoal sai da tua máquina, a aplicação funciona na mesma, e podes mudar de ideias quando quiseres nas definições.", + "onboarding.telemetry.question": "Negócio fechado?", + "onboarding.telemetry.gifAlt": "Meme Trade Offer", + "onboarding.telemetry.basic": "NÃO! Já chega", + "onboarding.telemetry.basicDefault": "(predefinido)", + "onboarding.telemetry.basicHint": "Apenas contadores anónimos e nada mais: sem perfil, sem identificador que te siga.", + "onboarding.telemetry.deal": "NEGÓCIO FECHADO", + "onboarding.telemetry.dealHint": "Adiciona um UUID separado para as estatísticas avançadas, guardado apenas na tua máquina. Continuo sem saber quem és, mas ajudas imenso. Obrigado!", + "onboarding.telemetry.optOutNote": "Escolhas o que escolheres, podes mudar depois em Definições -> Privacidade. :c", + "onboarding.telemetry.refuse": "Não obrigado, desliga tudo", + "onboarding.telemetry.refuseHint": "Nada é medido nem enviado, nem sequer os contadores anónimos.", + "onboarding.telemetry.back": "Voltar", + "onboarding.persistFailed": "Não foi possível guardar essa escolha. Tenta de novo.", + "onboarding.persistLater": "Perguntar no próximo arranque", + "settings.steam": "Steam", + "settings.riot": "Riot", + "settings.battleNet": "Battle.net", + "settings.ubisoft": "Ubisoft Connect", + "settings.runSteamAsAdmin": "Executar o Steam como administrador", + "settings.showUsernames": "Mostrar nomes de utilizador", + "settings.showSteamLastLogin": "Mostrar última sessão Steam", + "settings.showRiotLastLogin": "Mostrar última sessão Riot", + "settings.showBattleNetLastLogin": "Mostrar última sessão Battle.net", + "settings.showUbisoftLastLogin": "Mostrar última sessão Ubisoft", + "settings.showNotesUnderCards": "Mostrar notas sob os cartões", + "settings.expandedFolders": "Expandir todas as pastas", + "list.rootSection": "Raiz", + "settings.steamLaunch": "Mudança", + "settings.steamInstallation": "Instalação", + "settings.steamApiAndData": "API e dados", + "settings.launchOptions": "Opções de arranque", + "settings.shutdownMode": "Modo de encerramento", + "settings.shutdownModeGraceful": "Suave", + "settings.shutdownModeForce": "Forçar fecho", + "settings.autoDetected": "Detetado automaticamente", + "settings.steamFolder": "Pasta do Steam", + "settings.steamFolderRegistry": "Registo", + "settings.riotClientPath": "Executável do Riot Client", + "settings.battleNetPath": "Executável do Battle.net", + "settings.ubisoftPath": "Pasta do Ubisoft Connect", + "settings.steamWebApiKey": "Chave da Steam Web API", + "settings.getKey": "Obter chave", + "settings.pasteApiKey": "Cola a tua chave da Steam Web API", + "settings.saved": "Definições guardadas", + "settings.openApiKeyFailed": "Não foi possível abrir a página da chave da API Steam", + "settings.integrations": "Integrações", + "settings.deepLinks": "Permitir ligações accshift://", + "settings.deepLinksHint": "Deixa outras aplicações mudarem de conta por ti: favoritos, Stream Deck, scripts.", + "settings.cliEnabled": "Permitir a CLI do accshift", + "settings.cliEnabledHint": "Deixa a ferramenta de linha de comandos listar e mudar de conta. Desativado, todos os comandos accshift recusam-se a correr, diag incluído; a aplicação em si não é afetada.", + "settings.help": "Abrir a wiki do accshift", + "settings.helpShort": "Ajuda", + "settings.openHelpFailed": "Não foi possível abrir a wiki", + "settings.logs": "Registos", + "settings.openLogs": "Abrir a pasta dos registos", + "settings.openLogsFailed": "Não foi possível abrir a pasta dos registos", + "context.loading": "A carregar...", + "context.noGamesFound": "Nenhum jogo encontrado", + "context.menu.newFolder": "Nova pasta", + "context.menu.refresh": "Atualizar", + "context.menu.rename": "Renomear", + "context.menu.copy": "Copiar...", + "context.menu.deleteFolder": "Eliminar pasta", + "context.menu.folderColor": "Cor da pasta", + "context.menu.moveToFolder": "Mover para uma pasta", + "context.menu.moveToRoot": "Sem pasta", + "context.menu.editCardAndColor": "Editar cartão e cor", + "context.menu.cardColor": "Cor do cartão", + "context.menu.addNote": "Adicionar nota", + "context.menu.editNote": "Editar nota", + "platform.rename": "Renomear", + "platform.renameTitle": "Renomear conta", + "platform.renamePlaceholder": "Nome da conta", + "platform.newAccountPending": "A aguardar...", + "platform.setupBusy": "Está a decorrer outra operação do accshift. A aguardar que termine.", + "platform.renameNewAccount": "Dá um nome a esta conta", + "platform.copyLabelAccountId": "ID da conta", + "platform.forget": "Esquecer", + "platform.forgetConfirmTitle": "Esquecer «{display}»?", + "platform.forgetConfirmMessage": "Isto remove esta conta e a sua sessão guardada do Accshift.", + "platform.forgotAccount": "{display} esquecida", + "dialog.newFolderTitle": "Nova pasta", + "dialog.renameFolderTitle": "Renomear pasta", + "dialog.folderNamePlaceholder": "Nome da pasta", + "dialog.cardNoteTitle": "Nota do cartão", + "dialog.cardNotePlaceholder": "Escreve uma nota curta", + "card.noteAttached": "Nota anexada", + "card.clickAgainToSwitch": "Clique novamente para trocar", + "card.extensionWarnings": "Avisos", + "card.extensionNote": "Nota", + "card.cs2Section": "CS2", + "card.cs2Level": "Nível {level}", + "card.cs2CaseEarned": "Caixa semanal obtida", + "card.cs2CaseNotEarned": "Caixa semanal pendente", + "list.selectAccountPreview": "Seleciona uma conta para pré-visualizar", + "preview.noteLabel": "Nota", + "preview.currentlyActive": "Ativa neste momento", + "preview.switchAgain": "Mudar outra vez", + "preview.switchAccount": "Mudar de conta", + "ban.community": "Ban da comunidade", + "ban.vac.single": "{count} ban VAC", + "ban.vac.multiple": "{count} bans VAC", + "ban.game.single": "{count} ban de jogo", + "ban.game.multiple": "{count} bans de jogo", + "time.unknown": "desconhecido", + "time.justNow": "agora mesmo", + "time.minuteAgo": "há {count} minuto", + "time.minutesAgo": "há {count} minutos", + "time.hourAgo": "há {count} hora", + "time.hoursAgo": "há {count} horas", + "time.dayAgo": "há {count} dia", + "time.daysAgo": "há {count} dias", + "time.neverConnected": "nunca iniciou sessão", + "time.compactWrapper": "({value})", + "toast.copied": "{label} copiado", + "toast.legacyConfigMigrated": "Definições migradas para o novo formato", + "toast.legacyConfigMigrationFailed": "Falha ao migrar as definições antigas, consulta os registos", + "toast.failedLoadSettingsPanel": "Falha ao carregar o painel de definições", + "toast.banChecking": "A verificar bans...", + "toast.banCheckSummary.single": "Verificação de bans: {count} conta com bans", + "toast.banCheckSummary.multiple": "Verificação de bans: {count} contas com bans", + "toast.banCheckFailed": "A verificação de bans falhou: {error}", + "toast.avatarRefreshComplete": "Atualização de avatares concluída para {count} contas", + "toast.banRefreshComplete": "Atualização de bans concluída para {count} contas", + "toast.banRefreshNoApiKey": "Atualização de bans ignorada: adiciona uma chave da Steam Web API nas definições", + "toast.refreshPartial": "Atualização concluída para {ok} de {count} contas", + "toast.noSteamAccountsFound": "Nenhuma conta Steam encontrada. Inicia sessão no Steam pelo menos uma vez e atualiza.", + "toast.accountsRefreshed.single": "{count} conta atualizada", + "toast.accountsRefreshed.multiple": "{count} contas atualizadas", + "toast.steamFolderNotFound": "A pasta do Steam não foi encontrada. Define-a manualmente em Definições > Pasta do Steam.", + "toast.steamElevated": "O Steam está a correr como administrador. Ativa 'Executar o Steam como administrador' em Definições > Steam.", + "toast.noRiotProfilesFound": "Ainda não há perfis Riot. Clica em + para adicionar um.", + "toast.noBattleNetAccountsFound": "Nenhuma conta Battle.net encontrada. Inicia sessão no Battle.net pelo menos uma vez e atualiza.", + "toast.noUbisoftAccountsFound": "Nenhuma conta Ubisoft encontrada. Inicia sessão no Ubisoft Connect pelo menos uma vez e atualiza.", + "toast.deepLinkInvalid": "Ligação accshift:// não reconhecida", + "toast.deepLinkDisabled": "As ligações accshift:// estão desativadas nas definições", + "toast.deepLinkUnknownPlatform": "Plataforma desconhecida na ligação: {platform}", + "toast.deepLinkPlatformDisabled": "{platform} está desativada nas definições", + "toast.deepLinkAccountNotFound": "Nenhuma conta {platform} corresponde a «{account}»", + "toast.deepLinkSwitched": "Mudou para {account}", + "dialog.deepLinkSwitchTitle": "Mudar de conta?", + "dialog.deepLinkSwitchMessage": "Uma ligação accshift:// está a pedir para mudar a tua conta {platform} para «{account}». Continuar?", + "dialog.deepLinkSwitchConfirm": "Mudar", + "steam.launchOnline": "Mudar como online", + "steam.launchInvisible": "Mudar como invisível", + "steam.switchAndRunGame": "Mudar + iniciar {game}", + "steam.switchAndRunPick": "Mudar + iniciar um jogo...", + "steam.runGame": "Iniciar {game}", + "steam.runPick": "Iniciar um jogo...", + "steam.switchAndRunNoGames": "Nenhum jogo encontrado", + "steam.clearDefaultGame": "Limpar jogo predefinido", + "steam.copySteamId64": "Copiar SteamID64", + "steam.copyFriendCode": "Copiar código de amigo", + "steam.copyProfileUrl": "Copiar URL do perfil", + "steam.copyLabelUsername": "Nome de utilizador", + "steam.copyLabelSteamId64": "SteamID64", + "steam.copyLabelFriendCode": "Código de amigo", + "steam.copyLabelProfileUrl": "URL do perfil", + "steam.openUserdataFolder": "Abrir a pasta userdata", + "steam.clearIntegratedBrowserCache": "Repor a sessão do navegador", + "steam.clearIntegratedBrowserCacheConfirmTitle": "Repor a sessão do navegador do Steam?", + "steam.clearIntegratedBrowserCacheConfirmMessage": "Isto fecha o Steam e limpa a cache do navegador integrado usado pela Loja e pela Comunidade.", + "steam.clearedIntegratedBrowserCache": "Sessão do navegador do Steam reposta", + "steam.copySettingsFrom": "Copiar definições de", + "steam.copiedSettingsToCurrent": "Definições de {game} copiadas para a conta atual", + "steam.forget": "Esquecer", + "steam.forgetConfirmTitle": "Esquecer «{display}»?", + "steam.forgetConfirmMessage": "Isto remove esta conta da tua lista de contas Steam neste PC.", + "steam.forgotAccount": "{username} esquecida", + "steam.setupPendingLabel": "Conta Steam", + "steam.setupWaitingForClient": "À espera do Steam...", + "steam.setupWaitingForLogin": "Inicia sessão na conta Steam que queres adicionar.", + "steam.setupDetected": "Conta detetada", + "steam.setupConnected": "Ligado", + "steam.setupReady": "Conta Steam adicionada", + "steam.setupReadyWithProfile": "{profile} adicionada", + "steam.setupFailed": "A configuração do Steam falhou", + "steam.setupFailedMessage": "O Accshift não conseguiu concluir a adição desta conta Steam.", + "riot.captureSession": "Capturar a sessão atual", + "riot.recaptureSession": "Voltar a capturar a sessão", + "riot.capturedSession": "Sessão capturada para {profile}", + "riot.setupPendingLabel": "Conta Riot", + "riot.setupWaitingForClient": "À espera do Riot Client...", + "riot.setupWaitingForLogin": "Inicia sessão na conta Riot que queres adicionar.", + "riot.setupCapturing": "A guardar a sessão Riot...", + "riot.setupReady": "Conta Riot adicionada", + "riot.setupReadyWithProfile": "{profile} adicionada", + "riot.setupFailed": "A configuração do Riot falhou", + "riot.setupFailedMessage": "O Accshift não conseguiu concluir a adição desta conta Riot.", + "riot.setupStaySignedIn": "Mantém 'Manter sessão iniciada' ativado.", + "riot.setupDetected": "Conta detetada", + "riot.setupConnected": "Ligado", + "riot.forget": "Esquecer conta", + "riot.forgetConfirmTitle": "Esquecer «{display}»?", + "riot.forgetConfirmMessage": "Isto remove o perfil Riot local e a sua sessão guardada do Accshift.", + "riot.forgotProfile": "{profile} esquecido", + "battlenet.copyUsername": "Copiar nome de utilizador", + "battlenet.copyLabelUsername": "Nome de utilizador", + "battlenet.copyEmail": "Copiar email", + "battlenet.copyLabelEmail": "Email", + "battlenet.forget": "Esquecer", + "battlenet.forgetConfirmTitle": "Esquecer «{display}»?", + "battlenet.forgetConfirmMessage": "Isto remove esta conta da lista de contas Battle.net neste PC.", + "battlenet.forgotAccount": "{email} esquecida", + "battlenet.setupPendingLabel": "Conta Battle.net", + "battlenet.setupWaitingForClient": "À espera do Battle.net...", + "battlenet.setupWaitingForLogin": "Inicia sessão na conta Battle.net que queres adicionar.", + "battlenet.setupKeepMeLoggedIn": "Mantém 'Manter sessão iniciada' ativado, se possível.", + "battlenet.setupDetected": "Conta detetada", + "battlenet.setupConnected": "Ligado", + "battlenet.setupReady": "Conta Battle.net adicionada", + "battlenet.setupReadyWithProfile": "{profile} adicionada", + "battlenet.setupFailed": "A configuração do Battle.net falhou", + "battlenet.setupFailedMessage": "O Accshift não conseguiu concluir a adição desta conta Battle.net.", + "ubisoft.copyLabelUuid": "UUID", + "ubisoft.forget": "Esquecer", + "ubisoft.forgetConfirmTitle": "Esquecer «{display}»?", + "ubisoft.forgetConfirmMessage": "Isto remove esta conta e a sua sessão guardada do Accshift.", + "ubisoft.forgotAccount": "{display} esquecida", + "ubisoft.setupPendingLabel": "Conta Ubisoft", + "ubisoft.setupWaitingForClient": "À espera do Ubisoft Connect...", + "ubisoft.setupWaitingForLogin": "Inicia sessão na conta Ubisoft que queres adicionar.", + "ubisoft.setupKeepMeLoggedIn": "Mantém 'Lembrar-me' ativado.", + "ubisoft.setupDetected": "Conta detetada", + "ubisoft.setupConnected": "Ligado", + "ubisoft.setupReady": "Conta Ubisoft adicionada", + "ubisoft.setupReadyWithProfile": "{profile} adicionada", + "ubisoft.setupFailed": "A configuração do Ubisoft falhou", + "ubisoft.setupFailedMessage": "O Accshift não conseguiu concluir a adição desta conta Ubisoft.", + "settings.roblox": "Roblox", + "toast.noRobloxAccountsFound": "Nenhuma conta Roblox encontrada. Adiciona uma conta com o Quick Login ou cola um cookie.", + "roblox.noAccountsHint": "Carrega em + para adicionar uma conta Roblox via Quick Login.", + "roblox.sessionExpired": "Sessão expirada", + "roblox.sessionExpiredTooltip": "Esta conta tem a sessão terminada. Volta a adicioná-la antes de mudar.", + "roblox.sessionExpiredSwitchError": "Sessão expirada. Volta a adicionar esta conta para mudar. As notas e definições são mantidas.", + "roblox.cookiePasteTitle": "Adicionar conta por cookie", + "roblox.cookiePasteHint": "Cola um cookie .ROBLOSECURITY do teu navegador para adicionar uma conta diretamente.", + "settings.showRobloxLastLogin": "Mostrar última sessão", + "settings.robloxHealthCheck": "Verificação de sessão", + "settings.robloxHealthCheckHint": "Assinala as contas cuja sessão Roblox expirou; um pedido de rede por arranque.", + "roblox.copyLabelUsername": "Nome de utilizador", + "roblox.copyLabelUserId": "ID de utilizador", + "roblox.forget": "Esquecer", + "roblox.forgetConfirmTitle": "Esquecer «{display}»?", + "roblox.forgetConfirmMessage": "Isto remove esta conta e o cookie guardado deste PC.", + "roblox.forgotAccount": "{display} esquecida", + "roblox.setupPendingLabel": "Conta Roblox", + "roblox.setupWaitingForClient": "A ligar ao Roblox...", + "roblox.setupWaitingForLogin": "Abre a aplicação Roblox noutro dispositivo e introduz o código abaixo.", + "roblox.setupQuickLoginCode": "Código Quick Login", + "roblox.setupQuickLoginHint": "roblox.com/quick-login", + "roblox.copyCode": "Copiar código", + "roblox.setupDetected": "Conta detetada", + "roblox.setupConnected": "Ligado", + "roblox.setupReady": "Conta Roblox adicionada", + "roblox.setupReadyWithProfile": "{profile} adicionada", + "roblox.setupFailed": "A configuração do Roblox falhou", + "roblox.setupFailedMessage": "O Accshift não conseguiu concluir a adição desta conta Roblox.", + "settings.epic": "Epic Games", + "settings.epicPath": "Executável do Epic Games Launcher", + "settings.showEpicLastLogin": "Mostrar última sessão", + "toast.noEpicAccountsFound": "Nenhuma conta Epic Games encontrada. Inicia sessão no Epic Games Launcher pelo menos uma vez e atualiza.", + "epic.noAccountsHint": "Carrega em + para adicionar uma conta Epic Games.", + "epic.copyLabelAccountId": "ID da conta", + "epic.forget": "Esquecer", + "epic.forgetConfirmTitle": "Esquecer «{display}»?", + "epic.forgetConfirmMessage": "Isto remove esta conta e a sua sessão guardada do Accshift.", + "epic.forgotAccount": "{display} esquecida", + "epic.setupPendingLabel": "Conta Epic Games", + "epic.setupWaitingForClient": "À espera do Epic Games Launcher...", + "epic.setupWaitingForLogin": "Inicia sessão na conta Epic Games que queres adicionar.", + "epic.setupKeepMeLoggedIn": "Mantém 'Lembrar-me' ativado.", + "epic.setupDetected": "Conta detetada", + "epic.setupConnected": "Ligado", + "epic.setupReady": "Conta Epic Games adicionada", + "epic.setupReadyWithProfile": "{profile} adicionada", + "epic.setupFailed": "A configuração do Epic Games falhou", + "epic.setupFailedMessage": "O Accshift não conseguiu concluir a adição desta conta Epic Games.", + "settings.gog": "GOG Galaxy", + "settings.gogPath": "Executável do GOG Galaxy", + "settings.showGogLastLogin": "Mostrar última sessão", + "toast.noGogAccountsFound": "Nenhuma conta GOG Galaxy encontrada. Inicia sessão no GOG Galaxy pelo menos uma vez e atualiza.", + "gog.noAccountsHint": "Carrega em + para adicionar uma conta GOG Galaxy.", + "gog.copyLabelAccountId": "ID da conta", + "gog.forget": "Esquecer", + "gog.forgetConfirmTitle": "Esquecer «{display}»?", + "gog.forgetConfirmMessage": "Isto remove esta conta e a sua sessão guardada do Accshift.", + "gog.forgotAccount": "{display} esquecida", + "gog.setupPendingLabel": "Conta GOG Galaxy", + "gog.setupWaitingForClient": "À espera do GOG Galaxy...", + "gog.setupWaitingForLogin": "Inicia sessão na conta GOG Galaxy que queres adicionar.", + "gog.setupKeepMeLoggedIn": "Mantém 'Manter sessão iniciada' ativado.", + "gog.setupDetected": "Conta detetada", + "gog.setupConnected": "Ligado", + "gog.setupReady": "Conta GOG Galaxy adicionada", + "gog.setupReadyWithProfile": "{profile} adicionada", + "gog.setupFailed": "A configuração do GOG Galaxy falhou", + "gog.setupFailedMessage": "O Accshift não conseguiu concluir a adição desta conta GOG Galaxy.", + "settings.jagex": "Jagex Launcher", + "settings.jagexPath": "Executável do Jagex Launcher", + "settings.showJagexLastLogin": "Mostrar última sessão", + "toast.noJagexAccountsFound": "Ainda não há contas Jagex guardadas. Carrega em + para adicionar uma através do Jagex Launcher.", + "jagex.noAccountsHint": "Carrega em + para adicionar uma conta Jagex.", + "jagex.copyLabelAccountId": "ID da conta", + "jagex.forget": "Esquecer", + "jagex.forgetConfirmTitle": "Esquecer «{display}»?", + "jagex.forgetConfirmMessage": "Isto remove esta conta e a sua sessão guardada do Accshift.", + "jagex.forgotAccount": "{display} esquecida", + "jagex.setupPendingLabel": "Conta Jagex", + "jagex.setupWaitingForClient": "À espera do Jagex Launcher...", + "jagex.setupWaitingForLogin": "Inicia sessão na conta Jagex que queres adicionar.", + "jagex.setupKeepMeLoggedIn": "Mantém 'Lembrar-me' ativado.", + "jagex.setupDetected": "Conta detetada", + "jagex.setupConnected": "Ligado", + "jagex.setupReady": "Conta Jagex adicionada", + "jagex.setupReadyWithProfile": "{profile} adicionada", + "jagex.setupFailed": "A configuração do Jagex falhou", + "jagex.setupFailedMessage": "O Accshift não conseguiu concluir a adição desta conta Jagex.", + "settings.discord": "Discord", + "settings.discordPath": "Executável do Discord", + "settings.showDiscordLastLogin": "Mostrar última sessão", + "toast.noDiscordAccountsFound": "Nenhuma conta Discord encontrada. Adiciona uma conta Discord e atualiza.", + "discord.noAccountsHint": "Carrega em + para adicionar uma conta Discord.", + "discord.copyLabelAccountId": "ID da conta", + "discord.forget": "Esquecer", + "discord.forgetConfirmTitle": "Esquecer «{display}»?", + "discord.forgetConfirmMessage": "Isto remove esta conta e a sua sessão guardada do Accshift.", + "discord.forgotAccount": "{display} esquecida", + "discord.setupPendingLabel": "Conta Discord", + "discord.setupWaitingForClient": "À espera do Discord...", + "discord.setupWaitingForLogin": "Inicia sessão na conta Discord que queres adicionar.", + "discord.setupKeepMeLoggedIn": "Mantém a sessão iniciada para conservar esta conta.", + "discord.setupDetected": "Conta detetada", + "discord.setupConnected": "Ligado", + "discord.setupReady": "Conta Discord adicionada", + "discord.setupReadyWithProfile": "{profile} adicionada", + "discord.setupFailed": "A configuração do Discord falhou", + "discord.setupFailedMessage": "O Accshift não conseguiu concluir a adição desta conta Discord.", + "color.none": "Predefinida", + "color.blue": "Azul", + "color.cyan": "Ciano", + "color.green": "Verde", + "color.lime": "Verde-lima", + "color.yellow": "Amarelo", + "color.orange": "Laranja", + "color.red": "Vermelho", + "color.pink": "Rosa", + "color.violet": "Violeta", + "color.gray": "Cinzento", + "language.english": "English", + "language.french": "Français", + "language.spanish": "Español", + "language.portuguese": "Português (Portugal)", + "language.portugueseBrazil": "Português (Brasil)", + "language.russian": "Русский", + "language.chinese": "简体中文", + "theme.dark": "Escuro", + "theme.light": "Claro", + "theme.midnight": "Meia-noite", + "theme.glassDark": "Vidro escuro", + "theme.glassLight": "Vidro claro", + "theme.liquidGlass": "Vidro líquido", + "theme.custom": "Personalizado", + "settings.themeImport": "Importar", + "settings.themeExport": "Exportar", + "settings.themeDelete": "Eliminar tema", + "settings.themeImported": "Tema importado", + "settings.themeDeleted": "Tema eliminado", + "settings.themeExportCopied": "JSON do tema copiado para a área de transferência", + "settings.themeInvalidJson": "JSON de tema inválido", + "settings.themeCustomize": "Personalizar", + "settings.themeEdit": "Editar", + "settings.themeCopyName": "{name} cópia", + "settings.themeSaved": "Tema guardado", + "settings.themeMigrated": "Tema importado e atualizado para o formato atual", + "settings.themeInvalidId": "O identificador do tema está em falta ou é inválido", + "settings.themeInvalidName": "O tema não tem nome", + "settings.themeUnsupportedVersion": "Este tema precisa de uma versão mais recente do accshift", + "themeGroup.surface": "Superfícies", + "themeGroup.text": "Texto", + "themeGroup.semantic": "Cores de estado", + "themeGroup.shape": "Forma e densidade", + "themeGroup.typography": "Tipografia", + "themeGroup.motion": "Movimento", + "themeKind.rgbTriplet": "trio RGB", + "themeKind.hexColor": "cor hexadecimal", + "themeKind.color": "cor", + "themeKind.length": "comprimento", + "themeKind.shadow": "sombra", + "themeKind.choice": "opção", + "themeKind.fontStack": "tipo de letra", + "themeKind.signedLength": "espaçamento", + "themeKind.gradient": "gradiente", + "themeKind.number": "número", + "themeDensity.compact": "Compacta", + "themeDensity.cozy": "Normal", + "themeDensity.comfortable": "Ampla", + "themeAvatarShape.circle": "Redondo", + "themeAvatarShape.rounded": "Arredondado", + "themeAvatarShape.square": "Quadrado", + "themeSmoothing.auto": "Suavizado", + "themeSmoothing.none": "Pixel", + "themeToken.bgRgb": "Fundo da janela", + "themeToken.bgCard": "Fundo dos cartões", + "themeToken.bgCardHover": "Fundo dos cartões ao passar o cursor", + "themeToken.bgMuted": "Fundo atenuado", + "themeToken.bgElevated": "Fundo elevado", + "themeToken.border": "Contornos", + "themeToken.fg": "Texto principal", + "themeToken.fgMuted": "Texto secundário", + "themeToken.fgSubtle": "Texto discreto", + "themeToken.afkText": "Etiqueta de ausente", + "themeToken.accent": "Destaque", + "themeToken.accentFg": "Texto sobre o destaque", + "themeToken.success": "Sucesso", + "themeToken.warning": "Aviso", + "themeToken.danger": "Perigo", + "themeToken.radiusSm": "Raio pequeno", + "themeToken.radiusMd": "Raio médio", + "themeToken.radiusLg": "Raio grande", + "themeToken.elevationLow": "Sombra ligeira", + "themeToken.elevationMedium": "Sombra média", + "themeToken.elevationHigh": "Sombra forte", + "themeToken.density": "Densidade", + "themeToken.fontUi": "Tipo de letra da interface", + "themeToken.bgImage": "Gradiente da janela", + "themeToken.cardBgImage": "Gradiente das superfícies", + "themeToken.borderWidth": "Espessura dos contornos", + "themeToken.borderStyle": "Estilo dos contornos", + "themeToken.avatarShape": "Forma dos avatares", + "themeToken.focusRing": "Contorno de foco", + "themeToken.overlayBlur": "Desfoque do fundo", + "themeToken.fontDisplay": "Tipo de letra dos títulos", + "themeToken.lineHeight": "Altura da linha", + "themeToken.letterSpacing": "Espaçamento entre letras", + "themeToken.labelCase": "Maiúsculas das etiquetas", + "themeToken.fontSmoothing": "Suavização do texto", + "themeToken.motionScale": "Velocidade das animações", + "themeEditor.title": "Editor de temas", + "themeEditor.name": "Nome", + "themeEditor.author": "Autor", + "themeEditor.version": "Versão", + "themeEditor.base": "Herda de", + "themeEditor.colorScheme": "Modo de cor", + "themeEditor.glass": "Superfícies translúcidas", + "themeEditor.inherited": "Herdado", + "themeEditor.builtInBase": "Tema incorporado", + "themeEditor.reset": "Voltar ao valor herdado", + "themeEditor.issues": "Verificações", + "themeEditor.noIssues": "Nada a assinalar", + "themeEditor.fixErrorsFirst": "Corrija os valores recusados antes de guardar", + "themeEditor.save": "Guardar", + "themeEditor.saveFailed": "Não foi possível guardar o tema. Tenta de novo.", + "themeEditor.export": "Copiar", + "themeEditor.issueInvalidValue": "{token}: {kind} inválido, por exemplo {example}", + "themeEditor.issueUnknownToken": "{token} não é um valor de tema, é ignorado", + "themeEditor.issueMissingToken": "{token} não está definido, é usado o valor incorporado", + "themeEditor.issueUnknownBase": "O tema base {base} não existe, são usados os valores incorporados", + "themeEditor.issueContrast": "{token} sobre {against}: {ratio} para 1, abaixo do objetivo de {target} para 1", + "themeEditor.beta": "Beta", + "themeEditor.betaNote": "O formato de tema ainda muda: um tema escrito hoje pode precisar de um ajuste depois de uma atualização.", + "themeEditor.customCss": "CSS personalizado", + "themeEditor.customCssHelp": "Aplicado por cima do tema, para o que os valores acima não alcançam. Sem @import nem url(): um tema nunca vai buscar nada.", + "themeEditor.issueUnsafeCss": "{construct} não é permitido num tema, o CSS não é aplicado", + "themeEditor.issueCssTooLong": "CSS demasiado longo: {length} caracteres, o limite é {limit}", + "bulkEdit.title": "Edição em massa", + "bulkEdit.selectAccounts": "Selecionar contas", + "bulkEdit.selectAll": "Selecionar tudo", + "bulkEdit.deselectAll": "Desmarcar tudo", + "bulkEdit.next": "Seguinte", + "bulkEdit.apply": "Aplicar", + "bulkEdit.settings": "Definições", + "bulkEdit.newsPopup": "Pop-up de notícias dos jogos", + "bulkEdit.doNotDisturb": "Não incomodar", + "bulkEdit.launchOptions": "Opções de arranque por jogo", + "bulkEdit.unchanged": "Inalterado", + "bulkEdit.addLaunchOption": "Adicionar", + "bulkEdit.selectGame": "Seleciona um jogo...", + "bulkEdit.launchOptionsPlaceholder": "-fullscreen", + "bulkEdit.activeAccountWarning": "A conta atualmente ativa está selecionada. As alterações serão aplicadas no próximo reinício do Steam.", + "bulkEdit.toastSuccess": "Edição em massa: {count} com sucesso", + "bulkEdit.toastPartial": "Edição em massa: {succeeded} com sucesso, {failed} falhadas", + "bulkEdit.toastFailed": "A edição em massa falhou", + "bulkEdit.noAccountsSelected": "Nenhuma conta selecionada", + "bulkEdit.selected": "{count} selecionada(s)", + "bulkEdit.loadingGames": "A carregar jogos...", + "bulkEdit.copyUrls": "Copiar URLs", + "bulkEdit.urlsCopied": "{count} URL(s) de perfil copiada(s)", + "bulkEdit.cardColor": "Cor do cartão", + "bulkEdit.colorApplied": "Cor aplicada a {count} cartão(ões)", + "bulkEdit.colorCleared": "Cor removida de {count} cartão(ões)", + "common.retry": "Repetir", + "app.searchNoResults": "Nenhum resultado para «{query}»", + "app.folderEmpty": "Esta pasta está vazia", + "app.loadErrorGeneric": "Falha ao carregar as contas. Consulta a consola para mais detalhes.", + "toast.dismiss": "Fechar", + "toast.switchFailed": "A mudança de conta falhou. Consulta a consola para mais detalhes.", + "toast.addAccountFailed": "Não foi possível adicionar a conta. Consulta os registos da aplicação para mais detalhes.", + "toast.addAccountClientMissing": "{platform} não parece estar instalado. Instala-o, ou define a sua localização nas Definições.", + "toast.banCheckFailedGeneric": "A verificação de bans falhou. Consulta a consola para mais detalhes.", + "toast.renameAccountFailed": "Falha ao renomear: {error}", + "toast.copyFailed": "Não foi possível copiar para a área de transferência", + "toast.refreshFailed": "A atualização falhou. Consulta a consola para mais detalhes.", + "dialog.deleteFolderTitle": "Eliminar «{name}»?", + "dialog.deleteFolderMessage": "As subpastas também são eliminadas. As contas lá dentro sobem um nível, não são removidas.", + "personas.deleteConfirm": "Clica outra vez para eliminar", + "personas.discardConfirm": "Clica outra vez para descartar", + "personas.switchConfirmTitle": "Mudar de persona", + "personas.switchConfirmMessage": "Mudar {count} plataforma(s) para «{name}»? Cada cliente vai fechar e reabrir.", + "personas.switchConfirmAction": "Mudar", + "personas.deleteTitle": "Eliminar persona", + "personas.deleteMessage": "Eliminar «{name}»? As contas em si não são afetadas.", + "context.submenuError": "Não foi possível carregar este menu", + "settings.apiKeySaveFailed": "Não foi possível guardar a chave da API Steam", + "settings.apiKeyInvalidHint": "A chave não foi guardada. Verifica-a e tenta de novo.", + "settings.apiKeyCleared": "Chave da API Steam limpa", + "settings.apiKeyClearFailed": "Não foi possível limpar a chave da API Steam", + "settings.clearApiKey": "Limpar chave", + "settings.cs2Bridge": "Ponte de estatísticas CS2", + "settings.cs2BridgeLink": "Ligação da ponte", + "settings.cs2BridgeLinkPlaceholder": "https://o-teu-servidor/api/bridge/accshift/", + "settings.cs2BridgeTest": "Testar", + "settings.cs2BridgeStatusOk": "Ligado · {count} contas · {ms} ms", + "settings.cs2BridgeStatusFail": "A ligação falhou", + "settings.cs2BridgeStatusTesting": "A testar...", + "settings.cs2BridgeStatusUnknown": "Não testado", + "settings.cs2BridgeToken": "Token da API", + "settings.cs2BridgeTokenOptional": "opcional, para servidores próprios", + "settings.cs2BridgeTokenPlaceholder": "Token Bearer se o teu servidor precisar de um", + "settings.cs2BridgeSaveFailed": "Não foi possível guardar as definições da ponte CS2", + "settings.cs2BridgeClearToken": "Limpar token", + "settings.pathSaveFailed": "Não foi possível guardar o caminho de {platform}", + "settings.pathInvalidHint": "Não foi possível guardar este caminho. Verifica-o e tenta de novo.", + "settings.pathSteamNeverSignedIn": "Steam encontrado, inicia sessão uma vez para que crie o histórico de sessões", + "settings.pathNotSteamFolder": "Esta pasta não parece uma instalação do Steam", + "settings.pathNotADirectory": "O caminho tem de ser uma pasta existente", + "settings.pinSaved": "PIN guardado", + "settings.pinTakesEffectNextLaunch": "Aplica-se no próximo arranque da aplicação", + "settings.pinRequiredAfterInactivity": "Pedido após inatividade e no arranque da aplicação", + "settings.pinScreenLockOnly": "O PIN bloqueia esta janela e as trocas de conta. Não protege nada no disco.", + "settings.unitSeconds": "segundos", + "settings.unitDays": "dias", + "settings.telemetryLoadFailed": "Não foi possível carregar as definições de telemetria", + "palette.placeholder": "Escreve um comando ou o nome de uma conta...", + "palette.noResults": "Nenhum comando corresponde", + "palette.sectionAccounts": "Contas", + "palette.sectionActions": "Ações", + "palette.sectionNavigation": "Navegação", + "palette.openTab": "Ir para {platform}", + "palette.openFolder": "Abrir a pasta «{name}»", + "palette.backToParent": "Voltar à pasta acima", + "palette.zoomReset": "Repor o zoom" +} diff --git a/crates/accshift-app/i18n/ru.json b/crates/accshift-app/i18n/ru.json new file mode 100644 index 0000000..39b2eef --- /dev/null +++ b/crates/accshift-app/i18n/ru.json @@ -0,0 +1,741 @@ +{ + "app.loadingSettings": "Загрузка настроек...", + "app.loading": "Загрузка...", + "app.searchPlaceholder": "Поиск аккаунта...", + "app.noAccountsFound": "Аккаунты {platform} не найдены", + "app.noAccountsHint": "Убедитесь, что {platform} установлен и вы вошли хотя бы один раз.", + "app.switchingAccount": "Смена аккаунта...", + "app.platformRestarting": "{platform} перезапускается", + "app.comingSoon": "{platform} - скоро", + "app.platformUnsupportedOs": "{platform} пока недоступен в этой ОС", + "app.noCompatiblePlatforms": "В этой ОС нет ни одной совместимой платформы", + "app.noCompatiblePlatformsHint": "Следите за обновлениями поддержки Windows или используйте совместимую машину.", + "update.ctaAvailable": "Есть обновление", + "update.ctaInstalling": "Установка...", + "update.restartToApplyVersion": "Перезапустить и применить обновление {version}", + "update.restartToApply": "Перезапустить и применить обновление", + "update.readyToastVersion": "Обновление {version} готово", + "update.readyToast": "Обновление готово", + "update.restartFailed": "Не удалось перезапустить для установки обновления", + "pin.lockedTitle": "Приложение заблокировано", + "pin.lockedPrompt": "Введите PIN для разблокировки", + "pin.placeholder": "PIN из 4 цифр", + "pin.unlock": "Разблокировать", + "pin.invalid": "Неверный PIN", + "pin.tooManyAttempts": "Слишком много попыток. Повторите через {seconds} с.", + "common.ok": "OK", + "common.cancel": "Отмена", + "common.confirm": "Подтвердить", + "common.close": "Закрыть", + "common.back": "Назад", + "common.active": "Активен", + "common.choose": "Выбрать...", + "common.none": "Нет", + "common.custom": "Свой", + "common.configured": "Настроено", + "common.missing": "Не задано", + "common.enabled": "Включено", + "common.disabled": "Отключено", + "common.on": "Вкл", + "common.off": "Выкл", + "common.add": "Добавить", + "common.visible": "Показано", + "common.hidden": "Скрыто", + "common.inline": "В строке", + "common.tooltip": "Подсказка", + "titlebar.refresh": "Обновить", + "titlebar.addAccount": "Добавить аккаунт", + "titlebar.settings": "Настройки", + "titlebar.personas": "Персоны", + "titlebar.minimize": "Свернуть", + "titlebar.maximize": "Развернуть", + "titlebar.restore": "Восстановить", + "titlebar.close": "Закрыть", + "view.grid": "Вид сеткой", + "view.list": "Вид списком", + "settings.title": "Настройки", + "settings.general": "Общие", + "settings.appearance": "Оформление", + "settings.language": "Язык", + "settings.theme": "Тема", + "settings.uiZoom": "Масштаб интерфейса", + "settings.backgroundOpacity": "Прозрачность фона", + "settings.animations": "Анимации", + "settings.animationsSystem": "Как в системе", + "settings.animationsOn": "Всегда включены", + "settings.animationsOff": "Выключены", + "settings.performance": "Производительность", + "settings.suspendGraphicsWhenMinimized": "Отключать графику при сворачивании", + "settings.minimizeOnAccountSwitch": "Сворачивать при смене аккаунта", + "settings.platforms": "Платформы", + "settings.platformSearchPlaceholder": "Поиск платформ...", + "settings.platformSearchNoResults": "По запросу ничего не найдено.", + "settings.accountDisplay": "Отображение аккаунтов", + "settings.startupAndExtras": "Запуск", + "settings.defaultOnStartup": "Платформа при запуске", + "settings.platformDisabledSuffix": "(отключена)", + "settings.platformUnsupportedOs": "ОС не поддерживается", + "settings.platformNotImplemented": "В планах", + "settings.platformUserProvided": "Добавлена из вашей папки дескрипторов", + "settings.customPlatforms": "Пользовательские платформы", + "settings.customPlatformsHint": "Файлы дескрипторов в этой папке читаются при запуске приложения.", + "settings.customPlatformsNone": "Из этой папки не загружен ни один дескриптор.", + "settings.customPlatformsProblems": "Файлы, которые не загрузились", + "descriptor.addFromFile": "Добавить из файла", + "descriptor.reload": "Перезагрузить", + "descriptor.openFolder": "Открыть папку", + "descriptor.remove": "Удалить", + "descriptor.removeConfirmTitle": "Удалить {platform}?", + "descriptor.removeConfirmMessage": "Файл дескриптора покинет вашу папку. Уже сохранённые аккаунты останутся на диске.", + "descriptor.previewTitle": "Добавить платформу", + "descriptor.previewOs": "Описана для {list}", + "descriptor.previewRoots": "Папки, которых она может касаться", + "descriptor.previewSteps": "Что сделало бы переключение аккаунта", + "descriptor.previewWarnings": "Предупреждения", + "descriptor.previewNoPlan": "Не удалось построить пробный запуск: {problem}", + "descriptor.previewReplaces": "Это заменит дескриптор, который уже лежит в вашей папке.", + "descriptor.previewNothingWritten": "Ничего не записывается, пока вы не добавите.", + "descriptor.replace": "Заменить", + "descriptor.installed": "{platform} добавлена", + "descriptor.removed": "{platform} удалена", + "descriptor.actionRead": "чтение", + "descriptor.actionCapture": "снимок", + "descriptor.actionRestore": "восстановление", + "descriptor.actionDelete": "удаление", + "descriptor.actionClose": "закрытие", + "descriptor.actionLaunch": "запуск", + "settings.dataRefresh": "Обновление данных", + "settings.avatarRefresh": "Обновление аватаров", + "settings.banCheckDelay": "Задержка проверки банов", + "settings.refreshNow": "Обновить сейчас", + "settings.zeroEachLaunch": "0 = при каждом запуске", + "settings.privacy": "Приватность", + "settings.inactivityTimeout": "Тайм-аут бездействия", + "settings.zeroDisabled": "0 = отключено", + "settings.streamerMode": "Режим стримера", + "settings.streamerModeHint": "Автоматически размывать данные аккаунтов, когда запущен софт для стриминга.", + "settings.personasHint": "Показывать переключатель персон", + "settings.replayOnboarding": "Пройти знакомство заново", + "settings.replayOnboardingHint": "Снова показать приветственный тур и выбор по статистике использования.", + "streamer.title": "Режим стримера", + "streamer.disable": "Отключить", + "streamer.disablePermanently": "Отключить навсегда", + "personas.title": "Персоны", + "personas.create": "Новая персона", + "personas.activate": "Переключиться на эту персону", + "personas.edit": "Изменить", + "personas.delete": "Удалить", + "personas.finish": "Готово", + "personas.switching": "Переключение...", + "personas.switched": "Переключено на {name}", + "personas.switchPartial": "{name}: сбой на {failed}", + "personas.switchFailed": "Не удалось переключиться на {name}", + "personas.namePlaceholder": "Название персоны", + "personas.choosePlatforms": "Выберите аккаунт для каждой нужной платформы", + "personas.chooseAccount": "Выберите аккаунт {platform}", + "personas.removeFromPersona": "Убрать из этой персоны", + "personas.customImage": "Выбрать изображение...", + "personas.removeImage": "Удалить изображение", + "personas.imageError": "Не удалось загрузить это изображение.", + "personas.loadingAccounts": "Загрузка аккаунтов...", + "personas.noAccountsOnPlatform": "На этой платформе пока нет аккаунтов", + "personas.emptyHint": "Один клик переключает каждую платформу на её аккаунт.", + "settings.security": "Безопасность", + "settings.pinLockOnAfk": "Блокировка PIN при бездействии", + "settings.pinCode": "PIN-код", + "settings.pinPlaceholder": "4 цифры", + "settings.pinCurrentCode": "Текущий PIN", + "settings.pinCurrentHint": "Введите текущий PIN, чтобы изменить или отключить блокировку.", + "settings.telemetry": "Статистика использования (анонимно)", + "settings.telemetryModeA": "Базовая анонимная статистика", + "settings.telemetryModeAHint": "Считает запуски и то, какие функции используются. Случайный локальный ID не даёт посчитать одну установку дважды; личные данные не отправляются.", + "settings.telemetryModeB": "Расширенная статистика с локальным UUID (по желанию)", + "settings.telemetryModeBHint": "Добавляет случайный UUID, который хранится только на этой машине. Позволяет оценить удержание и важность функций. Личные данные по-прежнему не отправляются.", + "settings.telemetryExport": "Экспортировать мои данные", + "settings.telemetryExported": "Данные скопированы в буфер обмена", + "settings.telemetryExportFailed": "Не удалось экспортировать", + "settings.telemetryEnableFailed": "Не удалось включить расширенную статистику", + "settings.telemetryDisableFailed": "Не удалось отключить расширенную статистику", + "settings.telemetryDeletePending": "Расширенная статистика отключена; удаление на сервере потребует ещё одной попытки", + "settings.telemetryDeletePendingHint": "Удаление на сервере не завершено. Старый локальный ID сохранён только для повторной попытки или экспорта связанных данных.", + "settings.telemetryRetryDelete": "Повторить удаление данных", + "settings.telemetryDeleteCompleted": "Данные на сервере удалены", + "settings.telemetryDeleteRetryFailed": "Удаление на сервере всё ещё не завершено", + "settings.telemetryLearnMore": "Какие данные отправляются?", + "onboarding.welcome.title": "Добро пожаловать в Accshift", + "onboarding.welcome.version": "v{version}", + "onboarding.welcome.compatibleWith": "Ваша операционная система совместима с:", + "onboarding.welcome.detectedPlatforms": "На вашем компьютере обнаружены эти платформы:", + "onboarding.welcome.next": "Показать обзор", + "onboarding.welcome.skip": "Пропустить", + "onboarding.features.hint": "Эта часть приложения сейчас не видна.", + "onboarding.features.addAccount.label": "Добавить аккаунт", + "onboarding.features.addAccount.body": "Кнопка + справа сверху добавляет аккаунт на текущую платформу. Войдите один раз, Accshift его запомнит.", + "onboarding.features.switch.label": "Смена аккаунта", + "onboarding.features.switch.body": "Нажмите на карточку, и вы уже в этом аккаунте. Честно говоря, это всё приложение целиком.", + "onboarding.features.contextMenu.label": "Правый клик для действий", + "onboarding.features.contextMenu.body": "Правый клик по карточке позволяет переименовать её, сменить цвет, переместить в папку или удалить.", + "onboarding.features.platformSwitch.label": "Смена платформы", + "onboarding.features.platformSwitch.body": "То же самое для Riot, Battle.net, Epic Games и остальных. Вкладки сверху переключают платформы.", + "onboarding.features.next": "Далее", + "onboarding.features.back": "Назад", + "onboarding.features.step": "Шаг {step}", + "onboarding.features.mockRename": "Переименовать", + "onboarding.features.mockSetColor": "Задать цвет", + "onboarding.features.mockMoveToFolder": "Переместить в папку", + "onboarding.features.mockDelete": "Удалить", + "onboarding.features.mockAccount": "Аккаунт {number}", + "onboarding.telemetry.title": "ПРОДАЙТЕ ДУШУ ACCSHIFT!", + "onboarding.telemetry.intro": "Accshift считает немного анонимной статистики, чтобы я знал, что чинить первым. Ничего личного вашу машину не покидает, приложение работает одинаково в любом случае, и вы всегда можете передумать в настройках.", + "onboarding.telemetry.question": "По рукам?", + "onboarding.telemetry.gifAlt": "Мем с предложением обмена", + "onboarding.telemetry.basic": "НЕТ! Хватит уже", + "onboarding.telemetry.basicDefault": "(по умолчанию)", + "onboarding.telemetry.basicHint": "Только анонимные счётчики и ничего больше: ни профиля, ни идентификатора, который следует за вами.", + "onboarding.telemetry.deal": "ПО РУКАМ", + "onboarding.telemetry.dealHint": "Добавляет отдельный UUID для расширенной статистики, он хранится только на вашей машине. Кто вы, по-прежнему неизвестно, но помощь огромная. Спасибо!", + "onboarding.telemetry.optOutNote": "Что бы вы ни выбрали, это можно изменить позже в разделе Настройки -> Приватность. :c", + "onboarding.telemetry.refuse": "Нет, спасибо, отключить всё", + "onboarding.telemetry.refuseHint": "Ничего не измеряется и не отправляется, даже анонимные счётчики.", + "onboarding.telemetry.back": "Назад", + "onboarding.persistFailed": "Не удалось сохранить этот выбор. Попробуй ещё раз.", + "onboarding.persistLater": "Спросить при следующем запуске", + "settings.steam": "Steam", + "settings.riot": "Riot", + "settings.battleNet": "Battle.net", + "settings.ubisoft": "Ubisoft Connect", + "settings.runSteamAsAdmin": "Запускать Steam от админа", + "settings.showUsernames": "Показывать логины", + "settings.showSteamLastLogin": "Показывать последний вход в Steam", + "settings.showRiotLastLogin": "Показывать последний вход в Riot", + "settings.showBattleNetLastLogin": "Показывать последний вход в Battle.net", + "settings.showUbisoftLastLogin": "Показывать последний вход в Ubisoft", + "settings.showNotesUnderCards": "Показывать заметки под карточками", + "settings.expandedFolders": "Разворачивать все папки", + "list.rootSection": "Корень", + "settings.steamLaunch": "Смена", + "settings.steamInstallation": "Установка", + "settings.steamApiAndData": "API и данные", + "settings.launchOptions": "Параметры запуска", + "settings.shutdownMode": "Режим завершения", + "settings.shutdownModeGraceful": "Штатный", + "settings.shutdownModeForce": "Принудительный", + "settings.autoDetected": "Определено автоматически", + "settings.steamFolder": "Папка Steam", + "settings.steamFolderRegistry": "Реестр", + "settings.riotClientPath": "Исполняемый файл Riot Client", + "settings.battleNetPath": "Исполняемый файл Battle.net", + "settings.ubisoftPath": "Папка Ubisoft Connect", + "settings.steamWebApiKey": "Ключ Steam Web API", + "settings.getKey": "Получить ключ", + "settings.pasteApiKey": "Вставьте ваш ключ Steam Web API", + "settings.saved": "Настройки сохранены", + "settings.openApiKeyFailed": "Не удалось открыть страницу ключа Steam API", + "settings.integrations": "Интеграции", + "settings.deepLinks": "Разрешить ссылки accshift://", + "settings.deepLinksHint": "Позволяет другим приложениям менять аккаунт за вас: закладки, Stream Deck, скрипты.", + "settings.cliEnabled": "Разрешить CLI accshift", + "settings.cliEnabledHint": "Позволяет утилите командной строки показывать список и менять аккаунты. Если выключено, любая команда accshift отказывается выполняться, включая diag; само приложение не затронуто.", + "settings.help": "Открыть вики accshift", + "settings.helpShort": "Справка", + "settings.openHelpFailed": "Не удалось открыть вики", + "settings.logs": "Логи", + "settings.openLogs": "Открыть папку логов", + "settings.openLogsFailed": "Не удалось открыть папку логов", + "context.loading": "Загрузка...", + "context.noGamesFound": "Игры не найдены", + "context.menu.newFolder": "Новая папка", + "context.menu.refresh": "Обновить", + "context.menu.rename": "Переименовать", + "context.menu.copy": "Копировать...", + "context.menu.deleteFolder": "Удалить папку", + "context.menu.folderColor": "Цвет папки", + "context.menu.moveToFolder": "Переместить в папку", + "context.menu.moveToRoot": "Без папки", + "context.menu.editCardAndColor": "Изменить карточку и цвет", + "context.menu.cardColor": "Цвет карточки", + "context.menu.addNote": "Добавить заметку", + "context.menu.editNote": "Изменить заметку", + "platform.rename": "Переименовать", + "platform.renameTitle": "Переименовать аккаунт", + "platform.renamePlaceholder": "Имя аккаунта", + "platform.newAccountPending": "Ожидание...", + "platform.setupBusy": "Выполняется другая операция accshift. Ожидание её завершения.", + "platform.renameNewAccount": "Назовите этот аккаунт", + "platform.copyLabelAccountId": "ID аккаунта", + "platform.forget": "Забыть", + "platform.forgetConfirmTitle": "Забыть «{display}»?", + "platform.forgetConfirmMessage": "Аккаунт и его сохранённая сессия будут удалены из Accshift.", + "platform.forgotAccount": "Забыт {display}", + "dialog.newFolderTitle": "Новая папка", + "dialog.renameFolderTitle": "Переименовать папку", + "dialog.folderNamePlaceholder": "Имя папки", + "dialog.cardNoteTitle": "Заметка карточки", + "dialog.cardNotePlaceholder": "Напишите короткую заметку", + "card.noteAttached": "Есть заметка", + "card.clickAgainToSwitch": "Нажмите ещё раз для смены", + "card.extensionWarnings": "Предупреждения", + "card.extensionNote": "Заметка", + "card.cs2Section": "CS2", + "card.cs2Level": "Уровень {level}", + "card.cs2CaseEarned": "Недельный кейс получен", + "card.cs2CaseNotEarned": "Недельный кейс ожидается", + "list.selectAccountPreview": "Выберите аккаунт для просмотра", + "preview.noteLabel": "Заметка", + "preview.currentlyActive": "Сейчас активен", + "preview.switchAgain": "Сменить снова", + "preview.switchAccount": "Сменить аккаунт", + "ban.community": "Бан сообщества", + "ban.vac.single": "{count} бан VAC", + "ban.vac.multiple": "{count} банов VAC", + "ban.game.single": "{count} игровой бан", + "ban.game.multiple": "{count} игровых банов", + "time.unknown": "неизвестно", + "time.justNow": "только что", + "time.minuteAgo": "{count} мин. назад", + "time.minutesAgo": "{count} мин. назад", + "time.hourAgo": "{count} ч. назад", + "time.hoursAgo": "{count} ч. назад", + "time.dayAgo": "{count} дн. назад", + "time.daysAgo": "{count} дн. назад", + "time.neverConnected": "входов не было", + "time.compactWrapper": "({value})", + "toast.copied": "{label} скопировано", + "toast.legacyConfigMigrated": "Настройки перенесены в новый формат", + "toast.legacyConfigMigrationFailed": "Не удалось перенести старые настройки, смотрите логи", + "toast.failedLoadSettingsPanel": "Не удалось загрузить панель настроек", + "toast.banChecking": "Проверка банов...", + "toast.banCheckSummary.single": "Проверка банов: {count} аккаунт с банами", + "toast.banCheckSummary.multiple": "Проверка банов: {count} аккаунтов с банами", + "toast.banCheckFailed": "Проверка банов не удалась: {error}", + "toast.avatarRefreshComplete": "Аватары обновлены для {count} аккаунтов", + "toast.banRefreshComplete": "Баны обновлены для {count} аккаунтов", + "toast.banRefreshNoApiKey": "Обновление банов пропущено: добавьте ключ Steam Web API в настройках", + "toast.refreshPartial": "Обновление завершено для {ok} из {count} аккаунтов", + "toast.noSteamAccountsFound": "Аккаунты Steam не найдены. Войдите в Steam хотя бы один раз, затем обновите.", + "toast.accountsRefreshed.single": "Обновлён {count} аккаунт", + "toast.accountsRefreshed.multiple": "Обновлено аккаунтов: {count}", + "toast.steamFolderNotFound": "Папка Steam не найдена. Укажите её вручную в «Настройки > Папка Steam».", + "toast.steamElevated": "Steam запущен от админа. Включите «Запускать Steam от админа» в «Настройки > Steam».", + "toast.noRiotProfilesFound": "Профилей Riot пока нет. Нажмите +, чтобы добавить.", + "toast.noBattleNetAccountsFound": "Аккаунты Battle.net не найдены. Войдите в Battle.net хотя бы один раз, затем обновите.", + "toast.noUbisoftAccountsFound": "Аккаунты Ubisoft не найдены. Войдите в Ubisoft Connect хотя бы один раз, затем обновите.", + "toast.deepLinkInvalid": "Нераспознанная ссылка accshift://", + "toast.deepLinkDisabled": "Ссылки accshift:// отключены в настройках", + "toast.deepLinkUnknownPlatform": "Неизвестная платформа в ссылке: {platform}", + "toast.deepLinkPlatformDisabled": "{platform} отключён в настройках", + "toast.deepLinkAccountNotFound": "Нет аккаунта {platform}, подходящего под «{account}»", + "toast.deepLinkSwitched": "Переключено на {account}", + "dialog.deepLinkSwitchTitle": "Сменить аккаунт?", + "dialog.deepLinkSwitchMessage": "Ссылка accshift:// просит сменить ваш аккаунт {platform} на «{account}». Продолжить?", + "dialog.deepLinkSwitchConfirm": "Сменить", + "steam.launchOnline": "Сменить в сети", + "steam.launchInvisible": "Сменить в невидимке", + "steam.switchAndRunGame": "Сменить + запустить {game}", + "steam.switchAndRunPick": "Сменить + запустить игру…", + "steam.runGame": "Запустить {game}", + "steam.runPick": "Запустить игру…", + "steam.switchAndRunNoGames": "Игры не найдены", + "steam.clearDefaultGame": "Сбросить игру по умолчанию", + "steam.copySteamId64": "Копировать SteamID64", + "steam.copyFriendCode": "Копировать Friend Code", + "steam.copyProfileUrl": "Копировать ссылку на профиль", + "steam.copyLabelUsername": "Логин", + "steam.copyLabelSteamId64": "SteamID64", + "steam.copyLabelFriendCode": "Friend Code", + "steam.copyLabelProfileUrl": "Ссылка на профиль", + "steam.openUserdataFolder": "Открыть папку userdata", + "steam.clearIntegratedBrowserCache": "Сбросить сессию браузера", + "steam.clearIntegratedBrowserCacheConfirmTitle": "Сбросить сессию браузера Steam?", + "steam.clearIntegratedBrowserCacheConfirmMessage": "Steam будет закрыт, а кэш встроенного браузера для Магазина и Сообщества очищен.", + "steam.clearedIntegratedBrowserCache": "Сессия браузера Steam сброшена", + "steam.copySettingsFrom": "Копировать настройки из", + "steam.copiedSettingsToCurrent": "Настройки {game} скопированы в текущий аккаунт", + "steam.forget": "Забыть", + "steam.forgetConfirmTitle": "Забыть «{display}»?", + "steam.forgetConfirmMessage": "Аккаунт будет удалён из списка аккаунтов Steam на этом ПК.", + "steam.forgotAccount": "Забыт {username}", + "steam.setupPendingLabel": "Аккаунт Steam", + "steam.setupWaitingForClient": "Ожидание Steam...", + "steam.setupWaitingForLogin": "Войдите в аккаунт Steam, который хотите добавить.", + "steam.setupDetected": "Аккаунт определён", + "steam.setupConnected": "Подключено", + "steam.setupReady": "Аккаунт Steam добавлен", + "steam.setupReadyWithProfile": "Добавлен {profile}", + "steam.setupFailed": "Не удалось настроить Steam", + "steam.setupFailedMessage": "Accshift не смог завершить добавление этого аккаунта Steam.", + "riot.captureSession": "Захватить текущую сессию", + "riot.recaptureSession": "Захватить сессию заново", + "riot.capturedSession": "Сессия для {profile} захвачена", + "riot.setupPendingLabel": "Аккаунт Riot", + "riot.setupWaitingForClient": "Ожидание Riot Client...", + "riot.setupWaitingForLogin": "Войдите в аккаунт Riot, который хотите добавить.", + "riot.setupCapturing": "Сохранение сессии Riot...", + "riot.setupReady": "Аккаунт Riot добавлен", + "riot.setupReadyWithProfile": "Добавлен {profile}", + "riot.setupFailed": "Не удалось настроить Riot", + "riot.setupFailedMessage": "Accshift не смог завершить добавление этого аккаунта Riot.", + "riot.setupStaySignedIn": "Оставьте включённой опцию «Stay signed in».", + "riot.setupDetected": "Аккаунт определён", + "riot.setupConnected": "Подключено", + "riot.forget": "Забыть аккаунт", + "riot.forgetConfirmTitle": "Забыть «{display}»?", + "riot.forgetConfirmMessage": "Локальный профиль Riot и его сохранённая сессия будут удалены из Accshift.", + "riot.forgotProfile": "Забыт {profile}", + "battlenet.copyUsername": "Копировать логин", + "battlenet.copyLabelUsername": "Логин", + "battlenet.copyEmail": "Копировать e-mail", + "battlenet.copyLabelEmail": "E-mail", + "battlenet.forget": "Забыть", + "battlenet.forgetConfirmTitle": "Забыть «{display}»?", + "battlenet.forgetConfirmMessage": "Аккаунт будет удалён из списка аккаунтов Battle.net на этом ПК.", + "battlenet.forgotAccount": "Забыт {email}", + "battlenet.setupPendingLabel": "Аккаунт Battle.net", + "battlenet.setupWaitingForClient": "Ожидание Battle.net...", + "battlenet.setupWaitingForLogin": "Войдите в аккаунт Battle.net, который хотите добавить.", + "battlenet.setupKeepMeLoggedIn": "По возможности оставьте включённой опцию «Keep me logged in».", + "battlenet.setupDetected": "Аккаунт определён", + "battlenet.setupConnected": "Подключено", + "battlenet.setupReady": "Аккаунт Battle.net добавлен", + "battlenet.setupReadyWithProfile": "Добавлен {profile}", + "battlenet.setupFailed": "Не удалось настроить Battle.net", + "battlenet.setupFailedMessage": "Accshift не смог завершить добавление этого аккаунта Battle.net.", + "ubisoft.copyLabelUuid": "UUID", + "ubisoft.forget": "Забыть", + "ubisoft.forgetConfirmTitle": "Забыть «{display}»?", + "ubisoft.forgetConfirmMessage": "Аккаунт и его сохранённая сессия будут удалены из Accshift.", + "ubisoft.forgotAccount": "Забыт {display}", + "ubisoft.setupPendingLabel": "Аккаунт Ubisoft", + "ubisoft.setupWaitingForClient": "Ожидание Ubisoft Connect...", + "ubisoft.setupWaitingForLogin": "Войдите в аккаунт Ubisoft, который хотите добавить.", + "ubisoft.setupKeepMeLoggedIn": "Оставьте включённой опцию «Remember me».", + "ubisoft.setupDetected": "Аккаунт определён", + "ubisoft.setupConnected": "Подключено", + "ubisoft.setupReady": "Аккаунт Ubisoft добавлен", + "ubisoft.setupReadyWithProfile": "Добавлен {profile}", + "ubisoft.setupFailed": "Не удалось настроить Ubisoft", + "ubisoft.setupFailedMessage": "Accshift не смог завершить добавление этого аккаунта Ubisoft.", + "settings.roblox": "Roblox", + "toast.noRobloxAccountsFound": "Аккаунты Roblox не найдены. Добавьте аккаунт через Quick Login или вставьте cookie.", + "roblox.noAccountsHint": "Нажмите +, чтобы добавить аккаунт Roblox через Quick Login.", + "roblox.sessionExpired": "Сессия истекла", + "roblox.sessionExpiredTooltip": "Из этого аккаунта выполнен выход. Добавьте его заново перед сменой.", + "roblox.sessionExpiredSwitchError": "Сессия истекла. Добавьте этот аккаунт заново, чтобы переключиться. Заметки и настройки сохранятся.", + "roblox.cookiePasteTitle": "Добавить аккаунт по cookie", + "roblox.cookiePasteHint": "Вставьте cookie .ROBLOSECURITY из браузера, чтобы добавить аккаунт напрямую.", + "settings.showRobloxLastLogin": "Показывать последний вход", + "settings.robloxHealthCheck": "Проверка сессии", + "settings.robloxHealthCheckHint": "Помечает аккаунты с истёкшей сессией Roblox; один сетевой запрос за запуск.", + "roblox.copyLabelUsername": "Логин", + "roblox.copyLabelUserId": "ID пользователя", + "roblox.forget": "Забыть", + "roblox.forgetConfirmTitle": "Забыть «{display}»?", + "roblox.forgetConfirmMessage": "Аккаунт и сохранённый cookie будут удалены с этого ПК.", + "roblox.forgotAccount": "Забыт {display}", + "roblox.setupPendingLabel": "Аккаунт Roblox", + "roblox.setupWaitingForClient": "Подключение к Roblox...", + "roblox.setupWaitingForLogin": "Откройте приложение Roblox на другом устройстве и введите код ниже.", + "roblox.setupQuickLoginCode": "Код Quick Login", + "roblox.setupQuickLoginHint": "roblox.com/quick-login", + "roblox.copyCode": "Копировать код", + "roblox.setupDetected": "Аккаунт определён", + "roblox.setupConnected": "Подключено", + "roblox.setupReady": "Аккаунт Roblox добавлен", + "roblox.setupReadyWithProfile": "Добавлен {profile}", + "roblox.setupFailed": "Не удалось настроить Roblox", + "roblox.setupFailedMessage": "Accshift не смог завершить добавление этого аккаунта Roblox.", + "settings.epic": "Epic Games", + "settings.epicPath": "Исполняемый файл Epic Games Launcher", + "settings.showEpicLastLogin": "Показывать последний вход", + "toast.noEpicAccountsFound": "Аккаунты Epic Games не найдены. Войдите в Epic Games Launcher хотя бы один раз, затем обновите.", + "epic.noAccountsHint": "Нажмите +, чтобы добавить аккаунт Epic Games.", + "epic.copyLabelAccountId": "ID аккаунта", + "epic.forget": "Забыть", + "epic.forgetConfirmTitle": "Забыть «{display}»?", + "epic.forgetConfirmMessage": "Аккаунт и его сохранённая сессия будут удалены из Accshift.", + "epic.forgotAccount": "Забыт {display}", + "epic.setupPendingLabel": "Аккаунт Epic Games", + "epic.setupWaitingForClient": "Ожидание Epic Games Launcher...", + "epic.setupWaitingForLogin": "Войдите в аккаунт Epic Games, который хотите добавить.", + "epic.setupKeepMeLoggedIn": "Оставьте включённой опцию «Remember me».", + "epic.setupDetected": "Аккаунт определён", + "epic.setupConnected": "Подключено", + "epic.setupReady": "Аккаунт Epic Games добавлен", + "epic.setupReadyWithProfile": "Добавлен {profile}", + "epic.setupFailed": "Не удалось настроить Epic Games", + "epic.setupFailedMessage": "Accshift не смог завершить добавление этого аккаунта Epic Games.", + "settings.gog": "GOG Galaxy", + "settings.gogPath": "Исполняемый файл GOG Galaxy", + "settings.showGogLastLogin": "Показывать последний вход", + "toast.noGogAccountsFound": "Аккаунты GOG Galaxy не найдены. Войдите в GOG Galaxy хотя бы один раз, затем обновите.", + "gog.noAccountsHint": "Нажмите +, чтобы добавить аккаунт GOG Galaxy.", + "gog.copyLabelAccountId": "ID аккаунта", + "gog.forget": "Забыть", + "gog.forgetConfirmTitle": "Забыть «{display}»?", + "gog.forgetConfirmMessage": "Аккаунт и его сохранённая сессия будут удалены из Accshift.", + "gog.forgotAccount": "Забыт {display}", + "gog.setupPendingLabel": "Аккаунт GOG Galaxy", + "gog.setupWaitingForClient": "Ожидание GOG Galaxy...", + "gog.setupWaitingForLogin": "Войдите в аккаунт GOG Galaxy, который хотите добавить.", + "gog.setupKeepMeLoggedIn": "Оставьте включённой опцию «Stay signed in».", + "gog.setupDetected": "Аккаунт определён", + "gog.setupConnected": "Подключено", + "gog.setupReady": "Аккаунт GOG Galaxy добавлен", + "gog.setupReadyWithProfile": "Добавлен {profile}", + "gog.setupFailed": "Не удалось настроить GOG Galaxy", + "gog.setupFailedMessage": "Accshift не смог завершить добавление этого аккаунта GOG Galaxy.", + "settings.jagex": "Jagex Launcher", + "settings.jagexPath": "Исполняемый файл Jagex Launcher", + "settings.showJagexLastLogin": "Показывать последний вход", + "toast.noJagexAccountsFound": "Аккаунты Jagex ещё не сохранены. Нажмите +, чтобы добавить через Jagex Launcher.", + "jagex.noAccountsHint": "Нажмите +, чтобы добавить аккаунт Jagex.", + "jagex.copyLabelAccountId": "ID аккаунта", + "jagex.forget": "Забыть", + "jagex.forgetConfirmTitle": "Забыть «{display}»?", + "jagex.forgetConfirmMessage": "Аккаунт и его сохранённая сессия будут удалены из Accshift.", + "jagex.forgotAccount": "Забыт {display}", + "jagex.setupPendingLabel": "Аккаунт Jagex", + "jagex.setupWaitingForClient": "Ожидание Jagex Launcher...", + "jagex.setupWaitingForLogin": "Войдите в аккаунт Jagex, который хотите добавить.", + "jagex.setupKeepMeLoggedIn": "Оставьте включённой опцию «Remember me».", + "jagex.setupDetected": "Аккаунт определён", + "jagex.setupConnected": "Подключено", + "jagex.setupReady": "Аккаунт Jagex добавлен", + "jagex.setupReadyWithProfile": "Добавлен {profile}", + "jagex.setupFailed": "Не удалось настроить Jagex", + "jagex.setupFailedMessage": "Accshift не смог завершить добавление этого аккаунта Jagex.", + "settings.discord": "Discord", + "settings.discordPath": "Исполняемый файл Discord", + "settings.showDiscordLastLogin": "Показывать последний вход", + "toast.noDiscordAccountsFound": "Аккаунты Discord не найдены. Добавьте аккаунт Discord, затем обновите.", + "discord.noAccountsHint": "Нажмите +, чтобы добавить аккаунт Discord.", + "discord.copyLabelAccountId": "ID аккаунта", + "discord.forget": "Забыть", + "discord.forgetConfirmTitle": "Забыть «{display}»?", + "discord.forgetConfirmMessage": "Аккаунт и его сохранённая сессия будут удалены из Accshift.", + "discord.forgotAccount": "Забыт {display}", + "discord.setupPendingLabel": "Аккаунт Discord", + "discord.setupWaitingForClient": "Ожидание Discord...", + "discord.setupWaitingForLogin": "Войдите в аккаунт Discord, который хотите добавить.", + "discord.setupKeepMeLoggedIn": "Не выходите из аккаунта, чтобы он сохранился.", + "discord.setupDetected": "Аккаунт определён", + "discord.setupConnected": "Подключено", + "discord.setupReady": "Аккаунт Discord добавлен", + "discord.setupReadyWithProfile": "Добавлен {profile}", + "discord.setupFailed": "Не удалось настроить Discord", + "discord.setupFailedMessage": "Accshift не смог завершить добавление этого аккаунта Discord.", + "color.none": "По умолчанию", + "color.blue": "Синий", + "color.cyan": "Голубой", + "color.green": "Зелёный", + "color.lime": "Лаймовый", + "color.yellow": "Жёлтый", + "color.orange": "Оранжевый", + "color.red": "Красный", + "color.pink": "Розовый", + "color.violet": "Фиолетовый", + "color.gray": "Серый", + "language.english": "English", + "language.french": "Français", + "language.spanish": "Español", + "language.portuguese": "Português (Portugal)", + "language.portugueseBrazil": "Português (Brasil)", + "language.russian": "Русский", + "language.chinese": "简体中文", + "theme.dark": "Тёмная", + "theme.light": "Светлая", + "theme.midnight": "Полночь", + "theme.glassDark": "Стекло тёмное", + "theme.glassLight": "Стекло светлое", + "theme.liquidGlass": "Жидкое стекло", + "theme.custom": "Своя", + "settings.themeImport": "Импорт", + "settings.themeExport": "Экспорт", + "settings.themeDelete": "Удалить тему", + "settings.themeImported": "Тема импортирована", + "settings.themeDeleted": "Тема удалена", + "settings.themeExportCopied": "JSON темы скопирован в буфер обмена", + "settings.themeInvalidJson": "Некорректный JSON темы", + "settings.themeCustomize": "Настроить", + "settings.themeEdit": "Изменить", + "settings.themeCopyName": "{name} копия", + "settings.themeSaved": "Тема сохранена", + "settings.themeMigrated": "Тема импортирована и переведена в текущий формат", + "settings.themeInvalidId": "Идентификатор темы отсутствует или некорректен", + "settings.themeInvalidName": "У темы нет названия", + "settings.themeUnsupportedVersion": "Этой теме нужна более новая версия accshift", + "themeGroup.surface": "Поверхности", + "themeGroup.text": "Текст", + "themeGroup.semantic": "Цвета состояний", + "themeGroup.shape": "Форма и плотность", + "themeGroup.typography": "Типографика", + "themeGroup.motion": "Движение", + "themeKind.rgbTriplet": "триплет RGB", + "themeKind.hexColor": "шестнадцатеричный цвет", + "themeKind.color": "цвет", + "themeKind.length": "длина", + "themeKind.shadow": "тень", + "themeKind.choice": "вариант", + "themeKind.fontStack": "шрифт", + "themeKind.signedLength": "интервал", + "themeKind.gradient": "градиент", + "themeKind.number": "число", + "themeDensity.compact": "Плотная", + "themeDensity.cozy": "Обычная", + "themeDensity.comfortable": "Свободная", + "themeAvatarShape.circle": "Круг", + "themeAvatarShape.rounded": "Скруглённый", + "themeAvatarShape.square": "Квадрат", + "themeSmoothing.auto": "Сглаженный", + "themeSmoothing.none": "Пиксельный", + "themeToken.bgRgb": "Фон окна", + "themeToken.bgCard": "Фон карточек", + "themeToken.bgCardHover": "Фон карточек при наведении", + "themeToken.bgMuted": "Приглушённый фон", + "themeToken.bgElevated": "Приподнятый фон", + "themeToken.border": "Границы", + "themeToken.fg": "Основной текст", + "themeToken.fgMuted": "Второстепенный текст", + "themeToken.fgSubtle": "Малозаметный текст", + "themeToken.afkText": "Отметка отсутствия", + "themeToken.accent": "Акцент", + "themeToken.accentFg": "Текст на акценте", + "themeToken.success": "Успех", + "themeToken.warning": "Предупреждение", + "themeToken.danger": "Опасность", + "themeToken.radiusSm": "Малое скругление", + "themeToken.radiusMd": "Среднее скругление", + "themeToken.radiusLg": "Большое скругление", + "themeToken.elevationLow": "Лёгкая тень", + "themeToken.elevationMedium": "Средняя тень", + "themeToken.elevationHigh": "Сильная тень", + "themeToken.density": "Плотность", + "themeToken.fontUi": "Шрифт интерфейса", + "themeToken.bgImage": "Градиент окна", + "themeToken.cardBgImage": "Градиент поверхностей", + "themeToken.borderWidth": "Толщина границ", + "themeToken.borderStyle": "Стиль границ", + "themeToken.avatarShape": "Форма аватаров", + "themeToken.focusRing": "Рамка фокуса", + "themeToken.overlayBlur": "Размытие фона", + "themeToken.fontDisplay": "Шрифт заголовков", + "themeToken.lineHeight": "Высота строки", + "themeToken.letterSpacing": "Межбуквенный интервал", + "themeToken.labelCase": "Регистр подписей", + "themeToken.fontSmoothing": "Сглаживание текста", + "themeToken.motionScale": "Скорость анимаций", + "themeEditor.title": "Редактор тем", + "themeEditor.name": "Название", + "themeEditor.author": "Автор", + "themeEditor.version": "Версия", + "themeEditor.base": "Наследует от", + "themeEditor.colorScheme": "Цветовая схема", + "themeEditor.glass": "Полупрозрачные поверхности", + "themeEditor.inherited": "Унаследовано", + "themeEditor.builtInBase": "Встроенная тема", + "themeEditor.reset": "Вернуть унаследованное значение", + "themeEditor.issues": "Проверки", + "themeEditor.noIssues": "Замечаний нет", + "themeEditor.fixErrorsFirst": "Исправьте отклонённые значения перед сохранением", + "themeEditor.save": "Сохранить", + "themeEditor.saveFailed": "Не удалось сохранить тему. Попробуй ещё раз.", + "themeEditor.export": "Копировать", + "themeEditor.issueInvalidValue": "{token}: недопустимое значение ({kind}), например {example}", + "themeEditor.issueUnknownToken": "{token} не является значением темы и игнорируется", + "themeEditor.issueMissingToken": "{token} не задан, используется встроенное значение", + "themeEditor.issueUnknownBase": "Базовая тема {base} не найдена, используются встроенные значения", + "themeEditor.issueContrast": "{token} на {against}: {ratio} к 1, ниже цели {target} к 1", + "themeEditor.beta": "Бета", + "themeEditor.betaNote": "Формат темы ещё меняется: тему, написанную сегодня, возможно, придётся поправить после обновления.", + "themeEditor.customCss": "Свой CSS", + "themeEditor.customCssHelp": "Применяется поверх темы, для того, до чего значения выше не достают. Без @import и url(): тема ничего не загружает извне.", + "themeEditor.issueUnsafeCss": "{construct} в теме запрещён, CSS не применяется", + "themeEditor.issueCssTooLong": "CSS слишком длинный: {length} символов, предел {limit}", + "bulkEdit.title": "Массовое изменение", + "bulkEdit.selectAccounts": "Выберите аккаунты", + "bulkEdit.selectAll": "Выбрать все", + "bulkEdit.deselectAll": "Снять выбор", + "bulkEdit.next": "Далее", + "bulkEdit.apply": "Применить", + "bulkEdit.settings": "Настройки", + "bulkEdit.newsPopup": "Всплывающие новости игры", + "bulkEdit.doNotDisturb": "Не беспокоить", + "bulkEdit.launchOptions": "Параметры запуска по играм", + "bulkEdit.unchanged": "Без изменений", + "bulkEdit.addLaunchOption": "Добавить", + "bulkEdit.selectGame": "Выберите игру...", + "bulkEdit.launchOptionsPlaceholder": "-fullscreen", + "bulkEdit.activeAccountWarning": "Выбран активный сейчас аккаунт. Изменения применятся при следующем запуске Steam.", + "bulkEdit.toastSuccess": "Массовое изменение: успешно {count}", + "bulkEdit.toastPartial": "Массовое изменение: успешно {succeeded}, с ошибкой {failed}", + "bulkEdit.toastFailed": "Массовое изменение не удалось", + "bulkEdit.noAccountsSelected": "Аккаунты не выбраны", + "bulkEdit.selected": "Выбрано: {count}", + "bulkEdit.loadingGames": "Загрузка игр...", + "bulkEdit.copyUrls": "Копировать ссылки", + "bulkEdit.urlsCopied": "Скопировано ссылок на профиль: {count}", + "bulkEdit.cardColor": "Цвет карточки", + "bulkEdit.colorApplied": "Цвет применён к карточкам: {count}", + "bulkEdit.colorCleared": "Цвет снят с карточек: {count}", + "common.retry": "Повторить", + "app.searchNoResults": "Нет результатов по запросу «{query}»", + "app.folderEmpty": "Эта папка пуста", + "app.loadErrorGeneric": "Не удалось загрузить аккаунты. Подробности в консоли.", + "toast.dismiss": "Закрыть", + "toast.switchFailed": "Не удалось сменить аккаунт. Подробности в консоли.", + "toast.addAccountFailed": "Не удалось добавить аккаунт. Подробности в логах приложения.", + "toast.addAccountClientMissing": "Похоже, {platform} не установлен. Установите его или укажите путь в настройках.", + "toast.banCheckFailedGeneric": "Проверка банов не удалась. Подробности в консоли.", + "toast.renameAccountFailed": "Не удалось переименовать: {error}", + "toast.copyFailed": "Не удалось скопировать в буфер обмена", + "toast.refreshFailed": "Не удалось обновить. Подробности в консоли.", + "dialog.deleteFolderTitle": "Удалить «{name}»?", + "dialog.deleteFolderMessage": "Вложенные папки тоже удаляются. Аккаунты внутри поднимаются на уровень выше, а не удаляются.", + "personas.deleteConfirm": "Нажмите ещё раз для удаления", + "personas.discardConfirm": "Нажмите ещё раз, чтобы отменить", + "personas.switchConfirmTitle": "Смена персоны", + "personas.switchConfirmMessage": "Переключить платформы ({count}) на «{name}»? Каждый клиент закроется и запустится заново.", + "personas.switchConfirmAction": "Переключить", + "personas.deleteTitle": "Удалить персону", + "personas.deleteMessage": "Удалить «{name}»? Сами аккаунты не затрагиваются.", + "context.submenuError": "Не удалось загрузить это меню", + "settings.apiKeySaveFailed": "Не удалось сохранить ключ Steam API", + "settings.apiKeyInvalidHint": "Ключ не сохранён. Проверьте его и попробуйте снова.", + "settings.apiKeyCleared": "Ключ Steam API удалён", + "settings.apiKeyClearFailed": "Не удалось удалить ключ Steam API", + "settings.clearApiKey": "Удалить ключ", + "settings.cs2Bridge": "Мост статистики CS2", + "settings.cs2BridgeLink": "Ссылка на мост", + "settings.cs2BridgeLinkPlaceholder": "https://your-server/api/bridge/accshift/", + "settings.cs2BridgeTest": "Проверить", + "settings.cs2BridgeStatusOk": "Подключено · {count} аккаунтов · {ms} мс", + "settings.cs2BridgeStatusFail": "Не удалось подключиться", + "settings.cs2BridgeStatusTesting": "Проверка...", + "settings.cs2BridgeStatusUnknown": "Не проверялось", + "settings.cs2BridgeToken": "Токен API", + "settings.cs2BridgeTokenOptional": "необязательно, для своих серверов", + "settings.cs2BridgeTokenPlaceholder": "Bearer-токен, если он нужен серверу", + "settings.cs2BridgeSaveFailed": "Не удалось сохранить настройки моста CS2", + "settings.cs2BridgeClearToken": "Удалить токен", + "settings.pathSaveFailed": "Не удалось сохранить путь {platform}", + "settings.pathInvalidHint": "Этот путь не удалось сохранить. Проверьте его и попробуйте снова.", + "settings.pathSteamNeverSignedIn": "Steam найден, войдите один раз, чтобы он создал историю входов", + "settings.pathNotSteamFolder": "Эта папка не похожа на установку Steam", + "settings.pathNotADirectory": "Путь должен указывать на существующую папку", + "settings.pinSaved": "PIN сохранён", + "settings.pinTakesEffectNextLaunch": "Вступит в силу при следующем запуске приложения", + "settings.pinRequiredAfterInactivity": "Требуется после бездействия и при запуске приложения", + "settings.pinScreenLockOnly": "PIN блокирует это окно и переключение аккаунтов. Он ничего не защищает на диске.", + "settings.unitSeconds": "секунд", + "settings.unitDays": "дней", + "settings.telemetryLoadFailed": "Не удалось загрузить настройки телеметрии", + "palette.placeholder": "Введите команду или имя аккаунта...", + "palette.noResults": "Подходящих команд нет", + "palette.sectionAccounts": "Аккаунты", + "palette.sectionActions": "Действия", + "palette.sectionNavigation": "Навигация", + "palette.openTab": "Перейти к {platform}", + "palette.openFolder": "Открыть папку «{name}»", + "palette.backToParent": "Вернуться в родительскую папку", + "palette.zoomReset": "Сбросить масштаб" +} diff --git a/crates/accshift-app/i18n/zh.json b/crates/accshift-app/i18n/zh.json new file mode 100644 index 0000000..71b74d1 --- /dev/null +++ b/crates/accshift-app/i18n/zh.json @@ -0,0 +1,741 @@ +{ + "app.loadingSettings": "正在加载设置...", + "app.loading": "加载中...", + "app.searchPlaceholder": "搜索账号...", + "app.noAccountsFound": "未找到 {platform} 账号", + "app.noAccountsHint": "请确认已安装 {platform} 并且至少登录过一次。", + "app.switchingAccount": "正在切换账号...", + "app.platformRestarting": "{platform} 正在重启", + "app.comingSoon": "{platform} - 即将推出", + "app.platformUnsupportedOs": "{platform} 尚未支持当前系统", + "app.noCompatiblePlatforms": "当前系统没有可用的兼容平台", + "app.noCompatiblePlatformsHint": "请关注 Windows 支持更新,或改用受支持的设备。", + "update.ctaAvailable": "有可用更新", + "update.ctaInstalling": "正在安装...", + "update.restartToApplyVersion": "重启以应用更新 {version}", + "update.restartToApply": "重启以应用更新", + "update.readyToastVersion": "更新 {version} 已就绪", + "update.readyToast": "更新已就绪", + "update.restartFailed": "无法重启以应用更新", + "pin.lockedTitle": "应用已锁定", + "pin.lockedPrompt": "输入 PIN 解锁", + "pin.placeholder": "4 位 PIN", + "pin.unlock": "解锁", + "pin.invalid": "PIN 错误", + "pin.tooManyAttempts": "尝试次数过多,请在 {seconds} 秒后重试。", + "common.ok": "确定", + "common.cancel": "取消", + "common.confirm": "确认", + "common.close": "关闭", + "common.back": "返回", + "common.active": "使用中", + "common.choose": "选择...", + "common.none": "无", + "common.custom": "自定义", + "common.configured": "已配置", + "common.missing": "缺失", + "common.enabled": "已启用", + "common.disabled": "已禁用", + "common.on": "开", + "common.off": "关", + "common.add": "添加", + "common.visible": "显示", + "common.hidden": "隐藏", + "common.inline": "内嵌", + "common.tooltip": "悬浮提示", + "titlebar.refresh": "刷新", + "titlebar.addAccount": "添加账号", + "titlebar.settings": "设置", + "titlebar.personas": "身份", + "titlebar.minimize": "最小化", + "titlebar.maximize": "最大化", + "titlebar.restore": "还原", + "titlebar.close": "关闭", + "view.grid": "网格视图", + "view.list": "列表视图", + "settings.title": "设置", + "settings.general": "通用", + "settings.appearance": "外观", + "settings.language": "语言", + "settings.theme": "主题", + "settings.uiZoom": "界面缩放", + "settings.backgroundOpacity": "背景不透明度", + "settings.animations": "动画", + "settings.animationsSystem": "跟随系统", + "settings.animationsOn": "始终开启", + "settings.animationsOff": "关闭", + "settings.performance": "性能", + "settings.suspendGraphicsWhenMinimized": "最小化时暂停渲染", + "settings.minimizeOnAccountSwitch": "切换账号时最小化", + "settings.platforms": "平台", + "settings.platformSearchPlaceholder": "搜索平台...", + "settings.platformSearchNoResults": "没有匹配的平台。", + "settings.accountDisplay": "账号显示", + "settings.startupAndExtras": "启动", + "settings.defaultOnStartup": "启动时的默认平台", + "settings.platformDisabledSuffix": "(已禁用)", + "settings.platformUnsupportedOs": "系统不支持", + "settings.platformNotImplemented": "计划中", + "settings.platformUserProvided": "来自你的描述符文件夹", + "settings.customPlatforms": "自定义平台", + "settings.customPlatformsHint": "此文件夹中的描述符文件会在应用启动时读取。", + "settings.customPlatformsNone": "此文件夹中没有加载任何描述符。", + "settings.customPlatformsProblems": "未能加载的文件", + "descriptor.addFromFile": "从文件添加", + "descriptor.reload": "重新加载", + "descriptor.openFolder": "打开文件夹", + "descriptor.remove": "移除", + "descriptor.removeConfirmTitle": "移除 {platform}?", + "descriptor.removeConfirmMessage": "它的描述符文件会离开你的文件夹。已保存的账号仍留在磁盘上。", + "descriptor.previewTitle": "添加平台", + "descriptor.previewOs": "适用于 {list}", + "descriptor.previewRoots": "它可以访问的文件夹", + "descriptor.previewSteps": "切换账号会做什么", + "descriptor.previewWarnings": "警告", + "descriptor.previewNoPlan": "无法生成试运行:{problem}", + "descriptor.previewReplaces": "这会替换你文件夹中已有的描述符。", + "descriptor.previewNothingWritten": "在你添加之前不会写入任何内容。", + "descriptor.replace": "替换", + "descriptor.installed": "已添加 {platform}", + "descriptor.removed": "已移除 {platform}", + "descriptor.actionRead": "读取", + "descriptor.actionCapture": "保存", + "descriptor.actionRestore": "恢复", + "descriptor.actionDelete": "删除", + "descriptor.actionClose": "关闭", + "descriptor.actionLaunch": "启动", + "settings.dataRefresh": "数据刷新", + "settings.avatarRefresh": "头像刷新", + "settings.banCheckDelay": "封禁检查间隔", + "settings.refreshNow": "立即刷新", + "settings.zeroEachLaunch": "0 = 每次启动", + "settings.privacy": "隐私", + "settings.inactivityTimeout": "无操作超时", + "settings.zeroDisabled": "0 = 禁用", + "settings.streamerMode": "直播模式", + "settings.streamerModeHint": "检测到直播软件运行时自动模糊账号信息。", + "settings.personasHint": "显示身份切换器", + "settings.replayOnboarding": "重看引导", + "settings.replayOnboardingHint": "再次显示欢迎导览和使用统计选项。", + "streamer.title": "直播模式", + "streamer.disable": "关闭", + "streamer.disablePermanently": "永久关闭", + "personas.title": "身份", + "personas.create": "新建身份", + "personas.activate": "切换到该身份", + "personas.edit": "编辑", + "personas.delete": "删除", + "personas.finish": "完成", + "personas.switching": "切换中...", + "personas.switched": "已切换到 {name}", + "personas.switchPartial": "{name}:{failed} 切换失败", + "personas.switchFailed": "无法切换到 {name}", + "personas.namePlaceholder": "身份名称", + "personas.choosePlatforms": "为每个要包含的平台选择一个账号", + "personas.chooseAccount": "选择 {platform} 账号", + "personas.removeFromPersona": "从该身份中移除", + "personas.customImage": "选择图片...", + "personas.removeImage": "移除图片", + "personas.imageError": "无法加载该图片。", + "personas.loadingAccounts": "正在加载账号...", + "personas.noAccountsOnPlatform": "该平台还没有账号", + "personas.emptyHint": "点击一次即可把所有已包含的平台切换到对应账号。", + "settings.security": "安全", + "settings.pinLockOnAfk": "挂机时 PIN 锁定", + "settings.pinCode": "PIN 码", + "settings.pinPlaceholder": "4 位数字", + "settings.pinCurrentCode": "当前 PIN", + "settings.pinCurrentHint": "输入当前 PIN 以更改或关闭锁定。", + "settings.telemetry": "使用统计(匿名)", + "settings.telemetryModeA": "匿名基础统计", + "settings.telemetryModeAHint": "统计启动次数和功能使用情况。随机本地 ID 用于避免重复计入安装量,不会发送任何个人数据。", + "settings.telemetryModeB": "增强统计,附带本地 UUID(可选)", + "settings.telemetryModeBHint": "额外生成一个仅保存在本机的随机 UUID,用于衡量留存率和功能价值。同样不会发送个人数据。", + "settings.telemetryExport": "导出我的数据", + "settings.telemetryExported": "数据已复制到剪贴板", + "settings.telemetryExportFailed": "导出失败", + "settings.telemetryEnableFailed": "无法启用增强统计", + "settings.telemetryDisableFailed": "无法关闭增强统计", + "settings.telemetryDeletePending": "增强统计已关闭,服务器数据删除需要再试一次", + "settings.telemetryDeletePendingHint": "服务器删除仍在等待中。保留旧的本地 ID 只是为了让你重试删除或导出相关数据。", + "settings.telemetryRetryDelete": "重试删除数据", + "settings.telemetryDeleteCompleted": "服务器数据已删除", + "settings.telemetryDeleteRetryFailed": "服务器删除仍在等待中", + "settings.telemetryLearnMore": "会发送哪些数据?", + "onboarding.welcome.title": "欢迎使用 Accshift", + "onboarding.welcome.version": "v{version}", + "onboarding.welcome.compatibleWith": "你的操作系统兼容以下平台:", + "onboarding.welcome.detectedPlatforms": "在你的电脑上检测到以下平台:", + "onboarding.welcome.next": "带我看看", + "onboarding.welcome.skip": "跳过", + "onboarding.features.hint": "应用的这一部分目前不可见。", + "onboarding.features.addAccount.label": "添加账号", + "onboarding.features.addAccount.body": "右上角的 + 按钮可以为当前平台添加账号。登录一次,Accshift 就帮你记住。", + "onboarding.features.switch.label": "切换账号", + "onboarding.features.switch.body": "点一下卡片就切到那个账号。说实话,整个应用就这么简单。", + "onboarding.features.contextMenu.label": "右键查看操作", + "onboarding.features.contextMenu.body": "右键点击卡片可以重命名、改颜色、移动到文件夹或删除。", + "onboarding.features.platformSwitch.label": "切换平台", + "onboarding.features.platformSwitch.body": "Riot、Battle.net、Epic 等平台也一样。顶部的标签页用来切换平台。", + "onboarding.features.next": "下一步", + "onboarding.features.back": "返回", + "onboarding.features.step": "第 {step} 步", + "onboarding.features.mockRename": "重命名", + "onboarding.features.mockSetColor": "设置颜色", + "onboarding.features.mockMoveToFolder": "移动到文件夹", + "onboarding.features.mockDelete": "删除", + "onboarding.features.mockAccount": "账号 {number}", + "onboarding.telemetry.title": "把灵魂卖给 ACCSHIFT!", + "onboarding.telemetry.intro": "Accshift 会统计少量匿名使用数据,这样我才知道该先修哪里。没有任何个人信息离开你的电脑,开不开都不影响使用,你随时可以在设置里改主意。", + "onboarding.telemetry.question": "成交吗?", + "onboarding.telemetry.gifAlt": "交易提议表情包", + "onboarding.telemetry.basic": "不!够了", + "onboarding.telemetry.basicDefault": "(默认)", + "onboarding.telemetry.basicHint": "只有匿名计数,仅此而已:没有画像,没有跟着你的标识符。", + "onboarding.telemetry.deal": "成交", + "onboarding.telemetry.dealHint": "额外增加一个仅保存在本机的 UUID 用于增强统计。依然不知道你是谁,但你帮了大忙。谢谢!", + "onboarding.telemetry.optOutNote": "无论你选哪个,之后都可以在设置 -> 隐私里改。:c", + "onboarding.telemetry.refuse": "不用了,全部关闭", + "onboarding.telemetry.refuseHint": "什么都不统计、不发送,连匿名计数也没有。", + "onboarding.telemetry.back": "返回", + "onboarding.persistFailed": "未能保存这个选择。请再试一次。", + "onboarding.persistLater": "下次启动时再问", + "settings.steam": "Steam", + "settings.riot": "Riot", + "settings.battleNet": "Battle.net", + "settings.ubisoft": "Ubisoft Connect", + "settings.runSteamAsAdmin": "以管理员身份运行 Steam", + "settings.showUsernames": "显示用户名", + "settings.showSteamLastLogin": "显示 Steam 上次登录", + "settings.showRiotLastLogin": "显示 Riot 上次登录", + "settings.showBattleNetLastLogin": "显示 Battle.net 上次登录", + "settings.showUbisoftLastLogin": "显示 Ubisoft 上次登录", + "settings.showNotesUnderCards": "在卡片下方显示备注", + "settings.expandedFolders": "展开所有文件夹", + "list.rootSection": "根目录", + "settings.steamLaunch": "切换", + "settings.steamInstallation": "安装", + "settings.steamApiAndData": "API 与数据", + "settings.launchOptions": "启动选项", + "settings.shutdownMode": "关闭方式", + "settings.shutdownModeGraceful": "正常退出", + "settings.shutdownModeForce": "强制结束", + "settings.autoDetected": "自动检测", + "settings.steamFolder": "Steam 文件夹", + "settings.steamFolderRegistry": "注册表", + "settings.riotClientPath": "Riot 客户端可执行文件", + "settings.battleNetPath": "Battle.net 可执行文件", + "settings.ubisoftPath": "Ubisoft Connect 文件夹", + "settings.steamWebApiKey": "Steam Web API 密钥", + "settings.getKey": "获取密钥", + "settings.pasteApiKey": "粘贴你的 Steam Web API 密钥", + "settings.saved": "设置已保存", + "settings.openApiKeyFailed": "无法打开 Steam API 密钥页面", + "settings.integrations": "集成", + "settings.deepLinks": "允许 accshift:// 链接", + "settings.deepLinksHint": "让其他应用替你切换账号:书签、Stream Deck、脚本。", + "settings.cliEnabled": "允许 accshift 命令行工具", + "settings.cliEnabledHint": "让命令行工具列出并切换账号。关闭后,所有 accshift 命令都会拒绝运行,包括 diag;应用本身不受影响。", + "settings.help": "打开 accshift 维基", + "settings.helpShort": "帮助", + "settings.openHelpFailed": "无法打开维基", + "settings.logs": "日志", + "settings.openLogs": "打开日志文件夹", + "settings.openLogsFailed": "无法打开日志文件夹", + "context.loading": "加载中...", + "context.noGamesFound": "未找到游戏", + "context.menu.newFolder": "新建文件夹", + "context.menu.refresh": "刷新", + "context.menu.rename": "重命名", + "context.menu.copy": "复制...", + "context.menu.deleteFolder": "删除文件夹", + "context.menu.folderColor": "文件夹颜色", + "context.menu.moveToFolder": "移动到文件夹", + "context.menu.moveToRoot": "不放入文件夹", + "context.menu.editCardAndColor": "编辑卡片和颜色", + "context.menu.cardColor": "卡片颜色", + "context.menu.addNote": "添加备注", + "context.menu.editNote": "编辑备注", + "platform.rename": "重命名", + "platform.renameTitle": "重命名账号", + "platform.renamePlaceholder": "账号名称", + "platform.newAccountPending": "等待中...", + "platform.setupBusy": "另一个 accshift 操作正在运行。正在等待其完成。", + "platform.renameNewAccount": "为该账号命名", + "platform.copyLabelAccountId": "账号 ID", + "platform.forget": "移除", + "platform.forgetConfirmTitle": "移除「{display}」?", + "platform.forgetConfirmMessage": "这会从 Accshift 中删除该账号及其保存的会话。", + "platform.forgotAccount": "已移除 {display}", + "dialog.newFolderTitle": "新建文件夹", + "dialog.renameFolderTitle": "重命名文件夹", + "dialog.folderNamePlaceholder": "文件夹名称", + "dialog.cardNoteTitle": "卡片备注", + "dialog.cardNotePlaceholder": "写一条简短备注", + "card.noteAttached": "已附备注", + "card.clickAgainToSwitch": "再次点击以切换", + "card.extensionWarnings": "警告", + "card.extensionNote": "备注", + "card.cs2Section": "CS2", + "card.cs2Level": "等级 {level}", + "card.cs2CaseEarned": "本周箱子已获得", + "card.cs2CaseNotEarned": "本周箱子待获得", + "list.selectAccountPreview": "选择一个账号以预览", + "preview.noteLabel": "备注", + "preview.currentlyActive": "当前使用中", + "preview.switchAgain": "重新切换", + "preview.switchAccount": "切换账号", + "ban.community": "社区封禁", + "ban.vac.single": "{count} 次 VAC 封禁", + "ban.vac.multiple": "{count} 次 VAC 封禁", + "ban.game.single": "{count} 次游戏封禁", + "ban.game.multiple": "{count} 次游戏封禁", + "time.unknown": "未知", + "time.justNow": "刚刚", + "time.minuteAgo": "{count} 分钟前", + "time.minutesAgo": "{count} 分钟前", + "time.hourAgo": "{count} 小时前", + "time.hoursAgo": "{count} 小时前", + "time.dayAgo": "{count} 天前", + "time.daysAgo": "{count} 天前", + "time.neverConnected": "从未登录", + "time.compactWrapper": "({value})", + "toast.copied": "已复制{label}", + "toast.legacyConfigMigrated": "设置已迁移到新格式", + "toast.legacyConfigMigrationFailed": "旧设置迁移失败,请查看日志", + "toast.failedLoadSettingsPanel": "设置面板加载失败", + "toast.banChecking": "正在检查封禁...", + "toast.banCheckSummary.single": "封禁检查:{count} 个账号有封禁记录", + "toast.banCheckSummary.multiple": "封禁检查:{count} 个账号有封禁记录", + "toast.banCheckFailed": "封禁检查失败:{error}", + "toast.avatarRefreshComplete": "已完成 {count} 个账号的头像刷新", + "toast.banRefreshComplete": "已完成 {count} 个账号的封禁刷新", + "toast.banRefreshNoApiKey": "已跳过封禁刷新:请在设置中添加 Steam Web API 密钥", + "toast.refreshPartial": "已完成 {ok}/{count} 个账号的刷新", + "toast.noSteamAccountsFound": "未找到 Steam 账号。请先登录一次 Steam,然后刷新。", + "toast.accountsRefreshed.single": "已刷新 {count} 个账号", + "toast.accountsRefreshed.multiple": "已刷新 {count} 个账号", + "toast.steamFolderNotFound": "未找到 Steam 文件夹。请在设置 > Steam 文件夹中手动指定。", + "toast.steamElevated": "Steam 正在以管理员身份运行。请在设置 > Steam 中启用「以管理员身份运行 Steam」。", + "toast.noRiotProfilesFound": "还没有 Riot 档案。点击 + 添加一个。", + "toast.noBattleNetAccountsFound": "未找到 Battle.net 账号。请先登录一次 Battle.net,然后刷新。", + "toast.noUbisoftAccountsFound": "未找到 Ubisoft 账号。请先登录一次 Ubisoft Connect,然后刷新。", + "toast.deepLinkInvalid": "无法识别的 accshift:// 链接", + "toast.deepLinkDisabled": "accshift:// 链接已在设置中禁用", + "toast.deepLinkUnknownPlatform": "链接中的平台未知:{platform}", + "toast.deepLinkPlatformDisabled": "{platform} 已在设置中禁用", + "toast.deepLinkAccountNotFound": "没有与「{account}」匹配的 {platform} 账号", + "toast.deepLinkSwitched": "已切换到 {account}", + "dialog.deepLinkSwitchTitle": "切换账号?", + "dialog.deepLinkSwitchMessage": "有个 accshift:// 链接请求把你的 {platform} 账号切换为「{account}」。要继续吗?", + "dialog.deepLinkSwitchConfirm": "切换", + "steam.launchOnline": "在线切换", + "steam.launchInvisible": "隐身切换", + "steam.switchAndRunGame": "切换并运行 {game}", + "steam.switchAndRunPick": "切换并运行游戏...", + "steam.runGame": "运行 {game}", + "steam.runPick": "运行游戏...", + "steam.switchAndRunNoGames": "未找到游戏", + "steam.clearDefaultGame": "清除默认游戏", + "steam.copySteamId64": "复制 SteamID64", + "steam.copyFriendCode": "复制好友代码", + "steam.copyProfileUrl": "复制个人资料链接", + "steam.copyLabelUsername": "用户名", + "steam.copyLabelSteamId64": "SteamID64", + "steam.copyLabelFriendCode": "好友代码", + "steam.copyLabelProfileUrl": "个人资料链接", + "steam.openUserdataFolder": "打开 userdata 文件夹", + "steam.clearIntegratedBrowserCache": "重置浏览器会话", + "steam.clearIntegratedBrowserCacheConfirmTitle": "重置 Steam 浏览器会话?", + "steam.clearIntegratedBrowserCacheConfirmMessage": "这会关闭 Steam 并清除商店和社区使用的内置浏览器缓存。", + "steam.clearedIntegratedBrowserCache": "Steam 浏览器会话已重置", + "steam.copySettingsFrom": "从以下账号复制设置", + "steam.copiedSettingsToCurrent": "已把 {game} 的设置复制到当前账号", + "steam.forget": "移除", + "steam.forgetConfirmTitle": "移除「{display}」?", + "steam.forgetConfirmMessage": "这会把该账号从本机的 Steam 账号列表中移除。", + "steam.forgotAccount": "已移除 {username}", + "steam.setupPendingLabel": "Steam 账号", + "steam.setupWaitingForClient": "等待 Steam...", + "steam.setupWaitingForLogin": "请登录你要添加的 Steam 账号。", + "steam.setupDetected": "已检测到账号", + "steam.setupConnected": "已连接", + "steam.setupReady": "Steam 账号已添加", + "steam.setupReadyWithProfile": "已添加 {profile}", + "steam.setupFailed": "Steam 设置失败", + "steam.setupFailedMessage": "Accshift 无法完成该 Steam 账号的添加。", + "riot.captureSession": "捕获当前会话", + "riot.recaptureSession": "重新捕获会话", + "riot.capturedSession": "已捕获 {profile} 的会话", + "riot.setupPendingLabel": "Riot 账号", + "riot.setupWaitingForClient": "等待 Riot 客户端...", + "riot.setupWaitingForLogin": "请登录你要添加的 Riot 账号。", + "riot.setupCapturing": "正在保存 Riot 会话...", + "riot.setupReady": "Riot 账号已添加", + "riot.setupReadyWithProfile": "已添加 {profile}", + "riot.setupFailed": "Riot 设置失败", + "riot.setupFailedMessage": "Accshift 无法完成该 Riot 账号的添加。", + "riot.setupStaySignedIn": "请保持「保持登录」为开启状态。", + "riot.setupDetected": "已检测到账号", + "riot.setupConnected": "已连接", + "riot.forget": "移除账号", + "riot.forgetConfirmTitle": "移除「{display}」?", + "riot.forgetConfirmMessage": "这会从 Accshift 中删除本地 Riot 档案及其保存的会话。", + "riot.forgotProfile": "已移除 {profile}", + "battlenet.copyUsername": "复制用户名", + "battlenet.copyLabelUsername": "用户名", + "battlenet.copyEmail": "复制邮箱", + "battlenet.copyLabelEmail": "邮箱", + "battlenet.forget": "移除", + "battlenet.forgetConfirmTitle": "移除「{display}」?", + "battlenet.forgetConfirmMessage": "这会把该账号从本机的 Battle.net 账号列表中移除。", + "battlenet.forgotAccount": "已移除 {email}", + "battlenet.setupPendingLabel": "Battle.net 账号", + "battlenet.setupWaitingForClient": "等待 Battle.net...", + "battlenet.setupWaitingForLogin": "请登录你要添加的 Battle.net 账号。", + "battlenet.setupKeepMeLoggedIn": "尽量保持「保持登录状态」为开启。", + "battlenet.setupDetected": "已检测到账号", + "battlenet.setupConnected": "已连接", + "battlenet.setupReady": "Battle.net 账号已添加", + "battlenet.setupReadyWithProfile": "已添加 {profile}", + "battlenet.setupFailed": "Battle.net 设置失败", + "battlenet.setupFailedMessage": "Accshift 无法完成该 Battle.net 账号的添加。", + "ubisoft.copyLabelUuid": "UUID", + "ubisoft.forget": "移除", + "ubisoft.forgetConfirmTitle": "移除「{display}」?", + "ubisoft.forgetConfirmMessage": "这会从 Accshift 中删除该账号及其保存的会话。", + "ubisoft.forgotAccount": "已移除 {display}", + "ubisoft.setupPendingLabel": "Ubisoft 账号", + "ubisoft.setupWaitingForClient": "等待 Ubisoft Connect...", + "ubisoft.setupWaitingForLogin": "请登录你要添加的 Ubisoft 账号。", + "ubisoft.setupKeepMeLoggedIn": "请保持「记住我」为开启状态。", + "ubisoft.setupDetected": "已检测到账号", + "ubisoft.setupConnected": "已连接", + "ubisoft.setupReady": "Ubisoft 账号已添加", + "ubisoft.setupReadyWithProfile": "已添加 {profile}", + "ubisoft.setupFailed": "Ubisoft 设置失败", + "ubisoft.setupFailedMessage": "Accshift 无法完成该 Ubisoft 账号的添加。", + "settings.roblox": "Roblox", + "toast.noRobloxAccountsFound": "未找到 Roblox 账号。请使用快速登录添加账号,或粘贴一个 cookie。", + "roblox.noAccountsHint": "点击 + 通过快速登录添加 Roblox 账号。", + "roblox.sessionExpired": "会话已过期", + "roblox.sessionExpiredTooltip": "该账号已登出。切换前请重新添加。", + "roblox.sessionExpiredSwitchError": "会话已过期。请重新添加该账号后再切换。备注和设置会保留。", + "roblox.cookiePasteTitle": "通过 cookie 添加账号", + "roblox.cookiePasteHint": "从浏览器粘贴 .ROBLOSECURITY cookie 即可直接添加账号。", + "settings.showRobloxLastLogin": "显示上次登录", + "settings.robloxHealthCheck": "会话检查", + "settings.robloxHealthCheckHint": "标记 Roblox 会话已过期的账号,每次启动发送一次网络请求。", + "roblox.copyLabelUsername": "用户名", + "roblox.copyLabelUserId": "用户 ID", + "roblox.forget": "移除", + "roblox.forgetConfirmTitle": "移除「{display}」?", + "roblox.forgetConfirmMessage": "这会从本机删除该账号及其保存的 cookie。", + "roblox.forgotAccount": "已移除 {display}", + "roblox.setupPendingLabel": "Roblox 账号", + "roblox.setupWaitingForClient": "正在连接 Roblox...", + "roblox.setupWaitingForLogin": "在另一台设备上打开 Roblox 应用并输入下面的代码。", + "roblox.setupQuickLoginCode": "快速登录代码", + "roblox.setupQuickLoginHint": "roblox.com/quick-login", + "roblox.copyCode": "复制代码", + "roblox.setupDetected": "已检测到账号", + "roblox.setupConnected": "已连接", + "roblox.setupReady": "Roblox 账号已添加", + "roblox.setupReadyWithProfile": "已添加 {profile}", + "roblox.setupFailed": "Roblox 设置失败", + "roblox.setupFailedMessage": "Accshift 无法完成该 Roblox 账号的添加。", + "settings.epic": "Epic Games", + "settings.epicPath": "Epic Games Launcher 可执行文件", + "settings.showEpicLastLogin": "显示上次登录", + "toast.noEpicAccountsFound": "未找到 Epic Games 账号。请先登录一次 Epic Games Launcher,然后刷新。", + "epic.noAccountsHint": "点击 + 添加 Epic Games 账号。", + "epic.copyLabelAccountId": "账号 ID", + "epic.forget": "移除", + "epic.forgetConfirmTitle": "移除「{display}」?", + "epic.forgetConfirmMessage": "这会从 Accshift 中删除该账号及其保存的会话。", + "epic.forgotAccount": "已移除 {display}", + "epic.setupPendingLabel": "Epic Games 账号", + "epic.setupWaitingForClient": "等待 Epic Games Launcher...", + "epic.setupWaitingForLogin": "请登录你要添加的 Epic Games 账号。", + "epic.setupKeepMeLoggedIn": "请保持「记住我」为开启状态。", + "epic.setupDetected": "已检测到账号", + "epic.setupConnected": "已连接", + "epic.setupReady": "Epic Games 账号已添加", + "epic.setupReadyWithProfile": "已添加 {profile}", + "epic.setupFailed": "Epic Games 设置失败", + "epic.setupFailedMessage": "Accshift 无法完成该 Epic Games 账号的添加。", + "settings.gog": "GOG Galaxy", + "settings.gogPath": "GOG Galaxy 可执行文件", + "settings.showGogLastLogin": "显示上次登录", + "toast.noGogAccountsFound": "未找到 GOG Galaxy 账号。请先登录一次 GOG Galaxy,然后刷新。", + "gog.noAccountsHint": "点击 + 添加 GOG Galaxy 账号。", + "gog.copyLabelAccountId": "账号 ID", + "gog.forget": "移除", + "gog.forgetConfirmTitle": "移除「{display}」?", + "gog.forgetConfirmMessage": "这会从 Accshift 中删除该账号及其保存的会话。", + "gog.forgotAccount": "已移除 {display}", + "gog.setupPendingLabel": "GOG Galaxy 账号", + "gog.setupWaitingForClient": "等待 GOG Galaxy...", + "gog.setupWaitingForLogin": "请登录你要添加的 GOG Galaxy 账号。", + "gog.setupKeepMeLoggedIn": "请保持「保持登录」为开启状态。", + "gog.setupDetected": "已检测到账号", + "gog.setupConnected": "已连接", + "gog.setupReady": "GOG Galaxy 账号已添加", + "gog.setupReadyWithProfile": "已添加 {profile}", + "gog.setupFailed": "GOG Galaxy 设置失败", + "gog.setupFailedMessage": "Accshift 无法完成该 GOG Galaxy 账号的添加。", + "settings.jagex": "Jagex Launcher", + "settings.jagexPath": "Jagex Launcher 可执行文件", + "settings.showJagexLastLogin": "显示上次登录", + "toast.noJagexAccountsFound": "还没有保存的 Jagex 账号。点击 + 通过 Jagex Launcher 添加一个。", + "jagex.noAccountsHint": "点击 + 添加 Jagex 账号。", + "jagex.copyLabelAccountId": "账号 ID", + "jagex.forget": "移除", + "jagex.forgetConfirmTitle": "移除「{display}」?", + "jagex.forgetConfirmMessage": "这会从 Accshift 中删除该账号及其保存的会话。", + "jagex.forgotAccount": "已移除 {display}", + "jagex.setupPendingLabel": "Jagex 账号", + "jagex.setupWaitingForClient": "等待 Jagex Launcher...", + "jagex.setupWaitingForLogin": "请登录你要添加的 Jagex 账号。", + "jagex.setupKeepMeLoggedIn": "请保持「记住我」为开启状态。", + "jagex.setupDetected": "已检测到账号", + "jagex.setupConnected": "已连接", + "jagex.setupReady": "Jagex 账号已添加", + "jagex.setupReadyWithProfile": "已添加 {profile}", + "jagex.setupFailed": "Jagex 设置失败", + "jagex.setupFailedMessage": "Accshift 无法完成该 Jagex 账号的添加。", + "settings.discord": "Discord", + "settings.discordPath": "Discord 可执行文件", + "settings.showDiscordLastLogin": "显示上次登录", + "toast.noDiscordAccountsFound": "未找到 Discord 账号。请添加一个 Discord 账号,然后刷新。", + "discord.noAccountsHint": "点击 + 添加 Discord 账号。", + "discord.copyLabelAccountId": "账号 ID", + "discord.forget": "移除", + "discord.forgetConfirmTitle": "移除「{display}」?", + "discord.forgetConfirmMessage": "这会从 Accshift 中删除该账号及其保存的会话。", + "discord.forgotAccount": "已移除 {display}", + "discord.setupPendingLabel": "Discord 账号", + "discord.setupWaitingForClient": "等待 Discord...", + "discord.setupWaitingForLogin": "请登录你要添加的 Discord 账号。", + "discord.setupKeepMeLoggedIn": "保持登录状态才能保留该账号。", + "discord.setupDetected": "已检测到账号", + "discord.setupConnected": "已连接", + "discord.setupReady": "Discord 账号已添加", + "discord.setupReadyWithProfile": "已添加 {profile}", + "discord.setupFailed": "Discord 设置失败", + "discord.setupFailedMessage": "Accshift 无法完成该 Discord 账号的添加。", + "color.none": "默认", + "color.blue": "蓝色", + "color.cyan": "青色", + "color.green": "绿色", + "color.lime": "青柠色", + "color.yellow": "黄色", + "color.orange": "橙色", + "color.red": "红色", + "color.pink": "粉色", + "color.violet": "紫色", + "color.gray": "灰色", + "language.english": "English", + "language.french": "Français", + "language.spanish": "Español", + "language.portuguese": "Português (Portugal)", + "language.portugueseBrazil": "Português (Brasil)", + "language.russian": "Русский", + "language.chinese": "简体中文", + "theme.dark": "深色", + "theme.light": "浅色", + "theme.midnight": "午夜", + "theme.glassDark": "深色玻璃", + "theme.glassLight": "浅色玻璃", + "theme.liquidGlass": "液态玻璃", + "theme.custom": "自定义", + "settings.themeImport": "导入", + "settings.themeExport": "导出", + "settings.themeDelete": "删除主题", + "settings.themeImported": "主题已导入", + "settings.themeDeleted": "主题已删除", + "settings.themeExportCopied": "主题 JSON 已复制到剪贴板", + "settings.themeInvalidJson": "主题 JSON 无效", + "settings.themeCustomize": "自定义", + "settings.themeEdit": "编辑", + "settings.themeCopyName": "{name} 副本", + "settings.themeSaved": "主题已保存", + "settings.themeMigrated": "主题已导入并升级到当前格式", + "settings.themeInvalidId": "主题标识符缺失或无效", + "settings.themeInvalidName": "主题没有名称", + "settings.themeUnsupportedVersion": "该主题需要更新版本的 accshift", + "themeGroup.surface": "表面", + "themeGroup.text": "文字", + "themeGroup.semantic": "状态颜色", + "themeGroup.shape": "形状与密度", + "themeGroup.typography": "字体排印", + "themeGroup.motion": "动效", + "themeKind.rgbTriplet": "RGB 三元组", + "themeKind.hexColor": "十六进制颜色", + "themeKind.color": "颜色", + "themeKind.length": "长度", + "themeKind.shadow": "阴影", + "themeKind.choice": "选项", + "themeKind.fontStack": "字体", + "themeKind.signedLength": "字距", + "themeKind.gradient": "渐变", + "themeKind.number": "数值", + "themeDensity.compact": "紧凑", + "themeDensity.cozy": "适中", + "themeDensity.comfortable": "宽松", + "themeAvatarShape.circle": "圆形", + "themeAvatarShape.rounded": "圆角", + "themeAvatarShape.square": "方形", + "themeSmoothing.auto": "平滑", + "themeSmoothing.none": "像素", + "themeToken.bgRgb": "窗口背景", + "themeToken.bgCard": "卡片背景", + "themeToken.bgCardHover": "卡片悬停背景", + "themeToken.bgMuted": "弱化背景", + "themeToken.bgElevated": "浮起背景", + "themeToken.border": "边框", + "themeToken.fg": "主要文字", + "themeToken.fgMuted": "次要文字", + "themeToken.fgSubtle": "浅色文字", + "themeToken.afkText": "离开标记", + "themeToken.accent": "强调色", + "themeToken.accentFg": "强调色上的文字", + "themeToken.success": "成功", + "themeToken.warning": "警告", + "themeToken.danger": "危险", + "themeToken.radiusSm": "小圆角", + "themeToken.radiusMd": "中圆角", + "themeToken.radiusLg": "大圆角", + "themeToken.elevationLow": "浅阴影", + "themeToken.elevationMedium": "中阴影", + "themeToken.elevationHigh": "深阴影", + "themeToken.density": "密度", + "themeToken.fontUi": "界面字体", + "themeToken.bgImage": "窗口渐变", + "themeToken.cardBgImage": "表面渐变", + "themeToken.borderWidth": "边框粗细", + "themeToken.borderStyle": "边框样式", + "themeToken.avatarShape": "头像形状", + "themeToken.focusRing": "焦点边框", + "themeToken.overlayBlur": "背景模糊", + "themeToken.fontDisplay": "标题字体", + "themeToken.lineHeight": "行高", + "themeToken.letterSpacing": "字间距", + "themeToken.labelCase": "标签大小写", + "themeToken.fontSmoothing": "文字平滑", + "themeToken.motionScale": "动画速度", + "themeEditor.title": "主题编辑器", + "themeEditor.name": "名称", + "themeEditor.author": "作者", + "themeEditor.version": "版本", + "themeEditor.base": "继承自", + "themeEditor.colorScheme": "配色模式", + "themeEditor.glass": "半透明表面", + "themeEditor.inherited": "继承", + "themeEditor.builtInBase": "内置主题", + "themeEditor.reset": "恢复为继承的值", + "themeEditor.issues": "检查", + "themeEditor.noIssues": "没有发现问题", + "themeEditor.fixErrorsFirst": "请先修正被拒绝的值再保存", + "themeEditor.save": "保存", + "themeEditor.saveFailed": "未能保存主题。请再试一次。", + "themeEditor.export": "复制", + "themeEditor.issueInvalidValue": "{token}:不是有效的{kind},例如 {example}", + "themeEditor.issueUnknownToken": "{token} 不是主题值,将被忽略", + "themeEditor.issueMissingToken": "{token} 未设置,使用内置值", + "themeEditor.issueUnknownBase": "基础主题 {base} 不存在,使用内置值", + "themeEditor.issueContrast": "{token} 在 {against} 上的对比度为 {ratio}:1,低于 {target}:1 的目标", + "themeEditor.beta": "测试版", + "themeEditor.betaNote": "主题格式仍在变动:今天写的主题在更新后可能需要调整。", + "themeEditor.customCss": "自定义 CSS", + "themeEditor.customCssHelp": "叠加在主题之上,用于上面的取值无法覆盖的部分。不允许 @import 和 url():主题不会向外请求任何内容。", + "themeEditor.issueUnsafeCss": "主题中不允许 {construct},该 CSS 不会生效", + "themeEditor.issueCssTooLong": "CSS 过长:{length} 个字符,上限为 {limit}", + "bulkEdit.title": "批量编辑", + "bulkEdit.selectAccounts": "选择账号", + "bulkEdit.selectAll": "全选", + "bulkEdit.deselectAll": "取消全选", + "bulkEdit.next": "下一步", + "bulkEdit.apply": "应用", + "bulkEdit.settings": "设置", + "bulkEdit.newsPopup": "游戏新闻弹窗", + "bulkEdit.doNotDisturb": "请勿打扰", + "bulkEdit.launchOptions": "各游戏启动选项", + "bulkEdit.unchanged": "保持不变", + "bulkEdit.addLaunchOption": "添加", + "bulkEdit.selectGame": "选择游戏...", + "bulkEdit.launchOptionsPlaceholder": "-fullscreen", + "bulkEdit.activeAccountWarning": "当前使用中的账号也被选中。更改将在下次 Steam 重启后生效。", + "bulkEdit.toastSuccess": "批量编辑:{count} 个成功", + "bulkEdit.toastPartial": "批量编辑:{succeeded} 个成功,{failed} 个失败", + "bulkEdit.toastFailed": "批量编辑失败", + "bulkEdit.noAccountsSelected": "未选择账号", + "bulkEdit.selected": "已选 {count} 个", + "bulkEdit.loadingGames": "正在加载游戏...", + "bulkEdit.copyUrls": "复制链接", + "bulkEdit.urlsCopied": "已复制 {count} 个个人资料链接", + "bulkEdit.cardColor": "卡片颜色", + "bulkEdit.colorApplied": "已为 {count} 张卡片应用颜色", + "bulkEdit.colorCleared": "已清除 {count} 张卡片的颜色", + "common.retry": "重试", + "app.searchNoResults": "没有「{query}」的搜索结果", + "app.folderEmpty": "该文件夹是空的", + "app.loadErrorGeneric": "账号加载失败。详情见控制台。", + "toast.dismiss": "关闭", + "toast.switchFailed": "账号切换失败。详情见控制台。", + "toast.addAccountFailed": "无法添加该账号。详情见应用日志。", + "toast.addAccountClientMissing": "{platform} 似乎没有安装。请先安装,或在设置中指定其位置。", + "toast.banCheckFailedGeneric": "封禁检查失败。详情见控制台。", + "toast.renameAccountFailed": "重命名失败:{error}", + "toast.copyFailed": "无法复制到剪贴板", + "toast.refreshFailed": "刷新失败。详情见控制台。", + "dialog.deleteFolderTitle": "删除「{name}」?", + "dialog.deleteFolderMessage": "子文件夹会一并删除。里面的账号会移回上一级,不会被删除。", + "personas.deleteConfirm": "再点一次即可删除", + "personas.discardConfirm": "再点一次即可放弃", + "personas.switchConfirmTitle": "切换身份", + "personas.switchConfirmMessage": "把 {count} 个平台切换到「{name}」?每个客户端都会关闭并重新启动。", + "personas.switchConfirmAction": "切换", + "personas.deleteTitle": "删除身份", + "personas.deleteMessage": "删除「{name}」?账号本身不会受影响。", + "context.submenuError": "无法加载该菜单", + "settings.apiKeySaveFailed": "无法保存 Steam API 密钥", + "settings.apiKeyInvalidHint": "密钥未保存。请检查后重试。", + "settings.apiKeyCleared": "Steam API 密钥已清除", + "settings.apiKeyClearFailed": "无法清除 Steam API 密钥", + "settings.clearApiKey": "清除密钥", + "settings.cs2Bridge": "CS2 数据桥接", + "settings.cs2BridgeLink": "桥接链接", + "settings.cs2BridgeLinkPlaceholder": "https://your-server/api/bridge/accshift/", + "settings.cs2BridgeTest": "测试", + "settings.cs2BridgeStatusOk": "已连接 · {count} 个账号 · {ms} 毫秒", + "settings.cs2BridgeStatusFail": "连接失败", + "settings.cs2BridgeStatusTesting": "测试中...", + "settings.cs2BridgeStatusUnknown": "未测试", + "settings.cs2BridgeToken": "API 令牌", + "settings.cs2BridgeTokenOptional": "可选,用于自建服务器", + "settings.cs2BridgeTokenPlaceholder": "如果服务器需要,请填写 Bearer 令牌", + "settings.cs2BridgeSaveFailed": "无法保存 CS2 桥接设置", + "settings.cs2BridgeClearToken": "清除令牌", + "settings.pathSaveFailed": "无法保存 {platform} 路径", + "settings.pathInvalidHint": "该路径无法保存。请检查后重试。", + "settings.pathSteamNeverSignedIn": "已找到 Steam,请先登录一次,让它生成登录记录", + "settings.pathNotSteamFolder": "该文件夹看起来不是 Steam 安装目录", + "settings.pathNotADirectory": "该路径必须是已存在的文件夹", + "settings.pinSaved": "PIN 已保存", + "settings.pinTakesEffectNextLaunch": "下次启动应用时生效", + "settings.pinRequiredAfterInactivity": "无操作后以及应用启动时需要输入", + "settings.pinScreenLockOnly": "PIN 会锁定此窗口和账户切换,不会保护磁盘上的任何数据。", + "settings.unitSeconds": "秒", + "settings.unitDays": "天", + "settings.telemetryLoadFailed": "无法加载遥测设置", + "palette.placeholder": "输入命令或账号名称...", + "palette.noResults": "没有匹配的命令", + "palette.sectionAccounts": "账号", + "palette.sectionActions": "操作", + "palette.sectionNavigation": "导航", + "palette.openTab": "前往 {platform}", + "palette.openFolder": "打开文件夹「{name}」", + "palette.backToParent": "返回上级文件夹", + "palette.zoomReset": "重置缩放" +} diff --git a/crates/accshift-app/src/folders.rs b/crates/accshift-app/src/folders.rs new file mode 100644 index 0000000..c030d2b --- /dev/null +++ b/crates/accshift-app/src/folders.rs @@ -0,0 +1,506 @@ +//! The folder store, ported from `src/lib/features/folders/{types,store}.ts`. +//! +//! [`FolderStore`] is the store's JSON as a struct. Its methods are the TS +//! functions; the mutating ones return whether the store has to be written, +//! and [`crate::stores::ClientStores`] writes it. + +use crate::js; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; + +pub const CURRENT_VERSION: u32 = 1; +/// Every `itemOrder` key is this prefix plus a platform id, or a folder id. +pub const ROOT_KEY_PREFIX: &str = "root:"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FolderInfo { + pub id: String, + pub name: String, + pub parent_id: Option, + pub platform: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ItemKind { + Account, + Folder, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ItemRef { + #[serde(rename = "type")] + pub kind: ItemKind, + pub id: String, +} + +impl ItemRef { + pub fn account(id: impl Into) -> Self { + Self { + kind: ItemKind::Account, + id: id.into(), + } + } + pub fn folder(id: impl Into) -> Self { + Self { + kind: ItemKind::Folder, + id: id.into(), + } + } + fn same(&self, other: &ItemRef) -> bool { + self.kind == other.kind && self.id == other.id + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FolderStore { + pub version: u32, + pub folders: Vec, + pub item_order: BTreeMap>, +} + +impl Default for FolderStore { + fn default() -> Self { + Self { + version: CURRENT_VERSION, + folders: Vec::new(), + item_order: BTreeMap::new(), + } + } +} + +/// One folder of [`FolderStore::root_sections`] (or the root, `folder: None`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FolderSection { + pub folder: Option, + pub folder_items: Vec, + pub account_items: Vec, +} + +/// `getRootKey`. +pub fn root_key(platform: &str) -> String { + format!("{ROOT_KEY_PREFIX}{platform}") +} + +/// `folderId || getRootKey(platform)`: an empty id is the root too. +fn bucket_key(folder_id: Option<&str>, platform: &str) -> String { + match folder_id { + Some(id) if !id.is_empty() => id.to_string(), + _ => root_key(platform), + } +} + +fn non_empty(folder_id: Option<&str>) -> Option<&str> { + folder_id.filter(|id| !id.is_empty()) +} + +fn trimmed_string(record: &serde_json::Map, key: &str) -> String { + js::as_str(record.get(key)) + .map(|text| js::trim(text).to_string()) + .unwrap_or_default() +} + +fn sanitize_folder(value: &Value) -> Option { + let empty = serde_json::Map::new(); + let raw = js::as_record(Some(value)).unwrap_or(&empty); + let id = trimmed_string(raw, "id"); + let name = trimmed_string(raw, "name"); + let platform = trimmed_string(raw, "platform"); + let parent_id = match raw.get("parentId") { + Some(Value::String(parent)) if !js::trim(parent).is_empty() => { + Some(js::trim(parent).to_string()) + } + _ => None, + }; + if id.is_empty() || name.is_empty() || platform.is_empty() { + return None; + } + Some(FolderInfo { + id, + name, + parent_id, + platform, + }) +} + +fn sanitize_item_ref(value: &Value) -> Option { + let raw = js::as_record(Some(value))?; + let id = trimmed_string(raw, "id"); + if id.is_empty() { + return None; + } + let kind = match js::as_str(raw.get("type")) { + Some("account") => ItemKind::Account, + Some("folder") => ItemKind::Folder, + _ => return None, + }; + Some(ItemRef { kind, id }) +} + +/// `sanitizeStore` then `migrateStore`. Drops folders missing an id, name or +/// platform, and every `itemOrder` bucket that is neither a platform root nor +/// a live folder (a dead folder's bucket would keep its accounts forever). +pub fn sanitize_folder_store(value: &Value) -> FolderStore { + let raw = js::as_record(Some(value)); + let folders: Vec = match raw.and_then(|map| map.get("folders")) { + Some(Value::Array(items)) => items.iter().filter_map(sanitize_folder).collect(), + _ => Vec::new(), + }; + let valid: BTreeSet<&str> = folders.iter().map(|folder| folder.id.as_str()).collect(); + let mut item_order = BTreeMap::new(); + if let Some(order) = js::as_record(raw.and_then(|map| map.get("itemOrder"))) { + for (key, entry) in order { + if js::trim(key).is_empty() { + continue; + } + let Value::Array(items) = entry else { + continue; + }; + let is_root = key.starts_with(ROOT_KEY_PREFIX) && key.len() > ROOT_KEY_PREFIX.len(); + if !is_root && !valid.contains(key.as_str()) { + continue; + } + let refs: Vec = items + .iter() + .filter_map(sanitize_item_ref) + .filter(|item| item.kind != ItemKind::Folder || valid.contains(item.id.as_str())) + .collect(); + item_order.insert(key.clone(), refs); + } + } + FolderStore { + version: CURRENT_VERSION, + folders, + item_order, + } +} + +/// `getStore` on a raw store value: `None` (missing) is the empty store. +pub fn load_folder_store(value: Option<&Value>) -> FolderStore { + value.map(sanitize_folder_store).unwrap_or_default() +} + +/// `generateId`: base-36 milliseconds plus four random base-36 characters. +pub fn generate_folder_id() -> String { + let millis = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or(0); + let random = js::to_base36(js::random_u64()); + let suffix: String = random.chars().rev().take(4).collect(); + format!("{}{suffix}", js::to_base36(millis)) +} + +impl FolderStore { + pub fn to_value(&self) -> Value { + serde_json::to_value(self).unwrap_or(Value::Null) + } + + /// `getItemsInFolder`. + pub fn items_in_folder(&self, folder_id: Option<&str>, platform: &str) -> &[ItemRef] { + self.item_order + .get(&bucket_key(folder_id, platform)) + .map_or(&[], Vec::as_slice) + } + + /// `getRootSections`: each root folder with its items, then the root's own + /// accounts. + pub fn root_sections(&self, platform: &str) -> Vec { + let root_items = self.items_in_folder(None, platform); + let mut sections = Vec::new(); + for folder_ref in root_items + .iter() + .filter(|item| item.kind == ItemKind::Folder) + { + let Some(folder) = self.folder(&folder_ref.id) else { + continue; + }; + let items = self + .item_order + .get(&folder.id) + .map_or(&[][..], Vec::as_slice); + sections.push(FolderSection { + folder: Some(folder.clone()), + folder_items: items + .iter() + .filter(|item| item.kind == ItemKind::Folder) + .cloned() + .collect(), + account_items: items + .iter() + .filter(|item| item.kind == ItemKind::Account) + .cloned() + .collect(), + }); + } + sections.push(FolderSection { + folder: None, + folder_items: Vec::new(), + account_items: root_items + .iter() + .filter(|item| item.kind == ItemKind::Account) + .cloned() + .collect(), + }); + sections + } + + /// `createFolder` with a given id (see [`generate_folder_id`]). + pub fn create_folder( + &mut self, + id: String, + name: &str, + parent_id: Option<&str>, + platform: &str, + ) -> FolderInfo { + let folder = FolderInfo { + id, + name: name.to_string(), + parent_id: parent_id.map(str::to_string), + platform: platform.to_string(), + }; + self.folders.push(folder.clone()); + self.item_order + .entry(bucket_key(parent_id, platform)) + .or_default() + .push(ItemRef::folder(folder.id.clone())); + self.item_order.insert(folder.id.clone(), Vec::new()); + folder + } + + /// `getFolder`. + pub fn folder(&self, id: &str) -> Option<&FolderInfo> { + self.folders.iter().find(|folder| folder.id == id) + } + + /// `listFolders`: every folder of one platform, in creation order. + pub fn list_folders(&self, platform: &str) -> Vec<&FolderInfo> { + self.folders + .iter() + .filter(|folder| folder.platform == platform) + .collect() + } + + /// `findItemFolderId`: the folder holding this item, `None` at the root. + pub fn find_item_folder_id(&self, item: &ItemRef, platform: &str) -> Option<&str> { + self.folders + .iter() + .filter(|folder| folder.platform == platform) + .find(|folder| { + self.item_order + .get(&folder.id) + .is_some_and(|items| items.iter().any(|entry| entry.same(item))) + }) + .map(|folder| folder.id.as_str()) + } + + /// `getFolderPath`: from the outermost folder down to `folder_id`. + pub fn folder_path(&self, folder_id: Option<&str>) -> Vec<&FolderInfo> { + let mut path = Vec::new(); + let mut seen = BTreeSet::new(); + let mut current = non_empty(folder_id); + while let Some(id) = current { + // TS loops forever on a parent cycle; stop at the first repeat. + if !seen.insert(id) { + break; + } + let Some(folder) = self.folder(id) else { + break; + }; + path.insert(0, folder); + current = non_empty(folder.parent_id.as_deref()); + } + path + } + + /// `isFolderDescendant`: `folder_id` is `ancestor_id` or sits under it. + pub fn is_folder_descendant(&self, folder_id: Option<&str>, ancestor_id: &str) -> bool { + let mut seen = BTreeSet::new(); + let mut current = non_empty(folder_id); + while let Some(id) = current { + if id == ancestor_id { + return true; + } + if !seen.insert(id) { + break; + } + current = self + .folder(id) + .and_then(|folder| non_empty(folder.parent_id.as_deref())); + } + false + } + + /// `syncAccounts`: new accounts join the root, gone ones leave every + /// bucket of the platform. Returns whether anything changed. + pub fn sync_accounts(&mut self, account_ids: &[String], platform: &str) -> bool { + let root = root_key(platform); + let mut changed = false; + if !self.item_order.contains_key(&root) { + self.item_order.insert(root.clone(), Vec::new()); + changed = true; + } + let mut keys = vec![root.clone()]; + keys.extend( + self.folders + .iter() + .filter(|folder| folder.platform == platform) + .map(|folder| folder.id.clone()), + ); + let placed: BTreeSet = keys + .iter() + .filter_map(|key| self.item_order.get(key)) + .flatten() + .filter(|item| item.kind == ItemKind::Account) + .map(|item| item.id.clone()) + .collect(); + for id in account_ids { + if !placed.contains(id) { + if let Some(bucket) = self.item_order.get_mut(&root) { + bucket.push(ItemRef::account(id.clone())); + } + changed = true; + } + } + let valid: BTreeSet<&str> = account_ids.iter().map(String::as_str).collect(); + for key in &keys { + if let Some(items) = self.item_order.get_mut(key) { + let before = items.len(); + items.retain(|item| { + item.kind == ItemKind::Folder || valid.contains(item.id.as_str()) + }); + if items.len() != before { + changed = true; + } + } + } + changed + } + + /// `moveItem`. A folder never moves into its own subtree. `insert_index` + /// past the end appends, like `splice`. Returns whether the store changed + /// (always, once the move is allowed: TS saves even a no-op move). + pub fn move_item( + &mut self, + item: &ItemRef, + from_folder_id: Option<&str>, + to_folder_id: Option<&str>, + platform: &str, + insert_index: Option, + ) -> bool { + if item.kind == ItemKind::Folder && self.is_folder_descendant(to_folder_id, &item.id) { + return false; + } + let from_key = bucket_key(from_folder_id, platform); + let to_key = bucket_key(to_folder_id, platform); + if let Some(items) = self.item_order.get_mut(&from_key) { + items.retain(|entry| !entry.same(item)); + } + let target = self.item_order.entry(to_key).or_default(); + match insert_index { + Some(index) => target.insert(index.min(target.len()), item.clone()), + None => target.push(item.clone()), + } + if item.kind == ItemKind::Folder { + if let Some(folder) = self.folders.iter_mut().find(|folder| folder.id == item.id) { + folder.parent_id = to_folder_id.map(str::to_string); + } + } + true + } + + /// `reorderItems`: replaces one bucket. + pub fn reorder_items(&mut self, folder_id: Option<&str>, platform: &str, items: Vec) { + self.item_order + .insert(bucket_key(folder_id, platform), items); + } + + fn folder_tree_ids(&self, root_folder_id: &str) -> BTreeSet { + let mut ids = BTreeSet::new(); + let mut stack = vec![root_folder_id.to_string()]; + while let Some(current) = stack.pop() { + if current.is_empty() || ids.contains(¤t) { + continue; + } + for folder in &self.folders { + if folder.parent_id.as_deref() == Some(current.as_str()) { + stack.push(folder.id.clone()); + } + } + ids.insert(current); + } + ids + } + + fn nested_account_items( + &self, + folder_id: &str, + to_delete: &BTreeSet, + visited: &mut BTreeSet, + out: &mut Vec, + ) { + if !visited.insert(folder_id.to_string()) { + return; + } + for item in self.item_order.get(folder_id).into_iter().flatten() { + match item.kind { + ItemKind::Account => out.push(item.clone()), + ItemKind::Folder if to_delete.contains(&item.id) => { + self.nested_account_items(&item.id, to_delete, visited, out) + } + ItemKind::Folder => {} + } + } + } + + /// `deleteFolder`: the folder and its subtree go; their accounts take the + /// folder's place in its parent. Returns whether the folder existed. + pub fn delete_folder(&mut self, folder_id: &str) -> bool { + let Some(folder) = self.folder(folder_id).cloned() else { + return false; + }; + let parent_key = bucket_key(folder.parent_id.as_deref(), &folder.platform); + self.item_order.entry(parent_key.clone()).or_default(); + let to_delete = self.folder_tree_ids(folder_id); + let mut moved = Vec::new(); + self.nested_account_items(folder_id, &to_delete, &mut BTreeSet::new(), &mut moved); + if let Some(parent_items) = self.item_order.get_mut(&parent_key) { + match parent_items + .iter() + .position(|item| item.kind == ItemKind::Folder && item.id == folder_id) + { + Some(index) => { + parent_items.splice(index..=index, moved); + } + None => parent_items.extend(moved), + } + } + for items in self.item_order.values_mut() { + items.retain(|item| item.kind != ItemKind::Folder || !to_delete.contains(&item.id)); + } + for id in &to_delete { + self.item_order.remove(id); + } + self.folders + .retain(|folder| !to_delete.contains(&folder.id)); + true + } + + /// `renameFolder`. Returns whether the folder existed. + pub fn rename_folder(&mut self, folder_id: &str, new_name: &str) -> bool { + match self + .folders + .iter_mut() + .find(|folder| folder.id == folder_id) + { + Some(folder) => { + folder.name = new_name.to_string(); + true + } + None => false, + } + } +} diff --git a/crates/accshift-app/src/i18n.rs b/crates/accshift-app/src/i18n.rs new file mode 100644 index 0000000..7194132 --- /dev/null +++ b/crates/accshift-app/src/i18n.rs @@ -0,0 +1,339 @@ +//! The i18n runtime, ported from `src/lib/i18n/{index,dictionaries.svelte}.ts` +//! and the boot locale choice of `src/main.ts` / `src/lib/app/bootLocale.ts`. +//! +//! The dictionaries are `i18n/.json`, written from +//! `src/lib/i18n/messages*.ts` by `tools/i18n.dump.ts` and embedded here. A +//! key missing from a locale falls back to English, then to the key itself, +//! like `translate`. + +use crate::js; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::OnceLock; + +/// A shipped locale. The code is a lowercase BCP 47 tag. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)] +pub enum Locale { + #[default] + En, + Es, + Fr, + Pt, + PtBr, + Ru, + Zh, +} + +pub const DEFAULT_LOCALE: Locale = Locale::En; + +/// Every locale, in picker order. +pub const LOCALES: [Locale; 7] = [ + Locale::En, + Locale::Es, + Locale::Fr, + Locale::Pt, + Locale::PtBr, + Locale::Ru, + Locale::Zh, +]; + +/// One entry of the language picker. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LanguageOption { + pub code: Locale, + pub label_key: &'static str, +} + +/// Default first, then alphabetical by code: the settings picker order. +pub const LANGUAGE_OPTIONS: [LanguageOption; 7] = [ + LanguageOption { + code: Locale::En, + label_key: "language.english", + }, + LanguageOption { + code: Locale::Es, + label_key: "language.spanish", + }, + LanguageOption { + code: Locale::Fr, + label_key: "language.french", + }, + LanguageOption { + code: Locale::Pt, + label_key: "language.portuguese", + }, + LanguageOption { + code: Locale::PtBr, + label_key: "language.portugueseBrazil", + }, + LanguageOption { + code: Locale::Ru, + label_key: "language.russian", + }, + LanguageOption { + code: Locale::Zh, + label_key: "language.chinese", + }, +]; + +impl Locale { + pub fn code(self) -> &'static str { + match self { + Locale::En => "en", + Locale::Es => "es", + Locale::Fr => "fr", + Locale::Pt => "pt", + Locale::PtBr => "pt-br", + Locale::Ru => "ru", + Locale::Zh => "zh", + } + } + + /// The locale spelled exactly `code`. + pub fn from_code(code: &str) -> Option { + LOCALES.into_iter().find(|locale| locale.code() == code) + } + + /// The embedded dictionary JSON. + pub fn dictionary_json(self) -> &'static str { + match self { + Locale::En => include_str!("../i18n/en.json"), + Locale::Es => include_str!("../i18n/es.json"), + Locale::Fr => include_str!("../i18n/fr.json"), + Locale::Pt => include_str!("../i18n/pt.json"), + Locale::PtBr => include_str!("../i18n/pt-br.json"), + Locale::Ru => include_str!("../i18n/ru.json"), + Locale::Zh => include_str!("../i18n/zh.json"), + } + } +} + +impl std::fmt::Display for Locale { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.code()) + } +} + +impl Serialize for Locale { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.code()) + } +} + +impl<'de> Deserialize<'de> for Locale { + /// Lenient like `normalizeLocale`: anything unknown reads as English. + fn deserialize>(deserializer: D) -> Result { + let value = Value::deserialize(deserializer)?; + Ok(normalize_locale(Some(&value))) + } +} + +/// `isLocale`. +pub fn is_locale(value: Option<&Value>) -> bool { + js::as_str(value).and_then(Locale::from_code).is_some() +} + +/// `normalizeLocale`: the locale, or English for anything else. +pub fn normalize_locale(value: Option<&Value>) -> Locale { + js::as_str(value) + .and_then(Locale::from_code) + .unwrap_or(DEFAULT_LOCALE) +} + +/// `detectPreferredLocale` over the OS's preferred languages, most preferred +/// first (what `navigator.languages` then `navigator.language` list): the +/// exact tag first, then its base subtag, so "pt-BR" is `pt-br` while +/// "pt-PT", "es-419" and "zh-CN" land on `pt`, `es` and `zh`. +pub fn detect_preferred_locale>(candidates: &[S]) -> Locale { + for raw in candidates { + let normalized = js::trim(raw.as_ref()).to_lowercase(); + if normalized.is_empty() { + continue; + } + if let Some(locale) = Locale::from_code(&normalized) { + return locale; + } + let base = normalized.split('-').next().unwrap_or_default(); + if let Some(locale) = Locale::from_code(base) { + return locale; + } + } + DEFAULT_LOCALE +} + +/// The locale the first frame renders in: the stored settings' language, or +/// the OS's preferred one when no settings exist yet (`getSettings().language` +/// at boot). The TS app also remembers it in `localStorage` so the next start +/// prefetches that dictionary; every dictionary is embedded here, so that +/// part has nothing to do. +pub fn boot_locale>(stored_settings: Option<&Value>, preferred: &[S]) -> Locale { + let raw = js::as_record(stored_settings); + match raw.and_then(|record| record.get("language").map(Some)) { + Some(language) => normalize_locale(language), + None => detect_preferred_locale(preferred), + } +} + +/// A dictionary: message key to template. +pub type Dictionary = HashMap; + +/// The parsed dictionary of `locale`, built once. +pub fn dictionary(locale: Locale) -> &'static Dictionary { + static CACHE: [OnceLock; 7] = [ + OnceLock::new(), + OnceLock::new(), + OnceLock::new(), + OnceLock::new(), + OnceLock::new(), + OnceLock::new(), + OnceLock::new(), + ]; + let index = LOCALES + .iter() + .position(|candidate| *candidate == locale) + .unwrap_or(0); + CACHE[index].get_or_init(|| { + // A dictionary that fails to parse is an empty one: every key then + // falls back to English. The tests parse each file, so this only + // guards a broken export. + serde_json::from_str(locale.dictionary_json()).unwrap_or_default() + }) +} + +/// A value for a `{param}` placeholder. +#[derive(Debug, Clone, PartialEq)] +pub enum TranslationValue { + Str(String), + Num(f64), + Bool(bool), + /// `null` or `undefined`: prints nothing. + Null, +} + +impl TranslationValue { + /// `String(value)`, empty for `Null`. + pub fn render(&self) -> String { + match self { + TranslationValue::Str(text) => text.clone(), + TranslationValue::Num(number) => js::number_to_string(*number), + TranslationValue::Bool(flag) => flag.to_string(), + TranslationValue::Null => String::new(), + } + } +} + +impl From<&str> for TranslationValue { + fn from(value: &str) -> Self { + TranslationValue::Str(value.to_string()) + } +} +impl From for TranslationValue { + fn from(value: String) -> Self { + TranslationValue::Str(value) + } +} +impl From<&String> for TranslationValue { + fn from(value: &String) -> Self { + TranslationValue::Str(value.clone()) + } +} +impl From for TranslationValue { + fn from(value: bool) -> Self { + TranslationValue::Bool(value) + } +} +impl From for TranslationValue { + fn from(value: f64) -> Self { + TranslationValue::Num(value) + } +} +macro_rules! from_int { + ($($ty:ty),*) => {$( + impl From<$ty> for TranslationValue { + fn from(value: $ty) -> Self { + TranslationValue::Num(value as f64) + } + } + )*}; +} +from_int!(i32, i64, u32, u64, usize); +impl> From> for TranslationValue { + fn from(value: Option) -> Self { + value.map_or(TranslationValue::Null, Into::into) + } +} + +/// Named parameters for [`translate_with`]. +pub type TranslationParams<'a> = [(&'a str, TranslationValue)]; + +/// `formatTemplate`: every `{word}` (ASCII word characters) becomes its +/// parameter, or nothing when the parameter is absent or null. Without +/// parameters the template is returned as is. +pub fn format_template(template: &str, params: Option<&TranslationParams<'_>>) -> String { + let Some(params) = params else { + return template.to_string(); + }; + let mut out = String::with_capacity(template.len()); + let mut rest = template; + while let Some(open) = rest.find('{') { + out.push_str(&rest[..open]); + let after = &rest[open + 1..]; + let word_len = after + .bytes() + .take_while(|byte| byte.is_ascii_alphanumeric() || *byte == b'_') + .count(); + if word_len > 0 && after.as_bytes().get(word_len) == Some(&b'}') { + let key = &after[..word_len]; + if let Some((_, value)) = params.iter().find(|(name, _)| *name == key) { + out.push_str(&value.render()); + } + rest = &after[word_len + 1..]; + } else { + out.push('{'); + rest = after; + } + } + out.push_str(rest); + out +} + +/// The template of `key`: the locale's, else English, else the key. +pub fn template(locale: Locale, key: &str) -> &str { + lookup(dictionary(locale), key) +} + +fn lookup<'a>(dict: &'a Dictionary, key: &'a str) -> &'a str { + dict.get(key) + .or_else(|| dictionary(DEFAULT_LOCALE).get(key)) + .map_or(key, String::as_str) +} + +/// `translate(locale, key)`. +pub fn translate(locale: Locale, key: &str) -> String { + template(locale, key).to_string() +} + +/// `translate(locale, key, params)`. +pub fn translate_with(locale: Locale, key: &str, params: &TranslationParams<'_>) -> String { + format_template(template(locale, key), Some(params)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// TS mocks a French dictionary without "app.loading"; the lookup must + /// serve the real English string for it, not the key. + #[test] + fn falls_back_to_en_for_a_key_missing_from_a_locale() { + let mut fr = dictionary(Locale::Fr).clone(); + fr.remove("app.loading"); + assert_eq!(lookup(&fr, "app.loading"), "Loading..."); + assert_eq!( + lookup(&fr, "app.loading"), + translate(Locale::En, "app.loading") + ); + assert_eq!(lookup(&fr, "common.close"), "Fermer"); + } +} diff --git a/crates/accshift-app/src/js.rs b/crates/accshift-app/src/js.rs new file mode 100644 index 0000000..fd0e114 --- /dev/null +++ b/crates/accshift-app/src/js.rs @@ -0,0 +1,307 @@ +//! The few JavaScript semantics the ported stores lean on, so a value the TS +//! code normalized one way is normalized the same way here: `String.trim`, +//! `String.slice` on UTF-16 units, `Number(value)`, `Math.round` and +//! `Boolean(value)`. + +use serde_json::Value; + +/// The characters `String.prototype.trim` strips: ECMAScript WhiteSpace plus +/// LineTerminator. Not Rust's `char::is_whitespace`, which keeps U+FEFF and +/// strips U+0085. +pub fn is_js_whitespace(c: char) -> bool { + matches!( + c, + '\u{9}' | '\u{a}' | '\u{b}' | '\u{c}' | '\u{d}' | ' ' | '\u{a0}' | '\u{1680}' | '\u{2000}' + ..='\u{200a}' + | '\u{2028}' + | '\u{2029}' + | '\u{202f}' + | '\u{205f}' + | '\u{3000}' + | '\u{feff}' + ) +} + +/// `value.trim()`. +pub fn trim(value: &str) -> &str { + value.trim_matches(is_js_whitespace) +} + +/// `value.length`: UTF-16 code units. +pub fn utf16_len(value: &str) -> usize { + value.encode_utf16().count() +} + +/// `value.slice(0, max)`: the first `max` UTF-16 code units. A surrogate pair +/// cut in half becomes U+FFFD, where JS would keep the lone surrogate. +pub fn utf16_prefix(value: &str, max: usize) -> String { + if utf16_len(value) <= max { + return value.to_string(); + } + let units: Vec = value.encode_utf16().take(max).collect(); + String::from_utf16_lossy(&units) +} + +/// `Boolean(value)`, `None` standing for `undefined`. +pub fn truthy(value: Option<&Value>) -> bool { + match value { + None | Some(Value::Null) => false, + Some(Value::Bool(flag)) => *flag, + Some(Value::Number(number)) => number.as_f64().is_some_and(|n| n != 0.0 && !n.is_nan()), + Some(Value::String(text)) => !text.is_empty(), + Some(Value::Array(_)) | Some(Value::Object(_)) => true, + } +} + +/// `value !== false`, `None` standing for `undefined`. +pub fn not_false(value: Option<&Value>) -> bool { + !matches!(value, Some(Value::Bool(false))) +} + +/// `a ?? b`. +pub fn nullish_or<'a>(a: Option<&'a Value>, b: Option<&'a Value>) -> Option<&'a Value> { + match a { + None | Some(Value::Null) => b, + some => some, + } +} + +/// `value` as a record, the way the stores' `asRecord` reads it: anything but +/// a plain object is empty. +pub fn as_record(value: Option<&Value>) -> Option<&serde_json::Map> { + match value { + Some(Value::Object(map)) => Some(map), + _ => None, + } +} + +/// `typeof value === "string" ? value : None`. +pub fn as_str(value: Option<&Value>) -> Option<&str> { + match value { + Some(Value::String(text)) => Some(text), + _ => None, + } +} + +/// `Number(value)`, `None` standing for `undefined`. +pub fn to_number(value: Option<&Value>) -> f64 { + match value { + None => f64::NAN, + Some(value) => value_to_number(value), + } +} + +fn value_to_number(value: &Value) -> f64 { + match value { + Value::Null => 0.0, + Value::Bool(flag) => f64::from(u8::from(*flag)), + Value::Number(number) => number.as_f64().unwrap_or(f64::NAN), + Value::String(text) => string_to_number(text), + // Number([]) is Number("") and Number([x]) is Number(String(x)); two + // or more entries join with a comma, which never parses. + Value::Array(items) => match items.as_slice() { + [] => 0.0, + [only] => match only { + Value::Null => 0.0, + Value::Bool(_) | Value::Object(_) => f64::NAN, + other => value_to_number(other), + }, + _ => f64::NAN, + }, + Value::Object(_) => f64::NAN, + } +} + +/// ECMAScript `StringToNumber`. +pub fn string_to_number(text: &str) -> f64 { + let text = trim(text); + if text.is_empty() { + return 0.0; + } + match text { + "Infinity" | "+Infinity" => return f64::INFINITY, + "-Infinity" => return f64::NEG_INFINITY, + _ => {} + } + for (prefix, radix) in [ + ("0x", 16), + ("0X", 16), + ("0o", 8), + ("0O", 8), + ("0b", 2), + ("0B", 2), + ] { + if let Some(digits) = text.strip_prefix(prefix) { + if digits.is_empty() || !digits.chars().all(|c| c.is_digit(radix)) { + return f64::NAN; + } + return digits.chars().fold(0.0, |acc, c| { + acc * f64::from(radix) + f64::from(c.to_digit(radix).unwrap_or(0)) + }); + } + } + if is_decimal_literal(text) { + text.parse::().unwrap_or(f64::NAN) + } else { + f64::NAN + } +} + +/// `[+-] (digits [. digits?] | . digits) [(e|E) [+-] digits]`. +fn is_decimal_literal(text: &str) -> bool { + let bytes = text.as_bytes(); + let mut index = 0; + if matches!(bytes.first(), Some(b'+' | b'-')) { + index += 1; + } + let int_start = index; + while index < bytes.len() && bytes[index].is_ascii_digit() { + index += 1; + } + let int_digits = index - int_start; + let mut frac_digits = 0; + if index < bytes.len() && bytes[index] == b'.' { + index += 1; + let frac_start = index; + while index < bytes.len() && bytes[index].is_ascii_digit() { + index += 1; + } + frac_digits = index - frac_start; + } + if int_digits == 0 && frac_digits == 0 { + return false; + } + if index < bytes.len() && matches!(bytes[index], b'e' | b'E') { + index += 1; + if matches!(bytes.get(index), Some(b'+' | b'-')) { + index += 1; + } + let exp_start = index; + while index < bytes.len() && bytes[index].is_ascii_digit() { + index += 1; + } + if index == exp_start { + return false; + } + } + index == bytes.len() +} + +/// `Math.round`: nearest integer, halves toward positive infinity. +pub fn round(value: f64) -> f64 { + let floor = value.floor(); + if value - floor >= 0.5 { + floor + 1.0 + } else { + floor + } +} + +/// The stores' `clampInt`: `Number(value)`, rounded and clamped, or +/// `fallback` when it is not finite. +pub fn clamp_int(value: Option<&Value>, min: i64, max: i64, fallback: i64) -> i64 { + let numeric = to_number(value); + if !numeric.is_finite() { + return fallback; + } + let rounded = round(numeric).clamp(min as f64, max as f64); + rounded as i64 +} + +/// `String(value)` for a number, as the i18n templates print one. +pub fn number_to_string(value: f64) -> String { + if value.is_nan() { + return "NaN".to_string(); + } + if value.is_infinite() { + return if value > 0.0 { "Infinity" } else { "-Infinity" }.to_string(); + } + if value == value.trunc() && value.abs() < 1e21 { + return format!("{}", value as i128); + } + format!("{value}") +} + +/// `/^\d+$/.test(value)`: ASCII digits only, at least one. +pub fn is_ascii_digits(value: &str) -> bool { + !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) +} + +/// `/^[0-9a-fA-F]{n}$/` for one of `lengths`, after a leading `#`. +pub fn is_hex_color(value: &str, lengths: &[usize]) -> bool { + value.strip_prefix('#').is_some_and(|hex| { + lengths.contains(&hex.len()) && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) +} + +/// A random 64-bit value from the standard library's per-process hasher keys +/// and the clock, for ids that only need to be unique, as `Math.random` and +/// `crypto.randomUUID` are used in the stores. +pub fn random_u64() -> u64 { + use std::collections::hash_map::RandomState; + use std::hash::{BuildHasher, Hasher}; + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let mut hasher = RandomState::new().build_hasher(); + hasher.write_u64(COUNTER.fetch_add(1, Ordering::Relaxed)); + if let Ok(elapsed) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { + hasher.write_u128(elapsed.as_nanos()); + } + hasher.finish() +} + +/// `n.toString(36)`. +pub fn to_base36(mut value: u64) -> String { + const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + if value == 0 { + return "0".to_string(); + } + let mut out = Vec::new(); + while value > 0 { + out.push(DIGITS[(value % 36) as usize]); + value /= 36; + } + out.reverse(); + String::from_utf8(out).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn number_follows_js() { + assert!(to_number(None).is_nan()); + assert_eq!(to_number(Some(&json!(null))), 0.0); + assert_eq!(to_number(Some(&json!(true))), 1.0); + assert_eq!(to_number(Some(&json!(" 42 "))), 42.0); + assert_eq!(to_number(Some(&json!(""))), 0.0); + assert_eq!(to_number(Some(&json!("0x10"))), 16.0); + assert!(to_number(Some(&json!("inf"))).is_nan()); + assert!(to_number(Some(&json!("1e"))).is_nan()); + assert_eq!(to_number(Some(&json!(".5"))), 0.5); + assert_eq!(to_number(Some(&json!([]))), 0.0); + assert_eq!(to_number(Some(&json!(["7"]))), 7.0); + assert!(to_number(Some(&json!([1, 2]))).is_nan()); + assert!(to_number(Some(&json!({}))).is_nan()); + } + + #[test] + fn round_and_clamp_follow_js() { + assert_eq!(round(2.5), 3.0); + assert_eq!(round(-2.5), -2.0); + assert_eq!(round(0.499_999_999_999_999_94), 0.0); + assert_eq!(clamp_int(Some(&json!(140.6)), 75, 150, 100), 141); + assert_eq!(clamp_int(Some(&json!("abc")), 75, 150, 100), 100); + assert_eq!(clamp_int(Some(&json!(null)), 0, 90, 7), 0); + } + + #[test] + fn trim_and_slice_follow_js() { + assert_eq!(trim("\u{feff} a \u{3000}"), "a"); + assert_eq!(trim("\u{85}a"), "\u{85}a"); + assert_eq!(utf16_prefix("abc", 2), "ab"); + assert_eq!(utf16_len("\u{1f600}"), 2); + } +} diff --git a/crates/accshift-app/src/lib.rs b/crates/accshift-app/src/lib.rs index e037316..ce34b8d 100644 --- a/crates/accshift-app/src/lib.rs +++ b/crates/accshift-app/src/lib.rs @@ -6,13 +6,24 @@ //! - [`real::RealBackend`]: the real backend, a skeleton until it is ported. //! - [`jobs`]: runs calls off the UI thread and wakes the UI on each result. //! - [`dispatch`]: the same commands by name, with JSON arguments. +//! - [`storage`]: the raw client stores, their saves and refreshes. +//! - [`stores::ClientStores`]: every client store, typed ([`settings`], +//! [`folders`], [`personas`] and the small ones), over the storage. +//! - [`i18n`]: the seven dictionaries and `translate`. pub mod backend; pub mod dispatch; pub mod error; +pub mod folders; +pub mod i18n; pub mod jobs; +pub mod js; pub mod mock; +pub mod personas; pub mod real; +pub mod settings; +pub mod storage; +pub mod stores; pub mod types; pub use backend::{Backend, COMMANDS}; diff --git a/crates/accshift-app/src/personas.rs b/crates/accshift-app/src/personas.rs new file mode 100644 index 0000000..11e4f79 --- /dev/null +++ b/crates/accshift-app/src/personas.rs @@ -0,0 +1,263 @@ +//! The persona store, ported from `src/lib/features/personas/{types,store, +//! assignments}.ts`. +//! +//! A persona is a named identity bundle: activating it switches every +//! assigned platform to the persona's account in one action. + +use crate::js; +use crate::stores::StoreContext; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; + +pub const NAME_MAX: usize = 40; +/// Covers are downscaled before storage; anything longer is not a cover. +pub const IMAGE_MAX_CHARS: usize = 400_000; +const DEFAULT_NAME: &str = "Persona"; + +/// One platform slot of a persona: which account that platform switches to. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PersonaAssignment { + pub platform_id: String, + pub account_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Persona { + pub id: String, + pub name: String, + /// A card tint, `""` for none. + pub color: String, + /// A small `data:image/...` cover, or none (the card shows a mosaic). + pub image: Option, + pub assignments: Vec, +} + +/// A persona without its id, for [`crate::stores::ClientStores::create_persona`]. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PersonaInput { + pub name: String, + pub color: String, + pub image: Option, + pub assignments: Vec, +} + +/// The fields `updatePersona` replaces; `None` keeps the current value. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PersonaPatch { + pub name: Option, + pub color: Option, + pub image: Option>, + pub assignments: Option>, +} + +impl Persona { + pub fn apply(&mut self, patch: &PersonaPatch) { + if let Some(name) = &patch.name { + self.name = name.clone(); + } + if let Some(color) = &patch.color { + self.color = color.clone(); + } + if let Some(image) = &patch.image { + self.image = image.clone(); + } + if let Some(assignments) = &patch.assignments { + self.assignments = assignments.clone(); + } + } +} + +fn sanitize_assignments(value: Option<&Value>, ctx: &StoreContext) -> Vec { + let Some(Value::Array(items)) = value else { + return Vec::new(); + }; + let mut seen = BTreeSet::new(); + let mut result = Vec::new(); + for raw in items { + // `typeof raw === "object"` lets arrays through; they hold no fields. + let Some(record) = js::as_record(Some(raw)) else { + continue; + }; + let (Some(platform_id), Some(account_id)) = ( + js::as_str(record.get("platformId")), + js::as_str(record.get("accountId")), + ) else { + continue; + }; + if !ctx.knows_platform(platform_id) || seen.contains(platform_id) { + continue; + } + if account_id.is_empty() { + continue; + } + seen.insert(platform_id.to_string()); + result.push(PersonaAssignment { + platform_id: platform_id.to_string(), + account_id: account_id.to_string(), + }); + } + result +} + +fn is_persona_color(value: &str) -> bool { + js::is_hex_color(value, &[6]) +} + +fn sanitize_persona(value: &Value, ctx: &StoreContext) -> Option { + let raw = js::as_record(Some(value))?; + let id = js::as_str(raw.get("id")).filter(|id| !id.is_empty())?; + let name = js::as_str(raw.get("name")) + .map(js::trim) + .filter(|name| !name.is_empty()) + .map_or_else( + || DEFAULT_NAME.to_string(), + |name| js::utf16_prefix(name, NAME_MAX), + ); + let color = js::as_str(raw.get("color")) + .filter(|color| is_persona_color(color)) + .unwrap_or_default() + .to_string(); + let image = js::as_str(raw.get("image")) + .filter(|image| image.starts_with("data:image/") && js::utf16_len(image) <= IMAGE_MAX_CHARS) + .map(str::to_string); + Some(Persona { + id: id.to_string(), + name, + color, + image, + assignments: sanitize_assignments(raw.get("assignments"), ctx), + }) +} + +/// `sanitizeList`: valid personas, first of each id. +pub fn sanitize_personas(value: &Value, ctx: &StoreContext) -> Vec { + let Value::Array(items) = value else { + return Vec::new(); + }; + let mut seen = BTreeSet::new(); + let mut result = Vec::new(); + for raw in items { + let Some(persona) = sanitize_persona(raw, ctx) else { + continue; + }; + if !seen.insert(persona.id.clone()) { + continue; + } + result.push(persona); + } + result +} + +/// The list as the JSON the store holds. +pub fn personas_to_value(personas: &[Persona]) -> Value { + serde_json::to_value(personas).unwrap_or(Value::Null) +} + +/// `newId`: a random UUID v4, as `crypto.randomUUID` gives. +pub fn generate_persona_id() -> String { + let high = js::random_u64(); + let low = js::random_u64(); + let mut bytes = [0u8; 16]; + bytes[..8].copy_from_slice(&high.to_be_bytes()); + bytes[8..].copy_from_slice(&low.to_be_bytes()); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + let hex: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + format!( + "{}-{}-{}-{}-{}", + &hex[0..8], + &hex[8..12], + &hex[12..16], + &hex[16..20], + &hex[20..32] + ) +} + +/// The wizard state [`persona_assignments_for_save`] reads. +#[derive(Debug, Clone, Copy)] +pub struct AssignmentsForSave<'a> { + pub original: &'a [PersonaAssignment], + pub offered_platform_ids: &'a [String], + /// Platform id to picked account id; `""` clears that platform. + pub selection: &'a BTreeMap, + /// The account ids each platform listed. + pub accounts_by_platform: &'a BTreeMap>, + pub loading: bool, + pub failed_platform_ids: &'a [String], +} + +/// `personaAssignmentsForSave`: the assignments to write when a persona is +/// saved. A platform the wizard is not offering, or whose account read +/// failed, keeps its stored assignment; one whose accounts loaded drops an id +/// no longer listed. Stored order first, new picks after in wizard order. +pub fn persona_assignments_for_save(input: &AssignmentsForSave<'_>) -> Vec { + let offered: BTreeSet<&str> = input + .offered_platform_ids + .iter() + .map(String::as_str) + .collect(); + let failed: BTreeSet<&str> = input + .failed_platform_ids + .iter() + .map(String::as_str) + .collect(); + let listed = |platform_id: &str, id: &str| { + input + .accounts_by_platform + .get(platform_id) + .is_some_and(|accounts| accounts.iter().any(|account| account == id)) + }; + let mut result = Vec::new(); + let mut seen = BTreeSet::new(); + + for assignment in input.original { + seen.insert(assignment.platform_id.as_str()); + let edited = input.selection.get(&assignment.platform_id); + if edited.is_some_and(String::is_empty) { + continue; + } + if !offered.contains(assignment.platform_id.as_str()) { + result.push(assignment.clone()); + continue; + } + let selected = edited.map_or("", String::as_str); + let id = if selected.is_empty() { + assignment.account_id.as_str() + } else { + selected + }; + if id.is_empty() { + continue; + } + if !input.loading + && !failed.contains(assignment.platform_id.as_str()) + && !listed(&assignment.platform_id, id) + { + continue; + } + result.push(PersonaAssignment { + platform_id: assignment.platform_id.clone(), + account_id: id.to_string(), + }); + } + + for platform_id in input.offered_platform_ids { + if seen.contains(platform_id.as_str()) { + continue; + } + let Some(id) = input.selection.get(platform_id).filter(|id| !id.is_empty()) else { + continue; + }; + if !input.loading && !failed.contains(platform_id.as_str()) && !listed(platform_id, id) { + continue; + } + result.push(PersonaAssignment { + platform_id: platform_id.clone(), + account_id: id.clone(), + }); + } + result +} diff --git a/crates/accshift-app/src/settings.rs b/crates/accshift-app/src/settings.rs new file mode 100644 index 0000000..e78968a --- /dev/null +++ b/crates/accshift-app/src/settings.rs @@ -0,0 +1,502 @@ +//! The settings store, ported from `src/lib/features/settings/{types,store, +//! settingsPatch,apiKeyDraft,onboardingConsent}.ts` and the Steam entry of +//! `platformSettings` (`src/lib/platforms/steam/settingsSchema.ts`). +//! +//! [`sanitize_settings`] is the TS `sanitizeSettings`: any JSON in, a full +//! [`AppSettings`] out, with the same defaults, clamps and legacy keys. + +use crate::i18n::{self, Locale}; +use crate::js; +use crate::stores::StoreContext; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DataRefreshSettings { + pub avatar_cache_days: i64, + pub ban_check_days: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountDisplaySettings { + pub show_usernames: bool, + pub show_last_login_per_platform: BTreeMap, + pub show_card_notes_inline: bool, + pub expanded_folders: bool, +} + +/// `"system"` follows the OS reduced-motion preference; `"on"`/`"off"` force it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum AnimationsMode { + #[default] + System, + On, + Off, +} + +/// `"auto"` blurs the UI when streaming software is detected; `"off"` never does. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum StreamerMode { + #[default] + Auto, + Off, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum SteamShutdownMode { + #[default] + Graceful, + Force, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct SteamPlatformSettings { + pub run_as_admin: bool, + pub launch_options: String, + pub shutdown_mode: SteamShutdownMode, +} + +/// The per-platform settings. Only Steam has a schema today; a platform with +/// none has no entry, as in TS. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct PlatformSettings { + pub steam: SteamPlatformSettings, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppSettings { + pub language: Locale, + pub theme_id: String, + pub background_opacity: i64, + pub ui_scale_percent: i64, + pub animations: AnimationsMode, + pub streamer_mode: StreamerMode, + pub suspend_graphics_when_minimized: bool, + pub minimize_on_account_switch: bool, + pub data_refresh: DataRefreshSettings, + pub enabled_platforms: Vec, + /// Per-platform opt-out for network account health checks. + pub health_check_per_platform: BTreeMap, + pub personas_enabled: bool, + pub default_platform_id: String, + pub inactivity_blur_seconds: i64, + pub deep_links_enabled: bool, + /// Mirrored by the CLI binary, which refuses every subcommand when false. + pub cli_enabled: bool, + pub platform_settings: PlatformSettings, + pub account_display: AccountDisplaySettings, + pub pin_enabled: bool, + pub pin_hash: String, +} + +impl AppSettings { + /// The settings as the JSON the store holds. + pub fn to_value(&self) -> Value { + serde_json::to_value(self).unwrap_or(Value::Null) + } +} + +pub const DEFAULT_THEME_ID: &str = "dark"; +const DEFAULT_ENABLED_PLATFORM: &str = "steam"; +const DEFAULT_PLATFORM_ID: &str = "steam"; +const LAUNCH_OPTIONS_MAX: usize = 256; + +/// `DEFAULTS.healthCheckPerPlatform`. +pub const HEALTH_CHECK_DEFAULTS: [(&str, bool); 1] = [("roblox", true)]; + +/// `DEFAULTS.accountDisplay.showLastLoginPerPlatform`: what each platform +/// shows for an id missing from storage. Any other platform shows `false`. +pub const SHOW_LAST_LOGIN_DEFAULTS: [(&str, bool); 9] = [ + ("steam", false), + ("riot", false), + ("battle-net", false), + ("roblox", true), + ("epic", true), + ("ubisoft", true), + ("gog", true), + ("jagex", true), + ("discord", true), +]; + +/// Flat keys that predate `showLastLoginPerPlatform`. +const LEGACY_LAST_LOGIN_KEYS: [(&str, &str); 3] = [ + ("steam", "showLastLogin"), + ("riot", "showRiotLastLogin"), + ("battle-net", "showBattleNetLastLogin"), +]; + +/// `isValidPinHash` (`src/lib/shared/pin.ts`): `salt:hash` in hex (16 and 32 +/// bytes) or the legacy bare 32-byte hash. +pub fn is_valid_pin_hash(value: &str) -> bool { + let hex = |part: &str, len: usize| { + part.len() == len && part.bytes().all(|byte| byte.is_ascii_hexdigit()) + }; + match value.split_once(':') { + Some((salt, hash)) => hex(salt, 32) && hex(hash, 64), + None => hex(value, 64), + } +} + +fn sanitize_pin_hash(value: Option<&Value>) -> String { + let Some(text) = js::as_str(value) else { + return String::new(); + }; + let normalized = js::trim(text).to_lowercase(); + if is_valid_pin_hash(&normalized) { + normalized + } else { + String::new() + } +} + +fn field<'a>(record: Option<&'a Map>, key: &str) -> Option<&'a Value> { + record.and_then(|map| map.get(key)) +} + +fn has_field(record: Option<&Map>, key: &str) -> bool { + record.is_some_and(|map| map.contains_key(key)) +} + +/// `steamSettingsSchema.sanitize`. Legacy flat keys (`steamRunAsAdmin`, +/// `steamLaunchOptions`) still resolve. +pub fn sanitize_steam_settings( + raw: Option<&Value>, + legacy_root: Option<&Map>, +) -> SteamPlatformSettings { + let record = js::as_record(raw); + let launch_options = js::nullish_or( + field(record, "launchOptions"), + field(legacy_root, "steamLaunchOptions"), + ); + SteamPlatformSettings { + run_as_admin: js::truthy(js::nullish_or( + field(record, "runAsAdmin"), + field(legacy_root, "steamRunAsAdmin"), + )), + launch_options: js::as_str(launch_options) + .map(|text| js::utf16_prefix(js::trim(text), LAUNCH_OPTIONS_MAX)) + .unwrap_or_default(), + shutdown_mode: if js::as_str(field(record, "shutdownMode")) == Some("force") { + SteamShutdownMode::Force + } else { + SteamShutdownMode::Graceful + }, + } +} + +fn sanitize_show_last_login( + raw_account_display: Option<&Map>, + ctx: &StoreContext, +) -> BTreeMap { + let raw_map = js::as_record(field(raw_account_display, "showLastLoginPerPlatform")); + let mut result = BTreeMap::new(); + for id in ctx.platform_ids() { + let legacy_key = LEGACY_LAST_LOGIN_KEYS + .iter() + .find(|(platform, _)| *platform == id) + .map(|(_, key)| *key); + let value = if has_field(raw_map, id) { + js::truthy(field(raw_map, id)) + } else if let Some(key) = legacy_key.filter(|key| has_field(raw_account_display, key)) { + js::truthy(field(raw_account_display, key)) + } else { + SHOW_LAST_LOGIN_DEFAULTS + .iter() + .find(|(platform, _)| *platform == id) + .is_some_and(|(_, shown)| *shown) + }; + result.insert(id.to_string(), value); + } + result +} + +fn sanitize_health_check(raw: Option<&Map>) -> BTreeMap { + let raw_map = js::as_record(field(raw, "healthCheckPerPlatform")); + HEALTH_CHECK_DEFAULTS + .iter() + .map(|(id, default)| { + let value = if has_field(raw_map, id) { + js::not_false(field(raw_map, id)) + } else { + *default + }; + (id.to_string(), value) + }) + .collect() +} + +/// `sanitizeSettings`: every field present, every value in range. +pub fn sanitize_settings(value: &Value, ctx: &StoreContext) -> AppSettings { + let raw = js::as_record(Some(value)); + let raw_data_refresh = js::as_record(field(raw, "dataRefresh")); + let raw_platform_settings = js::as_record(field(raw, "platformSettings")); + let raw_account_display = js::as_record(field(raw, "accountDisplay")); + + let mut enabled_platforms: Vec = Vec::new(); + if let Some(Value::Array(items)) = field(raw, "enabledPlatforms") { + for item in items { + if let Value::String(id) = item { + if ctx.knows_platform(id) && !enabled_platforms.contains(id) { + enabled_platforms.push(id.clone()); + } + } + } + } + if enabled_platforms.is_empty() { + enabled_platforms.push(DEFAULT_ENABLED_PLATFORM.to_string()); + } + let default_platform_raw = + js::as_str(field(raw, "defaultPlatformId")).unwrap_or(DEFAULT_PLATFORM_ID); + let default_platform_id = if enabled_platforms + .iter() + .any(|id| id == default_platform_raw) + { + default_platform_raw.to_string() + } else { + enabled_platforms[0].clone() + }; + + let requested_pin = js::truthy(field(raw, "pinEnabled")); + let pin_hash = if requested_pin { + sanitize_pin_hash(field(raw, "pinHash")) + } else { + String::new() + }; + // An enabled flag without a usable hash protects nothing: setup again. + let pin_enabled = requested_pin && !pin_hash.is_empty(); + + let theme_requested = match field(raw, "themeId") { + Some(Value::String(id)) => id.as_str(), + _ if js::as_str(field(raw, "theme")) == Some("light") => "light", + _ => DEFAULT_THEME_ID, + }; + + AppSettings { + language: if has_field(raw, "language") { + i18n::normalize_locale(field(raw, "language")) + } else { + i18n::detect_preferred_locale(&ctx.preferred_languages) + }, + theme_id: ctx.resolve_theme_id(theme_requested).to_string(), + background_opacity: js::clamp_int(field(raw, "backgroundOpacity"), 0, 100, 100), + ui_scale_percent: js::clamp_int(field(raw, "uiScalePercent"), 75, 150, 100), + animations: match js::as_str(field(raw, "animations")) { + Some("on") => AnimationsMode::On, + Some("off") => AnimationsMode::Off, + _ => AnimationsMode::System, + }, + streamer_mode: if js::as_str(field(raw, "streamerMode")) == Some("off") { + StreamerMode::Off + } else { + StreamerMode::Auto + }, + suspend_graphics_when_minimized: js::not_false(field(raw, "suspendGraphicsWhenMinimized")), + minimize_on_account_switch: js::truthy(field(raw, "minimizeOnAccountSwitch")), + data_refresh: DataRefreshSettings { + avatar_cache_days: js::clamp_int( + js::nullish_or( + field(raw_data_refresh, "avatarCacheDays"), + field(raw, "avatarCacheDays"), + ), + 0, + 90, + 7, + ), + ban_check_days: js::clamp_int( + js::nullish_or( + field(raw_data_refresh, "banCheckDays"), + field(raw, "banCheckDays"), + ), + 0, + 90, + 7, + ), + }, + enabled_platforms, + health_check_per_platform: sanitize_health_check(raw), + personas_enabled: js::not_false(field(raw, "personasEnabled")), + default_platform_id, + inactivity_blur_seconds: js::clamp_int(field(raw, "inactivityBlurSeconds"), 0, 3600, 60), + deep_links_enabled: js::not_false(field(raw, "deepLinksEnabled")), + cli_enabled: js::not_false(field(raw, "cliEnabled")), + platform_settings: PlatformSettings { + steam: sanitize_steam_settings(field(raw_platform_settings, "steam"), raw), + }, + account_display: AccountDisplaySettings { + show_usernames: js::not_false(field(raw_account_display, "showUsernames")) + && js::not_false(field(raw, "showUsernames")), + show_last_login_per_platform: sanitize_show_last_login(raw_account_display, ctx), + show_card_notes_inline: js::truthy(js::nullish_or( + field(raw_account_display, "showCardNotesInline"), + field(raw, "showCardNotesInline"), + )), + expanded_folders: js::truthy(js::nullish_or( + field(raw_account_display, "expandedFolders"), + field(raw, "expandedFolders"), + )), + }, + pin_enabled, + pin_hash, + } +} + +/// `getCacheDuration`: how long a cached avatar stays fresh, in milliseconds +/// (0 means never cache). +pub fn cache_duration_ms(settings: &AppSettings) -> u64 { + let days = settings.data_refresh.avatar_cache_days.max(0) as u64; + days * 24 * 60 * 60 * 1000 +} + +// ------------------------------------------------------------ settingsPatch + +/// One setting that changed: where it sits and its new value (`None` when the +/// key is gone, `undefined` in TS). +#[derive(Debug, Clone, PartialEq)] +pub struct SettingsChange { + pub path: Vec, + pub value: Option, +} + +/// `diffSettings`: every leaf that differs between `before` and `after`. +/// Objects are walked key by key (sorted, where TS takes `before`'s key order +/// then `after`'s); lists and scalars are compared and replaced whole. +pub fn diff_settings(before: Option<&Value>, after: Option<&Value>) -> Vec { + let mut out = Vec::new(); + diff_into(before, after, &mut Vec::new(), &mut out); + out +} + +fn diff_into( + before: Option<&Value>, + after: Option<&Value>, + path: &mut Vec, + out: &mut Vec, +) { + if let (Some(Value::Object(left)), Some(Value::Object(right))) = (before, after) { + let mut keys: Vec<&String> = left.keys().chain(right.keys()).collect(); + keys.sort(); + keys.dedup(); + for key in keys { + path.push(key.clone()); + diff_into(left.get(key), right.get(key), path, out); + path.pop(); + } + return; + } + if before != after { + out.push(SettingsChange { + path: path.clone(), + value: after.cloned(), + }); + } +} + +fn apply_change(target: &mut Map, change: &SettingsChange) { + let Some((leaf, parents)) = change.path.split_last() else { + return; + }; + let mut node = target; + for key in parents { + let slot = node + .entry(key.clone()) + .or_insert_with(|| Value::Object(Map::new())); + if !slot.is_object() { + *slot = Value::Object(Map::new()); + } + let Value::Object(next) = slot else { + return; + }; + node = next; + } + match &change.value { + None => { + node.remove(leaf); + } + Some(value) => { + node.insert(leaf.clone(), value.clone()); + } + } +} + +/// `mergeSettingsDraft` on JSON: `current` with only the leaves the user +/// changed between `baseline` and `draft`. +pub fn merge_settings_draft_value(current: &Value, baseline: &Value, draft: &Value) -> Value { + let mut merged = match current { + Value::Object(map) => map.clone(), + _ => Map::new(), + }; + for change in diff_settings(Some(baseline), Some(draft)) { + if !change.path.is_empty() { + apply_change(&mut merged, &change); + } + } + Value::Object(merged) +} + +/// `mergeSettingsDraft`: the settings to save from an open panel, so a field +/// something else wrote meanwhile (zoom shortcut, streamer banner, a PIN hash +/// upgraded on unlock) is not put back. +pub fn merge_settings_draft( + current: &AppSettings, + baseline: &AppSettings, + draft: &AppSettings, +) -> Result { + let merged = + merge_settings_draft_value(¤t.to_value(), &baseline.to_value(), &draft.to_value()); + serde_json::from_value(merged) +} + +// ------------------------------------------------------------ apiKeyDraft + +/// The API key field after a successful save. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApiKeyDraft { + pub api_key: String, + pub touched: bool, +} + +/// `apiKeyDraftAfterSuccessfulSave`: clear the field only when it still holds +/// the key just written; a replacement typed meanwhile stays dirty. +pub fn api_key_draft_after_successful_save(current: &str, saved: &str) -> ApiKeyDraft { + if js::trim(current) == saved { + ApiKeyDraft { + api_key: String::new(), + touched: false, + } + } else { + ApiKeyDraft { + api_key: current.to_string(), + touched: true, + } + } +} + +// ------------------------------------------------------------ onboardingConsent + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OnboardingConsentOutcome { + Saved, + Failed, +} + +/// `completeOnboardingConsent`: a failure stays in the dialog, so the caller +/// shows it inline instead of a toast. +pub fn complete_onboarding_consent( + persist: impl FnOnce() -> Result, +) -> OnboardingConsentOutcome { + match persist() { + Ok(_) => OnboardingConsentOutcome::Saved, + Err(error) => { + eprintln!("telemetry_complete_onboarding failed: {error}"); + OnboardingConsentOutcome::Failed + } + } +} diff --git a/crates/accshift-app/src/storage.rs b/crates/accshift-app/src/storage.rs new file mode 100644 index 0000000..a9c58a8 --- /dev/null +++ b/crates/accshift-app/src/storage.rs @@ -0,0 +1,544 @@ +//! The client storage layer, ported from `src/lib/storage/clientStorage.ts` +//! and `storeIds.ts`. +//! +//! [`ClientStorage`] holds the raw JSON of every client store, exactly what +//! the backend's `load_client_storage_snapshot` hands over and what +//! `save_client_storage_store` writes. It never calls the backend itself +//! during a frame: a write marks its store pending, and the caller drains +//! [`ClientStorage::take_due_saves`] into the job pool, runs [`persist`] there +//! and reports back with [`ClientStorage::finish_save`]. The debounce, the +//! one-save-at-a-time rule per store, the manifest bookkeeping and the +//! external-refresh rules are the TS ones. +//! +//! Legacy `localStorage` keys cannot be read from Rust. [`initialize`] takes +//! them as an optional map (`key -> raw string`, as `localStorage.getItem` +//! returns it) for whoever reads them later. + +use crate::backend::Backend; +use crate::error::{BackendError, BackendResult}; +use crate::types::{ClientStorageSnapshot, StorageManifest}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::time::{Duration, Instant}; + +pub use accshift_core::storage::{ + STORAGE_SCHEMA_VERSION, STORE_ACCOUNT_CARD_COLORS as CLIENT_STORE_ACCOUNT_CARD_COLORS, + STORE_ACCOUNT_CARD_NOTES as CLIENT_STORE_ACCOUNT_CARD_NOTES, + STORE_ACCOUNT_DEFAULT_GAME as CLIENT_STORE_ACCOUNT_DEFAULT_GAME, + STORE_FOLDERS as CLIENT_STORE_FOLDERS, + STORE_FOLDER_CARD_COLORS as CLIENT_STORE_FOLDER_CARD_COLORS, + STORE_PERSONAS as CLIENT_STORE_PERSONAS, + STORE_ROBLOX_PROFILE_CACHE as CLIENT_STORE_ROBLOX_PROFILE_CACHE, + STORE_SETTINGS as CLIENT_STORE_SETTINGS, + STORE_STEAM_BAN_CHECK_STATE as CLIENT_STORE_STEAM_BAN_CHECK_STATE, + STORE_STEAM_BAN_INFO_CACHE as CLIENT_STORE_STEAM_BAN_INFO_CACHE, + STORE_STEAM_PROFILE_CACHE as CLIENT_STORE_STEAM_PROFILE_CACHE, + STORE_VIEW_MODE as CLIENT_STORE_VIEW_MODE, + TARGET_APP_CONFIG_LOCAL as STORAGE_TARGET_APP_CONFIG_LOCAL, + TARGET_APP_CONFIG_PORTABLE as STORAGE_TARGET_APP_CONFIG_PORTABLE, + TARGET_CUSTOM_THEMES as STORAGE_TARGET_CUSTOM_THEMES, + TARGET_DISCORD_SNAPSHOTS as STORAGE_TARGET_DISCORD_SNAPSHOTS, + TARGET_EPIC_SNAPSHOTS as STORAGE_TARGET_EPIC_SNAPSHOTS, + TARGET_GOG_SNAPSHOTS as STORAGE_TARGET_GOG_SNAPSHOTS, + TARGET_JAGEX_SNAPSHOTS as STORAGE_TARGET_JAGEX_SNAPSHOTS, + TARGET_RIOT_SNAPSHOTS as STORAGE_TARGET_RIOT_SNAPSHOTS, + TARGET_UBISOFT_SNAPSHOTS as STORAGE_TARGET_UBISOFT_SNAPSHOTS, +}; + +/// A client store id: one of [`CLIENT_STORE_IDS`]. +pub type StoreId = &'static str; + +/// Every client store, in the TS order. +pub const CLIENT_STORE_IDS: [StoreId; 12] = [ + CLIENT_STORE_SETTINGS, + CLIENT_STORE_FOLDERS, + CLIENT_STORE_PERSONAS, + CLIENT_STORE_ACCOUNT_CARD_NOTES, + CLIENT_STORE_ACCOUNT_CARD_COLORS, + CLIENT_STORE_ACCOUNT_DEFAULT_GAME, + CLIENT_STORE_FOLDER_CARD_COLORS, + CLIENT_STORE_VIEW_MODE, + CLIENT_STORE_STEAM_PROFILE_CACHE, + CLIENT_STORE_ROBLOX_PROFILE_CACHE, + CLIENT_STORE_STEAM_BAN_CHECK_STATE, + CLIENT_STORE_STEAM_BAN_INFO_CACHE, +]; + +/// The `localStorage` key each store lived under before the file stores. +pub const LEGACY_LOCAL_STORAGE_KEYS: [(StoreId, &str); 12] = [ + (CLIENT_STORE_SETTINGS, "accshift_settings"), + (CLIENT_STORE_FOLDERS, "accshift_folders"), + (CLIENT_STORE_PERSONAS, "accshift_personas"), + ( + CLIENT_STORE_ACCOUNT_CARD_NOTES, + "accshift_account_card_notes", + ), + ( + CLIENT_STORE_ACCOUNT_CARD_COLORS, + "accshift_account_card_colors", + ), + ( + CLIENT_STORE_ACCOUNT_DEFAULT_GAME, + "accshift_account_default_game", + ), + ( + CLIENT_STORE_FOLDER_CARD_COLORS, + "accshift_folder_card_colors", + ), + (CLIENT_STORE_VIEW_MODE, "accshift_viewMode"), + (CLIENT_STORE_STEAM_PROFILE_CACHE, "accshift_avatars"), + (CLIENT_STORE_ROBLOX_PROFILE_CACHE, "accshift_roblox_avatars"), + ( + CLIENT_STORE_STEAM_BAN_CHECK_STATE, + "accshift_ban_check_state_v2", + ), + ( + CLIENT_STORE_STEAM_BAN_INFO_CACHE, + "accshift_ban_info_cache_v1", + ), +]; + +/// How long a write waits before it is due, like the TS `scheduleSave`. +pub const SAVE_DEBOUNCE: Duration = Duration::from_millis(120); + +/// Raw legacy `localStorage` values by key, as `getItem` returns them. +pub type LegacyLocalStorage = BTreeMap; + +/// The [`StoreId`] spelled `id`, if it is one. +pub fn client_store_id(id: &str) -> Option { + CLIENT_STORE_IDS.iter().copied().find(|known| *known == id) +} + +/// `isClientStoreId`. +pub fn is_client_store_id(id: &str) -> bool { + client_store_id(id).is_some() +} + +/// The legacy `localStorage` key of a store. +pub fn legacy_local_storage_key(store_id: &str) -> Option<&'static str> { + LEGACY_LOCAL_STORAGE_KEYS + .iter() + .find(|(id, _)| *id == store_id) + .map(|(_, key)| *key) +} + +/// `readLegacyLocalStorageValue`: the view mode is a bare string, every other +/// store JSON; a value that does not parse counts as absent. +pub fn read_legacy_value(store_id: StoreId, legacy: &LegacyLocalStorage) -> Option { + let raw = legacy.get(legacy_local_storage_key(store_id)?)?; + if store_id == CLIENT_STORE_VIEW_MODE { + return Some(Value::String(raw.clone())); + } + serde_json::from_str(raw).ok() +} + +/// One store write to run off the UI thread with [`persist`]. +#[derive(Debug, Clone, PartialEq)] +pub struct PendingSave { + pub store_id: StoreId, + /// `null` deletes the store file. + pub value: Value, +} + +/// `persistStore`: writes the store and answers its new fingerprint (`None` +/// from the mock). +pub fn persist(backend: &dyn Backend, save: &PendingSave) -> BackendResult> { + backend.save_client_storage_store(save.store_id, &save.value) +} + +/// `emitStorageLog`: an info line under `frontend.storage`. Failures are +/// dropped, as in TS. +pub fn storage_log(backend: &dyn Backend, message: &str, details: Option<&Value>) { + let details = details.map(Value::to_string); + let _ = backend.log_app_event("info", "frontend.storage", message, details.as_deref()); +} + +/// What [`initialize`] did. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct InitReport { + /// The snapshot could not be read: every store is empty and nothing was + /// migrated, so a later launch can still read the files. + pub load_error: Option, + /// Stores filled from the legacy keys and written to disk. + pub migrated: Vec, + /// Legacy stores whose write failed (they stay in memory). + pub failed_migrations: Vec<(StoreId, String)>, +} + +/// `initializeClientStorage`, blocking: run it before the first frame or in a +/// job. `boot_snapshot` is the one the boot payload carried; without it the +/// snapshot is loaded through the backend. +pub fn initialize( + backend: &dyn Backend, + boot_snapshot: Option, + legacy: Option<&LegacyLocalStorage>, + mock_backend: bool, +) -> (ClientStorage, InitReport) { + let mut report = InitReport::default(); + let snapshot = match boot_snapshot { + Some(snapshot) => Ok(snapshot), + None => backend.load_client_storage_snapshot(), + }; + let snapshot = match snapshot { + Ok(snapshot) => snapshot, + Err(error) => { + report.load_error = Some(error.message); + return (ClientStorage::new(), report); + } + }; + let mut storage = ClientStorage::from_snapshot(snapshot); + storage_log( + backend, + "Loaded client storage into memory", + Some(&json!({ "storeCount": storage.snapshot_store_count })), + ); + + // The legacy keys are the real user's data; the mock saves nothing, so + // migrating there would only put that data on screen. + if mock_backend { + return (storage, report); + } + let Some(legacy) = legacy else { + return (storage, report); + }; + let migrated = storage.migrate_legacy(legacy); + if migrated.is_empty() { + return (storage, report); + } + for save in storage.take_all_saves() { + let result = persist(backend, &save); + if let Err(error) = &result { + report + .failed_migrations + .push((save.store_id, error.message.clone())); + } + storage.finish_save(save.store_id, &result); + } + storage_log( + backend, + "Migrated legacy localStorage stores", + Some(&json!({ "storeIds": migrated })), + ); + if let Ok(manifest) = backend.get_storage_manifest() { + storage.replace_manifest(manifest); + } + report.migrated = migrated; + (storage, report) +} + +/// The raw stores, their revisions, the last manifest seen and the saves +/// still owed to disk. +#[derive(Debug, Clone)] +pub struct ClientStorage { + values: BTreeMap, + revisions: BTreeMap, + manifest: StorageManifest, + /// Debounced writes not handed out yet, with the instant each is due. + pending: BTreeMap, + /// Writes handed out and not reported back. + in_flight: BTreeSet, + snapshot_store_count: usize, +} + +impl Default for ClientStorage { + fn default() -> Self { + Self::new() + } +} + +impl ClientStorage { + /// Every store empty, the manifest at schema 1 with no entry. + pub fn new() -> Self { + Self { + values: BTreeMap::new(), + revisions: BTreeMap::new(), + manifest: StorageManifest { + schema_version: STORAGE_SCHEMA_VERSION, + stores: BTreeMap::new(), + }, + pending: BTreeMap::new(), + in_flight: BTreeSet::new(), + snapshot_store_count: 0, + } + } + + pub fn from_snapshot(snapshot: ClientStorageSnapshot) -> Self { + let mut storage = Self::new(); + storage.apply_snapshot(snapshot); + storage + } + + /// `applySnapshot`: takes the snapshot's manifest and every store's value, + /// bumping every revision. + pub fn apply_snapshot(&mut self, snapshot: ClientStorageSnapshot) { + self.snapshot_store_count = snapshot.stores.len(); + self.manifest = snapshot.manifest; + let mut stores = snapshot.stores; + for id in CLIENT_STORE_IDS { + let value = stores.remove(id).unwrap_or(Value::Null); + self.values.insert(id, value); + self.bump(id); + } + } + + fn bump(&mut self, id: StoreId) { + *self.revisions.entry(id).or_insert(0) += 1; + } + + /// `getClientStoreValue`: `None` for a missing or `null` store. + pub fn get(&self, store_id: &str) -> Option<&Value> { + self.values.get(store_id).filter(|value| !value.is_null()) + } + + /// `getClientStoreRevision`: bumped by every write, snapshot and reload. + pub fn revision(&self, store_id: &str) -> u64 { + self.revisions.get(store_id).copied().unwrap_or(0) + } + + /// The last manifest seen, with this session's own writes recorded. + pub fn manifest(&self) -> &StorageManifest { + &self.manifest + } + + /// Takes `manifest` whole, as the TS migration path does after its saves. + pub fn replace_manifest(&mut self, manifest: StorageManifest) { + self.manifest = manifest; + } + + /// `setClientStoreValue` with the 120 ms debounce. + pub fn set(&mut self, store_id: StoreId, value: Value) { + self.set_with_delay(store_id, value, SAVE_DEBOUNCE); + } + + /// `setClientStoreValue(..., { immediate: true })`. + pub fn set_immediate(&mut self, store_id: StoreId, value: Value) { + self.set_with_delay(store_id, value, Duration::ZERO); + } + + fn set_with_delay(&mut self, store_id: StoreId, value: Value, delay: Duration) { + self.values.insert(store_id, value); + self.bump(store_id); + // A new write restarts the debounce, like clearTimeout + setTimeout. + self.pending.insert(store_id, Instant::now() + delay); + } + + /// `migrate`: every store still empty takes its legacy value, due at once. + /// Returns the stores it filled. + pub fn migrate_legacy(&mut self, legacy: &LegacyLocalStorage) -> Vec { + let mut migrated = Vec::new(); + for id in CLIENT_STORE_IDS { + if self.get(id).is_some() { + continue; + } + if let Some(value) = read_legacy_value(id, legacy) { + self.values.insert(id, value); + self.pending.insert(id, Instant::now()); + migrated.push(id); + } + } + migrated + } + + /// `hasPendingSave`: a local edit not yet on disk, debounced or writing. + pub fn has_pending_save(&self, store_id: &str) -> bool { + self.pending.contains_key(store_id) || self.in_flight.contains(store_id) + } + + /// True while any write is owed to disk. + pub fn has_unsaved(&self) -> bool { + !self.pending.is_empty() || !self.in_flight.is_empty() + } + + /// When the next debounced write falls due, for a timed repaint. + pub fn next_save_due(&self) -> Option { + self.pending + .iter() + .filter(|(id, _)| !self.in_flight.contains(*id)) + .map(|(_, due)| *due) + .min() + } + + /// The writes due at `now`. A store whose previous write is still in + /// flight waits for it, so two writes of one store never race. + pub fn take_due_saves(&mut self, now: Instant) -> Vec { + self.take_saves(|due| due <= now) + } + + /// `flushPendingSaves`: every pending write, due or not. Writes in flight + /// are the caller's to wait for; their store is handed out again here once + /// they are reported, if it changed meanwhile. + pub fn take_all_saves(&mut self) -> Vec { + self.take_saves(|_| true) + } + + fn take_saves(&mut self, due: impl Fn(Instant) -> bool) -> Vec { + let ready: Vec = self + .pending + .iter() + .filter(|(id, at)| !self.in_flight.contains(*id) && due(**at)) + .map(|(id, _)| *id) + .collect(); + ready + .into_iter() + .map(|id| { + self.pending.remove(id); + self.in_flight.insert(id); + PendingSave { + store_id: id, + value: self.values.get(id).cloned().unwrap_or(Value::Null), + } + }) + .collect() + } + + /// Reports a write handed out by `take_*_saves`. The fingerprint the + /// backend answers marks the write as our own, so the next manifest diff + /// does not read it back as an external change. A failed write is not + /// retried, as in TS: the value stays in memory until the next edit. + pub fn finish_save(&mut self, store_id: StoreId, result: &BackendResult>) { + self.in_flight.remove(store_id); + if let Ok(Some(fingerprint)) = result { + self.manifest + .stores + .insert(store_id.to_string(), fingerprint.clone()); + } + } + + /// Writes every pending store now and waits for each, like awaiting + /// `flushPendingSaves` before the window closes. Writes handed to the job + /// pool earlier are not awaited here. + pub fn flush_blocking(&mut self, backend: &dyn Backend) -> Vec<(StoreId, BackendError)> { + let mut failures = Vec::new(); + for save in self.take_all_saves() { + let result = persist(backend, &save); + if let Err(error) = &result { + failures.push((save.store_id, error.clone())); + } + self.finish_save(save.store_id, &result); + } + failures + } + + /// `recordManifestEntries`: marks only `keys` of `manifest` as seen. + fn record_manifest_entries(&mut self, manifest: &StorageManifest, keys: &[String]) { + for key in keys { + match manifest.stores.get(key) { + Some(fingerprint) => { + self.manifest + .stores + .insert(key.clone(), fingerprint.clone()); + } + None => { + self.manifest.stores.remove(key); + } + } + } + self.manifest.schema_version = manifest.schema_version; + } + + /// First half of `refreshClientStorageIfChanged`, given a fresh manifest. + /// Returns `None` when nothing changed. Storage targets that are not + /// client stores are marked as seen right away; client stores with a write + /// owed to disk are left alone. + pub fn begin_refresh(&mut self, next: &StorageManifest) -> Option { + let changed = diff_manifests(&self.manifest, next) + .into_iter() + .filter(|key| !is_client_store_id(key) || !self.has_pending_save(key)) + .collect::>(); + if changed.is_empty() { + return None; + } + let other_targets: Vec = changed + .iter() + .filter(|key| !is_client_store_id(key)) + .cloned() + .collect(); + self.record_manifest_entries(next, &other_targets); + let stores = changed + .iter() + .filter_map(|key| client_store_id(key)) + .map(|id| (id, self.revision(id))) + .collect(); + Some(RefreshPlan { + changed, + other_targets, + stores, + }) + } + + /// Second half: applies the stores of `snapshot` that saw no local write + /// since [`ClientStorage::begin_refresh`], and returns every id that + /// changed (other targets first). + pub fn finish_refresh( + &mut self, + plan: RefreshPlan, + snapshot: ClientStorageSnapshot, + ) -> Vec { + let applied: Vec = plan + .stores + .iter() + .filter(|(id, revision)| !self.has_pending_save(id) && self.revision(id) == *revision) + .map(|(id, _)| *id) + .collect(); + let mut stores = snapshot.stores; + for id in &applied { + let value = stores.remove(*id).unwrap_or(Value::Null); + self.values.insert(id, value); + self.bump(id); + } + let applied_keys: Vec = applied.iter().map(|id| id.to_string()).collect(); + self.record_manifest_entries(&snapshot.manifest, &applied_keys); + plan.other_targets.into_iter().chain(applied_keys).collect() + } + + /// `refreshClientStorageIfChanged`, blocking. + pub fn refresh_blocking(&mut self, backend: &dyn Backend) -> BackendResult> { + let next = backend.get_storage_manifest()?; + let Some(plan) = self.begin_refresh(&next) else { + return Ok(Vec::new()); + }; + storage_log( + backend, + "Detected external storage changes", + Some(&json!({ "changed": plan.changed })), + ); + if !plan.needs_snapshot() { + return Ok(plan.other_targets); + } + let snapshot = backend.load_client_storage_snapshot()?; + Ok(self.finish_refresh(plan, snapshot)) + } +} + +/// What a refresh found in the manifest, carried between the manifest read +/// and the snapshot read. +#[derive(Debug, Clone, PartialEq)] +pub struct RefreshPlan { + /// Every changed key worth reporting, stores and other targets. + pub changed: Vec, + /// Changed keys that are not client stores. + pub other_targets: Vec, + /// Changed client stores, with their revision before the snapshot read. + pub stores: Vec<(StoreId, u64)>, +} + +impl RefreshPlan { + /// False when only non-store targets changed: no snapshot is needed and + /// the result is [`RefreshPlan::other_targets`]. + pub fn needs_snapshot(&self) -> bool { + !self.stores.is_empty() + } +} + +/// `diffManifests`: keys whose fingerprint differs, a missing one reading as +/// `""`. Sorted by key (TS lists the previous manifest's keys first). +pub fn diff_manifests(previous: &StorageManifest, next: &StorageManifest) -> Vec { + let keys: BTreeSet<&String> = previous.stores.keys().chain(next.stores.keys()).collect(); + keys.into_iter() + .filter(|key| { + previous.stores.get(*key).map_or("", String::as_str) + != next.stores.get(*key).map_or("", String::as_str) + }) + .cloned() + .collect() +} diff --git a/crates/accshift-app/src/stores.rs b/crates/accshift-app/src/stores.rs new file mode 100644 index 0000000..ef10d55 --- /dev/null +++ b/crates/accshift-app/src/stores.rs @@ -0,0 +1,765 @@ +//! Every client store behind one value, plus the small stores of +//! `src/lib/shared/` (`accountCardColors`, `accountCardNotes`, +//! `folderCardColors`, `viewMode`, `cachedMapStore`) and +//! `src/lib/platforms/steam/defaultGameStore.ts`. +//! +//! [`ClientStores`] owns the [`ClientStorage`] and a typed copy of each store +//! the UI reads. A mutation updates the typed copy and writes its JSON into +//! the storage, which marks that store pending; the shell drains +//! [`ClientStores::take_due_saves`] into the job pool and reports each result +//! with [`ClientStores::finish_save`]. A typed copy is rebuilt (sanitized) +//! only when its store changes from outside: a snapshot or a refresh. A +//! local write keeps the value as written, like the TS caches. + +use crate::folders::{self, FolderInfo, FolderSection, FolderStore, ItemRef}; +use crate::i18n::Locale; +use crate::js; +use crate::personas::{self, Persona, PersonaInput, PersonaPatch}; +use crate::settings::{self, AppSettings}; +use crate::storage::{ + ClientStorage, PendingSave, RefreshPlan, StoreId, CLIENT_STORE_ACCOUNT_CARD_COLORS, + CLIENT_STORE_ACCOUNT_CARD_NOTES, CLIENT_STORE_ACCOUNT_DEFAULT_GAME, CLIENT_STORE_FOLDERS, + CLIENT_STORE_FOLDER_CARD_COLORS, CLIENT_STORE_PERSONAS, CLIENT_STORE_SETTINGS, + CLIENT_STORE_VIEW_MODE, +}; +use crate::types::ClientStorageSnapshot; +use crate::{Backend, BackendResult}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::time::Instant; + +/// The platforms shipped with the app, in display order: the platform +/// registry's chrome entries (`src/lib/platforms/registry.ts`), which also +/// cover every shipped descriptor. +pub const SHIPPED_PLATFORM_IDS: [&str; 9] = [ + "steam", + "riot", + "battle-net", + "ubisoft", + "roblox", + "epic", + "gog", + "jagex", + "discord", +]; + +/// The built-in theme ids (`BUILT_IN_DOCUMENTS` in `src/lib/theme/themes.ts`). +pub const BUILT_IN_THEME_IDS: [&str; 6] = [ + "dark", + "light", + "midnight", + "glass-dark", + "glass-light", + "liquid-glass", +]; + +/// What the sanitizers read from outside the stores: the platform list (the +/// shipped ones plus the user's descriptors), the theme list (built-ins plus +/// custom themes) and the OS's preferred languages for a fresh install. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StoreContext { + pub platform_ids: Vec, + pub theme_ids: Vec, + /// Most preferred first, like `navigator.languages`. + pub preferred_languages: Vec, +} + +impl Default for StoreContext { + fn default() -> Self { + Self::shipped() + } +} + +impl StoreContext { + /// Shipped platforms and built-in themes, no preferred language. + pub fn shipped() -> Self { + Self { + platform_ids: SHIPPED_PLATFORM_IDS + .iter() + .map(|id| id.to_string()) + .collect(), + theme_ids: BUILT_IN_THEME_IDS.iter().map(|id| id.to_string()).collect(), + preferred_languages: Vec::new(), + } + } + + pub fn platform_ids(&self) -> impl Iterator { + self.platform_ids.iter().map(String::as_str) + } + + pub fn knows_platform(&self, id: &str) -> bool { + self.platform_ids.iter().any(|known| known == id) + } + + /// `getThemeDefinition(id).id`: the id when it names a theme, the default + /// theme otherwise. + pub fn resolve_theme_id<'a>(&'a self, id: &'a str) -> &'a str { + if !id.is_empty() && self.theme_ids.iter().any(|known| known == id) { + id + } else { + settings::DEFAULT_THEME_ID + } + } +} + +// ------------------------------------------------------------ cachedMapStore + +/// A string map store (`createCachedMapStore`) with its entry sanitizer. +#[derive(Debug, Clone)] +pub struct StringMapStore { + store_id: StoreId, + sanitize_entry: fn(&str, &str) -> Option, + map: BTreeMap, +} + +impl StringMapStore { + pub fn new(store_id: StoreId, sanitize_entry: fn(&str, &str) -> Option) -> Self { + Self { + store_id, + sanitize_entry, + map: BTreeMap::new(), + } + } + + pub fn store_id(&self) -> StoreId { + self.store_id + } + + /// `sanitizeMap`: string values under non-blank keys that pass the entry + /// sanitizer. + pub fn load(&mut self, value: Option<&Value>) { + self.map.clear(); + let Some(record) = js::as_record(value) else { + return; + }; + for (key, raw) in record { + if js::trim(key).is_empty() { + continue; + } + let Value::String(raw) = raw else { + continue; + }; + if let Some(safe) = (self.sanitize_entry)(key, raw) { + self.map.insert(key.clone(), safe); + } + } + } + + /// `get`: the entry, `""` when absent. + pub fn get(&self, id: &str) -> &str { + self.map.get(id).map_or("", String::as_str) + } + + pub fn entries(&self) -> &BTreeMap { + &self.map + } + + fn put(&mut self, id: &str, value: &str) { + match (self.sanitize_entry)(id, value) { + Some(safe) => { + self.map.insert(id.to_string(), safe); + } + None => { + self.map.remove(id); + } + } + } + + /// `set`: a value the sanitizer refuses removes the entry. + pub fn set(&mut self, id: &str, value: &str) { + self.put(id, value); + } + + /// `setMany`: the same value for every id. Returns false (and writes + /// nothing) for an empty list. + pub fn set_many(&mut self, ids: &[String], value: &str) -> bool { + if ids.is_empty() { + return false; + } + for id in ids { + self.put(id, value); + } + true + } + + /// `remove`. + pub fn remove(&mut self, id: &str) { + self.map.remove(id); + } + + pub fn to_value(&self) -> Value { + serde_json::to_value(&self.map).unwrap_or(Value::Null) + } +} + +/// One account card colour preset. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ColorPreset { + pub id: &'static str, + pub color: &'static str, +} + +/// `ACCOUNT_CARD_COLOR_PRESETS`. +pub const ACCOUNT_CARD_COLOR_PRESETS: [ColorPreset; 11] = [ + ColorPreset { + id: "none", + color: "", + }, + ColorPreset { + id: "blue", + color: "#3b82f6", + }, + ColorPreset { + id: "cyan", + color: "#06b6d4", + }, + ColorPreset { + id: "green", + color: "#10b981", + }, + ColorPreset { + id: "lime", + color: "#84cc16", + }, + ColorPreset { + id: "yellow", + color: "#f59e0b", + }, + ColorPreset { + id: "orange", + color: "#f97316", + }, + ColorPreset { + id: "red", + color: "#f43f5e", + }, + ColorPreset { + id: "pink", + color: "#ec4899", + }, + ColorPreset { + id: "violet", + color: "#8b5cf6", + }, + ColorPreset { + id: "gray", + color: "#71717a", + }, +]; + +/// `SAFE_COLOR_RE`: `#rgb`, `#rrggbb` or `#rrggbbaa`. +pub fn sanitize_card_color(_key: &str, color: &str) -> Option { + js::is_hex_color(color, &[3, 6, 8]).then(|| color.to_string()) +} + +/// `MAX_NOTE_LENGTH`, in UTF-16 units. +pub const MAX_NOTE_LENGTH: usize = 180; + +/// `sanitizeNote`: control characters become spaces, then trim and cut. +pub fn sanitize_note(_key: &str, value: &str) -> Option { + let spaced: String = value + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .collect(); + let clean = js::utf16_prefix(js::trim(&spaced), MAX_NOTE_LENGTH); + (!clean.is_empty()).then_some(clean) +} + +// ------------------------------------------------------------ viewMode + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum ViewMode { + #[default] + Grid, + List, +} + +/// `getViewMode`: `"list"` is the list, anything else the grid. +pub fn view_mode_from_value(value: Option<&Value>) -> ViewMode { + if js::as_str(value) == Some("list") { + ViewMode::List + } else { + ViewMode::Grid + } +} + +impl ViewMode { + pub fn as_str(self) -> &'static str { + match self { + ViewMode::Grid => "grid", + ViewMode::List => "list", + } + } +} + +// ------------------------------------------------------------ defaultGameStore + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DefaultGame { + pub app_id: String, + pub name: String, +} + +/// The default game store's `sanitize`: entries with a numeric `appId`. +pub fn sanitize_default_games(value: Option<&Value>) -> BTreeMap { + let mut out = BTreeMap::new(); + let Some(record) = js::as_record(value) else { + return out; + }; + for (key, raw) in record { + if js::trim(key).is_empty() { + continue; + } + let Some(entry) = js::as_record(Some(raw)) else { + continue; + }; + let app_id = js::as_str(entry.get("appId")).map_or("", js::trim); + let name = js::as_str(entry.get("name")).unwrap_or_default(); + if !js::is_ascii_digits(app_id) { + continue; + } + out.insert( + key.clone(), + DefaultGame { + app_id: app_id.to_string(), + name: name.to_string(), + }, + ); + } + out +} + +// ------------------------------------------------------------ ClientStores + +/// Every client store the UI reads, typed, over the raw [`ClientStorage`]. +#[derive(Debug, Clone)] +pub struct ClientStores { + ctx: StoreContext, + storage: ClientStorage, + settings: AppSettings, + folders: FolderStore, + personas: Vec, + account_card_notes: StringMapStore, + account_card_colors: StringMapStore, + folder_card_colors: StringMapStore, + default_games: BTreeMap, + view_mode: ViewMode, + /// The storage revision each typed copy was built from or written at. + seen: BTreeMap, +} + +const TYPED_STORES: [StoreId; 8] = [ + CLIENT_STORE_SETTINGS, + CLIENT_STORE_FOLDERS, + CLIENT_STORE_PERSONAS, + CLIENT_STORE_ACCOUNT_CARD_NOTES, + CLIENT_STORE_ACCOUNT_CARD_COLORS, + CLIENT_STORE_ACCOUNT_DEFAULT_GAME, + CLIENT_STORE_FOLDER_CARD_COLORS, + CLIENT_STORE_VIEW_MODE, +]; + +impl ClientStores { + pub fn new(storage: ClientStorage, ctx: StoreContext) -> Self { + let settings = settings::sanitize_settings(&Value::Null, &ctx); + let mut stores = Self { + ctx, + storage, + settings, + folders: FolderStore::default(), + personas: Vec::new(), + account_card_notes: StringMapStore::new(CLIENT_STORE_ACCOUNT_CARD_NOTES, sanitize_note), + account_card_colors: StringMapStore::new( + CLIENT_STORE_ACCOUNT_CARD_COLORS, + sanitize_card_color, + ), + folder_card_colors: StringMapStore::new( + CLIENT_STORE_FOLDER_CARD_COLORS, + sanitize_card_color, + ), + default_games: BTreeMap::new(), + view_mode: ViewMode::Grid, + seen: BTreeMap::new(), + }; + for id in TYPED_STORES { + stores.reload(id); + } + stores + } + + fn reload(&mut self, id: StoreId) { + let value = self.storage.get(id); + match id { + CLIENT_STORE_SETTINGS => { + self.settings = + settings::sanitize_settings(value.unwrap_or(&Value::Null), &self.ctx) + } + CLIENT_STORE_FOLDERS => self.folders = folders::load_folder_store(value), + CLIENT_STORE_PERSONAS => { + self.personas = personas::sanitize_personas( + value.unwrap_or(&Value::Array(Vec::new())), + &self.ctx, + ) + } + CLIENT_STORE_ACCOUNT_CARD_NOTES => self.account_card_notes.load(value), + CLIENT_STORE_ACCOUNT_CARD_COLORS => self.account_card_colors.load(value), + CLIENT_STORE_FOLDER_CARD_COLORS => self.folder_card_colors.load(value), + CLIENT_STORE_ACCOUNT_DEFAULT_GAME => self.default_games = sanitize_default_games(value), + CLIENT_STORE_VIEW_MODE => self.view_mode = view_mode_from_value(value), + _ => return, + } + self.seen.insert(id, self.storage.revision(id)); + } + + /// Rebuilds every typed copy whose store changed in the storage since it + /// was built. Returns the stores it rebuilt. + pub fn sync(&mut self) -> Vec { + let stale: Vec = TYPED_STORES + .into_iter() + .filter(|id| self.seen.get(id).copied() != Some(self.storage.revision(id))) + .collect(); + for id in &stale { + self.reload(id); + } + stale + } + + fn write(&mut self, id: StoreId, value: Value) { + self.storage.set(id, value); + self.seen.insert(id, self.storage.revision(id)); + } + + pub fn context(&self) -> &StoreContext { + &self.ctx + } + + /// Replaces the platform and theme lists. Like TS, nothing is + /// re-sanitized until the next write or reload. + pub fn set_context(&mut self, ctx: StoreContext) { + self.ctx = ctx; + } + + pub fn storage(&self) -> &ClientStorage { + &self.storage + } + + /// The raw storage, for the stores without a typed copy here (profile and + /// ban caches). Call [`ClientStores::sync`] after changing a typed store + /// through it. + pub fn storage_mut(&mut self) -> &mut ClientStorage { + &mut self.storage + } + + // -------------------------------------------------------- saves + + pub fn take_due_saves(&mut self, now: Instant) -> Vec { + self.storage.take_due_saves(now) + } + + pub fn take_all_saves(&mut self) -> Vec { + self.storage.take_all_saves() + } + + pub fn finish_save(&mut self, store_id: StoreId, result: &BackendResult>) { + self.storage.finish_save(store_id, result); + } + + pub fn next_save_due(&self) -> Option { + self.storage.next_save_due() + } + + // -------------------------------------------------------- snapshot and refresh + + pub fn apply_snapshot(&mut self, snapshot: ClientStorageSnapshot) -> Vec { + self.storage.apply_snapshot(snapshot); + self.sync() + } + + pub fn begin_refresh(&mut self, next: &crate::types::StorageManifest) -> Option { + self.storage.begin_refresh(next) + } + + /// Applies a refresh and rebuilds the typed stores it touched. Returns + /// every changed id, as `refreshClientStorageIfChanged`. + pub fn finish_refresh( + &mut self, + plan: RefreshPlan, + snapshot: ClientStorageSnapshot, + ) -> Vec { + let changed = self.storage.finish_refresh(plan, snapshot); + self.sync(); + changed + } + + /// `refreshClientStorageIfChanged`, blocking. + pub fn refresh_blocking(&mut self, backend: &dyn Backend) -> BackendResult> { + let changed = self.storage.refresh_blocking(backend); + self.sync(); + changed + } + + // -------------------------------------------------------- settings + + /// `peekSettings` / `getSettings`. + pub fn settings(&self) -> &AppSettings { + &self.settings + } + + /// `hasStoredSettings`: false only on a fresh install. + pub fn has_stored_settings(&self) -> bool { + self.storage.get(CLIENT_STORE_SETTINGS).is_some() + } + + /// `saveSettings`: sanitized, then written. (The TS boot-theme + /// `localStorage` hint has no counterpart here.) + pub fn save_settings(&mut self, next: &AppSettings) { + let sanitized = settings::sanitize_settings(&next.to_value(), &self.ctx); + let value = sanitized.to_value(); + self.settings = sanitized; + self.write(CLIENT_STORE_SETTINGS, value); + } + + /// `getCacheDuration`, in milliseconds. + pub fn cache_duration_ms(&self) -> u64 { + settings::cache_duration_ms(&self.settings) + } + + pub fn language(&self) -> Locale { + self.settings.language + } + + // -------------------------------------------------------- folders + + pub fn folders(&self) -> &FolderStore { + &self.folders + } + + fn write_folders(&mut self) { + self.folders.version = folders::CURRENT_VERSION; + let value = self.folders.to_value(); + self.write(CLIENT_STORE_FOLDERS, value); + } + + pub fn items_in_folder(&self, folder_id: Option<&str>, platform: &str) -> &[ItemRef] { + self.folders.items_in_folder(folder_id, platform) + } + + pub fn root_sections(&self, platform: &str) -> Vec { + self.folders.root_sections(platform) + } + + pub fn create_folder( + &mut self, + name: &str, + parent_id: Option<&str>, + platform: &str, + ) -> FolderInfo { + let folder = + self.folders + .create_folder(folders::generate_folder_id(), name, parent_id, platform); + self.write_folders(); + folder + } + + pub fn sync_accounts(&mut self, account_ids: &[String], platform: &str) { + if self.folders.sync_accounts(account_ids, platform) { + self.write_folders(); + } + } + + pub fn move_item( + &mut self, + item: &ItemRef, + from_folder_id: Option<&str>, + to_folder_id: Option<&str>, + platform: &str, + insert_index: Option, + ) { + if self + .folders + .move_item(item, from_folder_id, to_folder_id, platform, insert_index) + { + self.write_folders(); + } + } + + pub fn reorder_items(&mut self, folder_id: Option<&str>, platform: &str, items: Vec) { + self.folders.reorder_items(folder_id, platform, items); + self.write_folders(); + } + + pub fn delete_folder(&mut self, folder_id: &str) { + if self.folders.delete_folder(folder_id) { + self.write_folders(); + } + } + + pub fn rename_folder(&mut self, folder_id: &str, new_name: &str) { + if self.folders.rename_folder(folder_id, new_name) { + self.write_folders(); + } + } + + // -------------------------------------------------------- personas + + /// `getPersonas`. + pub fn personas(&self) -> &[Persona] { + &self.personas + } + + fn persist_personas(&mut self, list: Vec) { + let value = personas::personas_to_value(&list); + let sanitized = personas::sanitize_personas(&value, &self.ctx); + let value = personas::personas_to_value(&sanitized); + self.personas = sanitized; + self.write(CLIENT_STORE_PERSONAS, value); + } + + /// `createPersona`: returns the persona as given, with its new id. + pub fn create_persona(&mut self, input: PersonaInput) -> Persona { + let persona = Persona { + id: personas::generate_persona_id(), + name: input.name, + color: input.color, + image: input.image, + assignments: input.assignments, + }; + let mut list = self.personas.clone(); + list.push(persona.clone()); + self.persist_personas(list); + persona + } + + /// `updatePersona`. + pub fn update_persona(&mut self, id: &str, patch: &PersonaPatch) { + let list = self + .personas + .iter() + .cloned() + .map(|mut persona| { + if persona.id == id { + persona.apply(patch); + } + persona + }) + .collect(); + self.persist_personas(list); + } + + /// `deletePersona`. + pub fn delete_persona(&mut self, id: &str) { + let list = self + .personas + .iter() + .filter(|persona| persona.id != id) + .cloned() + .collect(); + self.persist_personas(list); + } + + // -------------------------------------------------------- string maps + + fn write_map(&mut self, which: StoreId) { + let value = match which { + CLIENT_STORE_ACCOUNT_CARD_NOTES => self.account_card_notes.to_value(), + CLIENT_STORE_ACCOUNT_CARD_COLORS => self.account_card_colors.to_value(), + _ => self.folder_card_colors.to_value(), + }; + self.write(which, value); + } + + pub fn account_card_note(&self, account_id: &str) -> &str { + self.account_card_notes.get(account_id) + } + + pub fn account_card_notes(&self) -> &StringMapStore { + &self.account_card_notes + } + + pub fn set_account_card_note(&mut self, account_id: &str, note: &str) { + self.account_card_notes.set(account_id, note); + self.write_map(CLIENT_STORE_ACCOUNT_CARD_NOTES); + } + + pub fn clear_account_card_note(&mut self, account_id: &str) { + self.account_card_notes.remove(account_id); + self.write_map(CLIENT_STORE_ACCOUNT_CARD_NOTES); + } + + pub fn account_card_color(&self, account_id: &str) -> &str { + self.account_card_colors.get(account_id) + } + + pub fn set_account_card_color(&mut self, account_id: &str, color: &str) { + self.account_card_colors.set(account_id, color); + self.write_map(CLIENT_STORE_ACCOUNT_CARD_COLORS); + } + + /// `setAccountCardColors`: one write for every id. + pub fn set_account_card_colors(&mut self, account_ids: &[String], color: &str) { + if self.account_card_colors.set_many(account_ids, color) { + self.write_map(CLIENT_STORE_ACCOUNT_CARD_COLORS); + } + } + + pub fn folder_card_color(&self, folder_id: &str) -> &str { + self.folder_card_colors.get(folder_id) + } + + pub fn set_folder_card_color(&mut self, folder_id: &str, color: &str) { + self.folder_card_colors.set(folder_id, color); + self.write_map(CLIENT_STORE_FOLDER_CARD_COLORS); + } + + // -------------------------------------------------------- default game + + /// `getDefaultGame`. + pub fn default_game(&self, steam_id: &str) -> Option<&DefaultGame> { + self.default_games.get(steam_id) + } + + fn write_default_games(&mut self) { + let value = serde_json::to_value(&self.default_games).unwrap_or(Value::Null); + self.write(CLIENT_STORE_ACCOUNT_DEFAULT_GAME, value); + } + + /// `setDefaultGame`: ignored unless the app id is numeric. + pub fn set_default_game(&mut self, steam_id: &str, game: &DefaultGame) { + let app_id = js::trim(&game.app_id); + if !js::is_ascii_digits(app_id) { + return; + } + self.default_games.insert( + steam_id.to_string(), + DefaultGame { + app_id: app_id.to_string(), + name: game.name.clone(), + }, + ); + self.write_default_games(); + } + + /// `clearDefaultGame`: no write when there is none. + pub fn clear_default_game(&mut self, steam_id: &str) { + if self.default_games.remove(steam_id).is_some() { + self.write_default_games(); + } + } + + // -------------------------------------------------------- view mode + + pub fn view_mode(&self) -> ViewMode { + self.view_mode + } + + pub fn set_view_mode(&mut self, mode: ViewMode) { + self.view_mode = mode; + self.write( + CLIENT_STORE_VIEW_MODE, + Value::String(mode.as_str().to_string()), + ); + } +} diff --git a/crates/accshift-app/tests/client_storage.rs b/crates/accshift-app/tests/client_storage.rs new file mode 100644 index 0000000..46a3143 --- /dev/null +++ b/crates/accshift-app/tests/client_storage.rs @@ -0,0 +1,359 @@ +//! The TS tests of the ported client storage layer +//! (`src/lib/storage/clientStorage*.test.ts`), as Rust tests. The debounce +//! timers and in-flight promises of TS become explicit steps: a write is +//! pending until `take_*_saves` hands it out, in flight until `finish_save`. + +use accshift_app::mock::MockBackend; +use accshift_app::storage::{ + initialize, ClientStorage, LegacyLocalStorage, CLIENT_STORE_FOLDERS, CLIENT_STORE_SETTINGS, + SAVE_DEBOUNCE, +}; +use accshift_app::types::{ClientStorageSnapshot, StorageManifest}; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +fn manifest(entries: &[(&str, &str)]) -> StorageManifest { + StorageManifest { + schema_version: 1, + stores: entries + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(), + } +} + +fn snapshot(entries: &[(&str, &str)], stores: Value) -> ClientStorageSnapshot { + ClientStorageSnapshot { + manifest: manifest(entries), + stores: serde_json::from_value(stores).unwrap(), + } +} + +/// Every store `{}`, so nothing looks missing (the TS boot payload). +fn booted() -> ClientStorage { + let stores: BTreeMap = accshift_app::storage::CLIENT_STORE_IDS + .iter() + .map(|id| (id.to_string(), json!({}))) + .collect(); + ClientStorage::from_snapshot(ClientStorageSnapshot { + manifest: manifest(&[]), + stores, + }) +} + +fn later() -> Instant { + Instant::now() + SAVE_DEBOUNCE + Duration::from_millis(1) +} + +// ------------------------------------------------------------ flushPendingSaves + +#[test] +fn debounces_a_save_instead_of_persisting_immediately() { + let mut storage = booted(); + storage.set(CLIENT_STORE_FOLDERS, json!({ "a": 1 })); + assert_eq!(storage.get(CLIENT_STORE_FOLDERS), Some(&json!({ "a": 1 }))); + assert!(storage.take_due_saves(Instant::now()).is_empty()); + assert!(storage.has_pending_save(CLIENT_STORE_FOLDERS)); +} + +#[test] +fn flush_persists_pending_stores_immediately_and_cancels_the_timer() { + let mut storage = booted(); + storage.set(CLIENT_STORE_FOLDERS, json!({ "folders": ["x"] })); + let saves = storage.take_all_saves(); + assert_eq!(saves.len(), 1); + assert_eq!(saves[0].store_id, CLIENT_STORE_FOLDERS); + assert_eq!(saves[0].value, json!({ "folders": ["x"] })); + storage.finish_save(CLIENT_STORE_FOLDERS, &Ok(None)); + // The debounce is gone: past it, nothing more is due. + assert!(storage + .take_due_saves(later() + Duration::from_secs(1)) + .is_empty()); +} + +#[test] +fn flush_is_a_no_op_when_nothing_is_pending() { + let mut storage = booted(); + assert!(storage.take_all_saves().is_empty()); + assert!(!storage.has_unsaved()); +} + +#[test] +fn flush_waits_for_a_save_already_started_by_the_timer() { + let mut storage = booted(); + storage.set(CLIENT_STORE_FOLDERS, json!({ "folders": ["slow"] })); + let started = storage.take_due_saves(later()); + assert_eq!(started.len(), 1); + // Flushing hands out nothing new while that save is in flight, and the + // storage still owes it to disk. + assert!(storage.take_all_saves().is_empty()); + assert!(storage.has_unsaved()); + storage.finish_save(CLIENT_STORE_FOLDERS, &Ok(None)); + assert!(!storage.has_unsaved()); +} + +#[test] +fn marks_its_own_write_as_seen_so_the_next_focus_finds_no_change() { + let mut storage = booted(); + storage.set(CLIENT_STORE_FOLDERS, json!({ "folders": ["x"] })); + for save in storage.take_all_saves() { + storage.finish_save(save.store_id, &Ok(Some("file:42:4242".to_string()))); + } + let next = manifest(&[(CLIENT_STORE_FOLDERS, "file:42:4242")]); + assert_eq!(storage.begin_refresh(&next), None); +} + +// ------------------------------------------------------------ external refresh + +/// Both stores recorded as seen, like the TS `beforeEach`. +fn seen() -> ClientStorage { + let mut storage = booted(); + storage.replace_manifest(manifest(&[ + (CLIENT_STORE_FOLDERS, "seen"), + (CLIENT_STORE_SETTINGS, "seen"), + ])); + storage +} + +fn refresh( + storage: &mut ClientStorage, + next: &[(&str, &str)], + snap: ClientStorageSnapshot, +) -> Vec { + match storage.begin_refresh(&manifest(next)) { + None => Vec::new(), + Some(plan) if !plan.needs_snapshot() => plan.other_targets, + Some(plan) => storage.finish_refresh(plan, snap), + } +} + +#[test] +fn keeps_an_edit_still_in_the_save_debounce_and_saves_it_afterwards() { + let mut storage = seen(); + storage.set(CLIENT_STORE_FOLDERS, json!({ "folders": ["local"] })); + let external = [ + (CLIENT_STORE_FOLDERS, "external"), + (CLIENT_STORE_SETTINGS, "seen"), + ]; + refresh( + &mut storage, + &external, + snapshot( + &external, + json!({ CLIENT_STORE_FOLDERS: { "folders": ["external"] } }), + ), + ); + assert_eq!( + storage.get(CLIENT_STORE_FOLDERS), + Some(&json!({ "folders": ["local"] })) + ); + let saves = storage.take_due_saves(later()); + assert_eq!(saves.len(), 1); + assert_eq!(saves[0].value, json!({ "folders": ["local"] })); +} + +#[test] +fn a_snapshot_already_loading_does_not_replace_a_save_that_finished() { + let mut storage = seen(); + let external = [ + (CLIENT_STORE_FOLDERS, "external"), + (CLIENT_STORE_SETTINGS, "seen"), + ]; + let plan = storage.begin_refresh(&manifest(&external)).expect("plan"); + // While the snapshot is in flight, the user edits and the save lands. + storage.set(CLIENT_STORE_FOLDERS, json!({ "folders": ["local"] })); + let saves = storage.take_due_saves(later()); + assert_eq!(saves[0].value, json!({ "folders": ["local"] })); + storage.finish_save(CLIENT_STORE_FOLDERS, &Ok(Some("local:1".to_string()))); + storage.finish_refresh( + plan, + snapshot( + &external, + json!({ CLIENT_STORE_FOLDERS: { "folders": ["stale"] } }), + ), + ); + assert_eq!( + storage.get(CLIENT_STORE_FOLDERS), + Some(&json!({ "folders": ["local"] })) + ); +} + +#[test] +fn keeps_an_edit_whose_save_is_still_in_flight() { + let mut storage = seen(); + storage.set(CLIENT_STORE_FOLDERS, json!({ "folders": ["local"] })); + assert_eq!(storage.take_due_saves(later()).len(), 1); + let external = [ + (CLIENT_STORE_FOLDERS, "external"), + (CLIENT_STORE_SETTINGS, "seen"), + ]; + refresh( + &mut storage, + &external, + snapshot( + &external, + json!({ CLIENT_STORE_FOLDERS: { "folders": ["external"] } }), + ), + ); + assert_eq!( + storage.get(CLIENT_STORE_FOLDERS), + Some(&json!({ "folders": ["local"] })) + ); + storage.finish_save(CLIENT_STORE_FOLDERS, &Ok(Some("local:slow".to_string()))); +} + +#[test] +fn still_applies_external_changes_to_stores_with_no_pending_save() { + let mut storage = seen(); + storage.set(CLIENT_STORE_FOLDERS, json!({ "folders": ["local"] })); + let external = [ + (CLIENT_STORE_FOLDERS, "external"), + (CLIENT_STORE_SETTINGS, "external"), + ]; + let changed = refresh( + &mut storage, + &external, + snapshot( + &external, + json!({ + CLIENT_STORE_FOLDERS: { "folders": ["external"] }, + CLIENT_STORE_SETTINGS: { "language": "fr" }, + }), + ), + ); + assert_eq!(changed, [CLIENT_STORE_SETTINGS]); + assert_eq!( + storage.get(CLIENT_STORE_SETTINGS), + Some(&json!({ "language": "fr" })) + ); + assert_eq!( + storage.get(CLIENT_STORE_FOLDERS), + Some(&json!({ "folders": ["local"] })) + ); +} + +#[test] +fn does_not_mark_a_store_as_seen_when_it_changed_after_the_manifest_read() { + let mut storage = seen(); + let first = [ + (CLIENT_STORE_FOLDERS, "external"), + (CLIENT_STORE_SETTINGS, "seen"), + ]; + let later_manifest = [ + (CLIENT_STORE_FOLDERS, "external"), + (CLIENT_STORE_SETTINGS, "later"), + ]; + let stores = json!({ + CLIENT_STORE_FOLDERS: { "folders": ["external"] }, + CLIENT_STORE_SETTINGS: { "language": "de" }, + }); + let changed = refresh( + &mut storage, + &first, + snapshot(&later_manifest, stores.clone()), + ); + assert_eq!(changed, [CLIENT_STORE_FOLDERS]); + let changed = refresh( + &mut storage, + &later_manifest, + snapshot(&later_manifest, stores), + ); + assert_eq!(changed, [CLIENT_STORE_SETTINGS]); + assert_eq!( + storage.get(CLIENT_STORE_SETTINGS), + Some(&json!({ "language": "de" })) + ); +} + +// ------------------------------------------------------------ onClientStoreChange + +#[test] +fn a_write_changes_its_own_store_revision_only() { + // TS tells listeners; here a reader compares revisions. + let mut storage = booted(); + let settings = storage.revision(CLIENT_STORE_SETTINGS); + storage.set(CLIENT_STORE_FOLDERS, json!({ "a": 1 })); + assert_eq!(storage.revision(CLIENT_STORE_SETTINGS), settings); + storage.set(CLIENT_STORE_SETTINGS, json!({ "uiScalePercent": 110 })); + assert_eq!(storage.revision(CLIENT_STORE_SETTINGS), settings + 1); +} + +// ------------------------------------------------------------ legacy migration + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .unwrap() + .to_path_buf() +} + +fn mock() -> MockBackend { + MockBackend::load(&MockBackend::data_dir(&repo_root()), "empty", None).unwrap() +} + +fn legacy() -> LegacyLocalStorage { + BTreeMap::from([( + "accshift_folders".to_string(), + json!({ "folders": [{ "id": "real", "name": "Real folder" }] }).to_string(), + )]) +} + +/// No store at all: every store is a migration candidate. +fn empty_snapshot() -> ClientStorageSnapshot { + snapshot(&[], json!({})) +} + +#[test] +fn migrates_legacy_keys_against_the_real_backend() { + let (storage, report) = initialize(&mock(), Some(empty_snapshot()), Some(&legacy()), false); + assert_eq!( + storage.get(CLIENT_STORE_FOLDERS), + Some(&json!({ "folders": [{ "id": "real", "name": "Real folder" }] })) + ); + assert_eq!(report.migrated, [CLIENT_STORE_FOLDERS]); + assert!(!storage.has_unsaved()); +} + +#[test] +fn leaves_the_store_files_alone_when_the_snapshot_cannot_be_read() { + let backend = mock(); + assert!(backend.fail( + "load_client_storage_snapshot", + Some("Could not parse JSON folders.json") + )); + let (storage, report) = initialize(&backend, None, Some(&legacy()), false); + assert!(report.load_error.is_some()); + assert!(report.migrated.is_empty()); + assert_eq!(storage.get(CLIENT_STORE_FOLDERS), None); + assert!(!storage.has_unsaved()); +} + +#[test] +fn never_reads_legacy_keys_under_the_mock_backend() { + let (storage, report) = initialize(&mock(), Some(empty_snapshot()), Some(&legacy()), true); + assert!(report.migrated.is_empty()); + assert_eq!(storage.get(CLIENT_STORE_FOLDERS), None); + assert!(!storage.has_unsaved()); +} + +#[test] +fn refresh_blocking_reads_through_the_backend() { + // The empty scenario's manifest has no entry, so a storage that recorded + // one sees it as removed and reloads that store from the snapshot. + let backend = mock(); + let mut storage = booted(); + storage.replace_manifest(manifest(&[(CLIENT_STORE_FOLDERS, "old")])); + let changed = storage.refresh_blocking(&backend).unwrap(); + assert_eq!(changed, [CLIENT_STORE_FOLDERS]); + assert_eq!( + storage.get(CLIENT_STORE_FOLDERS), + Some(&json!({ "version": 1, "folders": [], "itemOrder": {} })) + ); + assert_eq!( + storage.refresh_blocking(&backend).unwrap(), + Vec::::new() + ); +} diff --git a/crates/accshift-app/tests/data/stores/context.json b/crates/accshift-app/tests/data/stores/context.json new file mode 100644 index 0000000..3e0d73e --- /dev/null +++ b/crates/accshift-app/tests/data/stores/context.json @@ -0,0 +1,15 @@ +{ + "platformIds": [ + "steam", + "riot", + "battle-net", + "ubisoft", + "roblox", + "epic", + "gog", + "jagex", + "discord" + ], + "themeIds": ["dark", "light", "midnight", "glass-dark", "glass-light", "liquid-glass"], + "preferredLanguages": ["fr-FR"] +} diff --git a/crates/accshift-app/tests/data/stores/roundtrip.json b/crates/accshift-app/tests/data/stores/roundtrip.json new file mode 100644 index 0000000..462c5a6 --- /dev/null +++ b/crates/accshift-app/tests/data/stores/roundtrip.json @@ -0,0 +1,3563 @@ +[ + { + "name": "demo client.settings", + "storeId": "client.settings", + "input": { + "language": "en", + "animations": "on", + "streamerMode": "off", + "inactivityBlurSeconds": 0, + "pinEnabled": false, + "themeId": "glass-dark", + "enabledPlatforms": ["steam", "riot", "roblox"] + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "en", + "themeId": "glass-dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "on", + "streamerMode": "off", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam", "riot", "roblox"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 0, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "demo client.folders", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "account", + "id": "76561198000000017" + } + ] + } + }, + "op": { + "kind": "reorderRoot", + "platform": "steam" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "account", + "id": "76561198000000017" + } + ] + } + } + }, + { + "name": "demo client.personas", + "storeId": "client.personas", + "input": [], + "op": { + "kind": "deletePersona", + "id": "__none__" + }, + "wrote": true, + "expected": [] + }, + { + "name": "demo client.account-card-notes", + "storeId": "client.account-card-notes", + "input": {}, + "op": { + "kind": "clearNote", + "id": "__none__" + }, + "wrote": true, + "expected": {} + }, + { + "name": "demo client.account-card-colors", + "storeId": "client.account-card-colors", + "input": { + "76561198000000001": "#8b5cf6", + "76561198000000005": "#f97316", + "76561198000000009": "#ec4899" + }, + "op": { + "kind": "setColor", + "id": "__none__", + "color": "" + }, + "wrote": true, + "expected": { + "76561198000000001": "#8b5cf6", + "76561198000000005": "#f97316", + "76561198000000009": "#ec4899" + } + }, + { + "name": "demo client.account-default-game", + "storeId": "client.account-default-game", + "input": {}, + "op": { + "kind": "setDefaultGame", + "id": "__roundtrip__", + "game": { + "appId": " 730 ", + "name": "Counter-Strike 2" + } + }, + "wrote": true, + "expected": { + "__roundtrip__": { + "appId": "730", + "name": "Counter-Strike 2" + } + } + }, + { + "name": "demo client.folder-card-colors", + "storeId": "client.folder-card-colors", + "input": {}, + "op": { + "kind": "setFolderColor", + "id": "__none__", + "color": "" + }, + "wrote": true, + "expected": {} + }, + { + "name": "demo client.view-mode", + "storeId": "client.view-mode", + "input": "grid", + "op": { + "kind": "resaveViewMode" + }, + "wrote": true, + "expected": "grid" + }, + { + "name": "dev client.settings", + "storeId": "client.settings", + "input": { + "language": "en", + "animations": "on", + "streamerMode": "off", + "inactivityBlurSeconds": 0, + "pinEnabled": false, + "enabledPlatforms": ["steam", "riot"], + "accountDisplay": { + "showUsernames": true, + "showCardNotesInline": true + } + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "en", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "on", + "streamerMode": "off", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam", "riot"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 0, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": true, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "dev client.folders", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "dev-folder-outer", + "name": "Outer folder", + "parentId": null, + "platform": "steam" + }, + { + "id": "dev-folder-inner", + "name": "Inner folder", + "parentId": "dev-folder-outer", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198100000001" + }, + { + "type": "account", + "id": "76561198100000002" + }, + { + "type": "account", + "id": "76561198100000003" + }, + { + "type": "account", + "id": "76561198100000004" + }, + { + "type": "account", + "id": "76561198100000005" + }, + { + "type": "account", + "id": "76561198100000006" + }, + { + "type": "folder", + "id": "dev-folder-outer" + } + ], + "dev-folder-outer": [ + { + "type": "account", + "id": "76561198100000007" + }, + { + "type": "folder", + "id": "dev-folder-inner" + } + ], + "dev-folder-inner": [ + { + "type": "account", + "id": "76561198100000008" + } + ] + } + }, + "op": { + "kind": "reorderRoot", + "platform": "steam" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [ + { + "id": "dev-folder-outer", + "name": "Outer folder", + "parentId": null, + "platform": "steam" + }, + { + "id": "dev-folder-inner", + "name": "Inner folder", + "parentId": "dev-folder-outer", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198100000001" + }, + { + "type": "account", + "id": "76561198100000002" + }, + { + "type": "account", + "id": "76561198100000003" + }, + { + "type": "account", + "id": "76561198100000004" + }, + { + "type": "account", + "id": "76561198100000005" + }, + { + "type": "account", + "id": "76561198100000006" + }, + { + "type": "folder", + "id": "dev-folder-outer" + } + ], + "dev-folder-outer": [ + { + "type": "account", + "id": "76561198100000007" + }, + { + "type": "folder", + "id": "dev-folder-inner" + } + ], + "dev-folder-inner": [ + { + "type": "account", + "id": "76561198100000008" + } + ] + } + } + }, + { + "name": "dev client.account-card-notes", + "storeId": "client.account-card-notes", + "input": { + "76561198100000001": "short note", + "76561198100000005": "a note that runs long enough to test wrapping and truncation on an inline card note" + }, + "op": { + "kind": "clearNote", + "id": "__none__" + }, + "wrote": true, + "expected": { + "76561198100000001": "short note", + "76561198100000005": "a note that runs long enough to test wrapping and truncation on an inline card note" + } + }, + { + "name": "dev client.account-card-colors", + "storeId": "client.account-card-colors", + "input": { + "76561198100000001": "#3b82f6", + "76561198100000003": "#f43f5e", + "76561198100000005": "#10b981" + }, + "op": { + "kind": "setColor", + "id": "__none__", + "color": "" + }, + "wrote": true, + "expected": { + "76561198100000001": "#3b82f6", + "76561198100000003": "#f43f5e", + "76561198100000005": "#10b981" + } + }, + { + "name": "empty client.settings", + "storeId": "client.settings", + "input": { + "language": "en", + "animations": "on", + "streamerMode": "off", + "inactivityBlurSeconds": 0, + "pinEnabled": false, + "enabledPlatforms": ["steam"] + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "en", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "on", + "streamerMode": "off", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 0, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "empty client.folders", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [], + "itemOrder": {} + }, + "op": { + "kind": "reorderRoot", + "platform": "steam" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [], + "itemOrder": { + "root:steam": [] + } + } + }, + { + "name": "empty client.account-card-colors", + "storeId": "client.account-card-colors", + "input": {}, + "op": { + "kind": "setColor", + "id": "__none__", + "color": "" + }, + "wrote": true, + "expected": {} + }, + { + "name": "missing client.settings", + "storeId": "client.settings", + "input": null, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "fr", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "system", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 60, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "missing client.folders", + "storeId": "client.folders", + "input": null, + "op": { + "kind": "reorderRoot", + "platform": "steam" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [], + "itemOrder": { + "root:steam": [] + } + } + }, + { + "name": "missing client.personas", + "storeId": "client.personas", + "input": null, + "op": { + "kind": "deletePersona", + "id": "__none__" + }, + "wrote": true, + "expected": [] + }, + { + "name": "missing client.account-card-notes", + "storeId": "client.account-card-notes", + "input": null, + "op": { + "kind": "clearNote", + "id": "__none__" + }, + "wrote": true, + "expected": {} + }, + { + "name": "missing client.account-card-colors", + "storeId": "client.account-card-colors", + "input": null, + "op": { + "kind": "setColor", + "id": "__none__", + "color": "" + }, + "wrote": true, + "expected": {} + }, + { + "name": "missing client.account-default-game", + "storeId": "client.account-default-game", + "input": null, + "op": { + "kind": "setDefaultGame", + "id": "__roundtrip__", + "game": { + "appId": " 730 ", + "name": "Counter-Strike 2" + } + }, + "wrote": true, + "expected": { + "__roundtrip__": { + "appId": "730", + "name": "Counter-Strike 2" + } + } + }, + { + "name": "missing client.folder-card-colors", + "storeId": "client.folder-card-colors", + "input": null, + "op": { + "kind": "setFolderColor", + "id": "__none__", + "color": "" + }, + "wrote": true, + "expected": {} + }, + { + "name": "missing client.view-mode", + "storeId": "client.view-mode", + "input": null, + "op": { + "kind": "resaveViewMode" + }, + "wrote": true, + "expected": "grid" + }, + { + "name": "settings from settingsPatch test", + "storeId": "client.settings", + "input": { + "language": "en", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "system", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam", "riot"], + "healthCheckPerPlatform": {}, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 60, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": {}, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": true, + "pinHash": "legacy-hash" + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "en", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "system", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam", "riot"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 60, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "settings language only", + "storeId": "client.settings", + "input": { + "language": "fr" + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "fr", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "system", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 60, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "settings ui scale only", + "storeId": "client.settings", + "input": { + "uiScalePercent": 110 + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "fr", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 110, + "animations": "system", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 60, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "settings not an object", + "storeId": "client.settings", + "input": ["steam"], + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "fr", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "system", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 60, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "settings null language", + "storeId": "client.settings", + "input": { + "language": null + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "en", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "system", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 60, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "settings legacy flat keys", + "storeId": "client.settings", + "input": { + "theme": "light", + "avatarCacheDays": "30", + "banCheckDays": null, + "showUsernames": false, + "showCardNotesInline": 1, + "expandedFolders": "", + "steamRunAsAdmin": true, + "steamLaunchOptions": " -novid -console ", + "accountDisplay": { + "showLastLogin": true, + "showRiotLastLogin": 0 + } + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "fr", + "themeId": "light", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "system", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "dataRefresh": { + "avatarCacheDays": 30, + "banCheckDays": 0 + }, + "enabledPlatforms": ["steam"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 60, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": true, + "launchOptions": "-novid -console", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": false, + "showLastLoginPerPlatform": { + "steam": true, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": true, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "settings bad values", + "storeId": "client.settings", + "input": { + "language": "de", + "themeId": "nope", + "backgroundOpacity": 140.5, + "uiScalePercent": "abc", + "animations": "ON", + "streamerMode": "off", + "suspendGraphicsWhenMinimized": 0, + "minimizeOnAccountSwitch": "yes", + "dataRefresh": { + "avatarCacheDays": -3, + "banCheckDays": [45] + }, + "enabledPlatforms": ["riot", "riot", 42, "nope", "steam"], + "defaultPlatformId": "roblox", + "healthCheckPerPlatform": { + "roblox": null + }, + "inactivityBlurSeconds": 1000000000, + "deepLinksEnabled": null, + "cliEnabled": false, + "personasEnabled": false, + "pinEnabled": true, + "pinHash": " AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB ", + "platformSettings": { + "steam": { + "runAsAdmin": "", + "launchOptions": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "shutdownMode": "force" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": 1, + "discord": false, + "ghost": true + }, + "showCardNotesInline": null, + "expandedFolders": true + } + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "en", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "system", + "streamerMode": "off", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": true, + "dataRefresh": { + "avatarCacheDays": 0, + "banCheckDays": 45 + }, + "enabledPlatforms": ["riot", "steam"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": false, + "defaultPlatformId": "riot", + "inactivityBlurSeconds": 3600, + "deepLinksEnabled": true, + "cliEnabled": false, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "shutdownMode": "force" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": true, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": false + }, + "showCardNotesInline": false, + "expandedFolders": true + }, + "pinEnabled": true, + "pinHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + }, + { + "name": "settings pin without hash", + "storeId": "client.settings", + "input": { + "pinEnabled": true, + "pinHash": "1234" + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "fr", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "system", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 60, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "settings legacy pin hash", + "storeId": "client.settings", + "input": { + "pinEnabled": 1, + "pinHash": "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC" + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "fr", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "system", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 60, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": true, + "pinHash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } + }, + { + "name": "settings hash without pin", + "storeId": "client.settings", + "input": { + "pinEnabled": false, + "pinHash": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "fr", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "system", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 60, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "settings empty platforms", + "storeId": "client.settings", + "input": { + "enabledPlatforms": [], + "defaultPlatformId": 5, + "backgroundOpacity": " 42.5 ", + "uiScalePercent": "0x80", + "inactivityBlurSeconds": [], + "themeId": "", + "theme": "light" + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "fr", + "themeId": "dark", + "backgroundOpacity": 43, + "uiScalePercent": 128, + "animations": "system", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "dataRefresh": { + "avatarCacheDays": 7, + "banCheckDays": 7 + }, + "enabledPlatforms": ["steam"], + "healthCheckPerPlatform": { + "roblox": true + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 0, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "settings custom theme id unknown here", + "storeId": "client.settings", + "input": { + "themeId": "midnight", + "animations": "off", + "dataRefresh": { + "avatarCacheDays": 0, + "banCheckDays": 90.5 + }, + "healthCheckPerPlatform": { + "roblox": false + }, + "platformSettings": { + "steam": "broken" + } + }, + "op": { + "kind": "saveSettings" + }, + "wrote": true, + "expected": { + "language": "fr", + "themeId": "midnight", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "off", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "dataRefresh": { + "avatarCacheDays": 0, + "banCheckDays": 90 + }, + "enabledPlatforms": ["steam"], + "healthCheckPerPlatform": { + "roblox": false + }, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 60, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { + "runAsAdmin": false, + "launchOptions": "", + "shutdownMode": "graceful" + } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": { + "steam": false, + "riot": false, + "battle-net": false, + "ubisoft": true, + "roblox": true, + "epic": true, + "gog": true, + "jagex": true, + "discord": true + }, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": false, + "pinHash": "" + } + }, + { + "name": "folders ghost bucket", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "live", + "name": "Live", + "parentId": null, + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "live" + } + ], + "live": [ + { + "type": "account", + "id": "account-1" + } + ], + "ghost": [ + { + "type": "account", + "id": "account-2" + } + ] + } + }, + "op": { + "kind": "reorderRoot", + "platform": "steam" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [ + { + "id": "live", + "name": "Live", + "parentId": null, + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "live" + } + ], + "live": [ + { + "type": "account", + "id": "account-1" + } + ] + } + } + }, + { + "name": "folders root buckets of two platforms", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "account-1" + } + ], + "root:riot": [ + { + "type": "account", + "id": "account-2" + } + ] + } + }, + "op": { + "kind": "reorderRoot", + "platform": "steam" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "account-1" + } + ], + "root:riot": [ + { + "type": "account", + "id": "account-2" + } + ] + } + } + }, + { + "name": "folders bare root prefix", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [], + "itemOrder": { + "root:": [ + { + "type": "account", + "id": "account-1" + } + ] + } + }, + "op": { + "kind": "reorderRoot", + "platform": "steam" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [], + "itemOrder": { + "root:steam": [] + } + } + }, + { + "name": "folders move root account into folder", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + }, + "op": { + "kind": "moveItem", + "item": { + "type": "account", + "id": "at-root" + }, + "from": null, + "to": "old", + "platform": "steam" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + }, + { + "type": "account", + "id": "at-root" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + } + }, + { + "name": "folders move account back to root", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + }, + "op": { + "kind": "moveItem", + "item": { + "type": "account", + "id": "in-old" + }, + "from": "old", + "to": null, + "platform": "steam" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + }, + { + "type": "account", + "id": "in-old" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + } + }, + { + "name": "folders move at index", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + }, + "op": { + "kind": "moveItem", + "item": { + "type": "account", + "id": "at-root" + }, + "from": null, + "to": "smurfs", + "platform": "steam", + "index": 0 + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + } + ], + "smurfs": [ + { + "type": "account", + "id": "at-root" + }, + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + } + }, + { + "name": "folders move folder into its own subtree", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + }, + "op": { + "kind": "moveItem", + "item": { + "type": "folder", + "id": "smurfs" + }, + "from": null, + "to": "old", + "platform": "steam" + }, + "wrote": false, + "expected": null + }, + { + "name": "folders move folder to riot root", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + }, + "op": { + "kind": "moveItem", + "item": { + "type": "folder", + "id": "old" + }, + "from": "smurfs", + "to": null, + "platform": "steam" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": null, + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + }, + { + "type": "folder", + "id": "old" + } + ], + "smurfs": [ + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + } + }, + { + "name": "folders delete nested folder tree", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + }, + "op": { + "kind": "deleteFolder", + "id": "smurfs" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [ + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "in-old" + }, + { + "type": "account", + "id": "in-smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + } + }, + { + "name": "folders delete missing folder", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + }, + "op": { + "kind": "deleteFolder", + "id": "nope" + }, + "wrote": false, + "expected": null + }, + { + "name": "folders rename", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + }, + "op": { + "kind": "renameFolder", + "id": "old", + "name": "Older" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Older", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + } + }, + { + "name": "folders sync accounts", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + }, + "op": { + "kind": "syncAccounts", + "ids": ["at-root", "in-old", "new-1", "new-2"], + "platform": "steam" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + }, + { + "type": "account", + "id": "new-1" + }, + { + "type": "account", + "id": "new-2" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + } + }, + { + "name": "folders sync unchanged", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + }, + "op": { + "kind": "syncAccounts", + "ids": ["at-root", "in-smurfs", "in-old"], + "platform": "steam" + }, + "wrote": false, + "expected": null + }, + { + "name": "folders sync new platform", + "storeId": "client.folders", + "input": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ] + } + }, + "op": { + "kind": "syncAccounts", + "ids": ["epic-1"], + "platform": "epic" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [ + { + "id": "smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "old", + "name": "Old", + "parentId": "smurfs", + "platform": "steam" + }, + { + "id": "riot-folder", + "name": "Ranked", + "parentId": null, + "platform": "riot" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "smurfs" + }, + { + "type": "account", + "id": "at-root" + } + ], + "smurfs": [ + { + "type": "folder", + "id": "old" + }, + { + "type": "account", + "id": "in-smurfs" + } + ], + "old": [ + { + "type": "account", + "id": "in-old" + } + ], + "root:riot": [ + { + "type": "folder", + "id": "riot-folder" + } + ], + "root:epic": [ + { + "type": "account", + "id": "epic-1" + } + ] + } + } + }, + { + "name": "folders bad values", + "storeId": "client.folders", + "input": { + "folders": [ + { + "id": " a ", + "name": " A ", + "parentId": " ", + "platform": " steam " + }, + { + "id": "b", + "name": "", + "platform": "steam" + }, + { + "id": "c", + "name": "C", + "parentId": 5, + "platform": "steam" + }, + "junk", + null + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "a" + }, + { + "type": "folder", + "id": "b" + }, + { + "type": "account", + "id": " x " + }, + { + "type": "account", + "id": "" + }, + { + "type": "user", + "id": "y" + }, + null + ], + "a": "not a list", + "c": [ + { + "type": "folder", + "id": "c" + } + ], + " ": [ + { + "type": "account", + "id": "z" + } + ] + } + }, + "op": { + "kind": "reorderRoot", + "platform": "steam" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [ + { + "id": "a", + "name": "A", + "parentId": null, + "platform": "steam" + }, + { + "id": "c", + "name": "C", + "parentId": null, + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "folder", + "id": "a" + }, + { + "type": "account", + "id": "x" + } + ], + "c": [ + { + "type": "folder", + "id": "c" + } + ] + } + } + }, + { + "name": "folders not an object", + "storeId": "client.folders", + "input": [1, 2], + "op": { + "kind": "reorderRoot", + "platform": "steam" + }, + "wrote": true, + "expected": { + "version": 1, + "folders": [], + "itemOrder": { + "root:steam": [] + } + } + }, + { + "name": "personas bad values", + "storeId": "client.personas", + "input": [ + { + "id": "p1", + "name": " Big brother ", + "color": "#A1b2C3", + "image": "data:image/png;base64,AAAA", + "assignments": [ + { + "platformId": "steam", + "accountId": "s1" + }, + { + "platformId": "steam", + "accountId": "s2" + }, + { + "platformId": "ghost", + "accountId": "g1" + }, + { + "platformId": "riot", + "accountId": "" + }, + { + "platformId": "riot", + "accountId": 7 + }, + { + "platformId": "discord", + "accountId": "d1" + }, + [] + ] + }, + { + "id": "p1", + "name": "Duplicate" + }, + { + "id": "", + "name": "No id" + }, + { + "name": "No id" + }, + { + "id": "p2", + "name": " ", + "color": "#abc", + "image": "https://example.com/a.png", + "assignments": "nope" + }, + { + "id": "p3", + "name": "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn", + "color": 5, + "image": "data:image/png;aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + { + "id": "p4", + "name": "Emoji 😀 name", + "image": null + }, + "junk" + ], + "op": { + "kind": "deletePersona", + "id": "__none__" + }, + "wrote": true, + "expected": [ + { + "id": "p1", + "name": "Big brother", + "color": "#A1b2C3", + "image": "data:image/png;base64,AAAA", + "assignments": [ + { + "platformId": "steam", + "accountId": "s1" + }, + { + "platformId": "discord", + "accountId": "d1" + } + ] + }, + { + "id": "p2", + "name": "Persona", + "color": "", + "image": null, + "assignments": [] + }, + { + "id": "p3", + "name": "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn", + "color": "", + "image": null, + "assignments": [] + }, + { + "id": "p4", + "name": "Emoji 😀 name", + "color": "", + "image": null, + "assignments": [] + } + ] + }, + { + "name": "personas not a list", + "storeId": "client.personas", + "input": { + "id": "p1" + }, + "op": { + "kind": "deletePersona", + "id": "__none__" + }, + "wrote": true, + "expected": [] + }, + { + "name": "personas update", + "storeId": "client.personas", + "input": [ + { + "id": "p1", + "name": "One", + "color": "", + "image": null, + "assignments": [] + }, + { + "id": "p2", + "name": "Two", + "color": "", + "image": null, + "assignments": [] + } + ], + "op": { + "kind": "updatePersona", + "id": "p2", + "patch": { + "name": " Renamed ", + "color": "#123456", + "assignments": [ + { + "platformId": "roblox", + "accountId": "r1" + } + ] + } + }, + "wrote": true, + "expected": [ + { + "id": "p1", + "name": "One", + "color": "", + "image": null, + "assignments": [] + }, + { + "id": "p2", + "name": "Renamed", + "color": "#123456", + "image": null, + "assignments": [ + { + "platformId": "roblox", + "accountId": "r1" + } + ] + } + ] + }, + { + "name": "personas delete", + "storeId": "client.personas", + "input": [ + { + "id": "p1", + "name": "One", + "color": "", + "image": null, + "assignments": [] + }, + { + "id": "p2", + "name": "Two", + "color": "", + "image": null, + "assignments": [] + } + ], + "op": { + "kind": "deletePersona", + "id": "p1" + }, + "wrote": true, + "expected": [ + { + "id": "p2", + "name": "Two", + "color": "", + "image": null, + "assignments": [] + } + ] + }, + { + "name": "notes bad values", + "storeId": "client.account-card-notes", + "input": { + "acc1": " hello ", + "acc2": "line1\u0000line2\u0001line3…end", + "acc3": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "acc4": " ", + "acc5": 5, + " ": "blank key", + "acc6": "😀 emoji" + }, + "op": { + "kind": "clearNote", + "id": "__none__" + }, + "wrote": true, + "expected": { + "acc1": "hello", + "acc2": "line1 line2 line3 end", + "acc3": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "acc6": "😀 emoji" + } + }, + { + "name": "notes set", + "storeId": "client.account-card-notes", + "input": { + "acc1": "keep" + }, + "op": { + "kind": "setNote", + "id": "acc2", + "note": " new\tnote " + }, + "wrote": true, + "expected": { + "acc1": "keep", + "acc2": "new note" + } + }, + { + "name": "notes set empty removes", + "storeId": "client.account-card-notes", + "input": { + "acc1": "keep", + "acc2": "gone" + }, + "op": { + "kind": "setNote", + "id": "acc2", + "note": " \u0000 " + }, + "wrote": true, + "expected": { + "acc1": "keep" + } + }, + { + "name": "colors bad values", + "storeId": "client.account-card-colors", + "input": { + "a": "#ff0000", + "b": "#abc", + "c": "#aabbccdd", + "d": "red", + "e": "#xyz", + "f": "#abcd", + "g": 5, + "": "#ffffff" + }, + "op": { + "kind": "setColor", + "id": "__none__", + "color": "" + }, + "wrote": true, + "expected": { + "a": "#ff0000", + "b": "#abc", + "c": "#aabbccdd" + } + }, + { + "name": "colors set many", + "storeId": "client.account-card-colors", + "input": { + "keep": "#000" + }, + "op": { + "kind": "setColors", + "ids": ["a", "b"], + "color": "#3b82f6" + }, + "wrote": true, + "expected": { + "keep": "#000", + "a": "#3b82f6", + "b": "#3b82f6" + } + }, + { + "name": "colors set many invalid removes", + "storeId": "client.account-card-colors", + "input": { + "a": "#000", + "b": "#111", + "c": "#222" + }, + "op": { + "kind": "setColors", + "ids": ["a", "b"], + "color": "bad" + }, + "wrote": true, + "expected": { + "c": "#222" + } + }, + { + "name": "colors set many empty list", + "storeId": "client.account-card-colors", + "input": { + "a": "#000" + }, + "op": { + "kind": "setColors", + "ids": [], + "color": "#fff" + }, + "wrote": false, + "expected": null + }, + { + "name": "folder colors bad values", + "storeId": "client.folder-card-colors", + "input": { + "f1": "#123456", + "f2": "blue" + }, + "op": { + "kind": "setFolderColor", + "id": "__none__", + "color": "" + }, + "wrote": true, + "expected": { + "f1": "#123456" + } + }, + { + "name": "default game bad values", + "storeId": "client.account-default-game", + "input": { + "s1": { + "appId": " 730 ", + "name": "CS2" + }, + "s2": { + "appId": "abc", + "name": "Bad" + }, + "s3": { + "appId": "570" + }, + "s4": { + "appId": "440", + "name": 5 + }, + "s5": [], + "s6": "730", + " ": { + "appId": "1", + "name": "blank key" + } + }, + "op": { + "kind": "setDefaultGame", + "id": "__roundtrip__", + "game": { + "appId": " 730 ", + "name": "Counter-Strike 2" + } + }, + "wrote": true, + "expected": { + "s1": { + "appId": "730", + "name": "CS2" + }, + "s3": { + "appId": "570", + "name": "" + }, + "s4": { + "appId": "440", + "name": "" + }, + "__roundtrip__": { + "appId": "730", + "name": "Counter-Strike 2" + } + } + }, + { + "name": "default game invalid set", + "storeId": "client.account-default-game", + "input": { + "s1": { + "appId": "730", + "name": "CS2" + } + }, + "op": { + "kind": "setDefaultGame", + "id": "s2", + "game": { + "appId": "x1", + "name": "Bad" + } + }, + "wrote": false, + "expected": null + }, + { + "name": "view mode list", + "storeId": "client.view-mode", + "input": "list", + "op": { + "kind": "resaveViewMode" + }, + "wrote": true, + "expected": "list" + }, + { + "name": "view mode other", + "storeId": "client.view-mode", + "input": 5, + "op": { + "kind": "resaveViewMode" + }, + "wrote": true, + "expected": "grid" + } +] diff --git a/crates/accshift-app/tests/feature_stores.rs b/crates/accshift-app/tests/feature_stores.rs new file mode 100644 index 0000000..f8a8d7b --- /dev/null +++ b/crates/accshift-app/tests/feature_stores.rs @@ -0,0 +1,668 @@ +//! The TS tests of the ported stores (settings, folders, personas, the +//! small shared stores), as Rust tests. + +use accshift_app::folders::{FolderStore, ItemRef}; +use accshift_app::personas::{persona_assignments_for_save, AssignmentsForSave, PersonaAssignment}; +use accshift_app::settings::{ + api_key_draft_after_successful_save, complete_onboarding_consent, diff_settings, + merge_settings_draft, AnimationsMode, AppSettings, OnboardingConsentOutcome, SettingsChange, + StreamerMode, +}; +use accshift_app::storage::{ + ClientStorage, CLIENT_STORE_ACCOUNT_CARD_COLORS, CLIENT_STORE_ACCOUNT_CARD_NOTES, + CLIENT_STORE_FOLDERS, +}; +use accshift_app::stores::{ + ClientStores, StoreContext, StringMapStore, ACCOUNT_CARD_COLOR_PRESETS, +}; +use accshift_app::types::ClientStorageSnapshot; +use serde_json::{json, Value}; +use std::collections::BTreeMap; + +// ------------------------------------------------------------ settingsPatch + +fn settings(edit: impl FnOnce(&mut Value)) -> AppSettings { + let mut value = json!({ + "language": "en", + "themeId": "dark", + "backgroundOpacity": 100, + "uiScalePercent": 100, + "animations": "system", + "streamerMode": "auto", + "suspendGraphicsWhenMinimized": true, + "minimizeOnAccountSwitch": false, + "dataRefresh": { "avatarCacheDays": 7, "banCheckDays": 7 }, + "enabledPlatforms": ["steam", "riot"], + "healthCheckPerPlatform": {}, + "personasEnabled": true, + "defaultPlatformId": "steam", + "inactivityBlurSeconds": 60, + "deepLinksEnabled": true, + "cliEnabled": true, + "platformSettings": { + "steam": { "runAsAdmin": false, "launchOptions": "", "shutdownMode": "graceful" } + }, + "accountDisplay": { + "showUsernames": true, + "showLastLoginPerPlatform": {}, + "showCardNotesInline": false, + "expandedFolders": false + }, + "pinEnabled": true, + "pinHash": "legacy-hash" + }); + edit(&mut value); + serde_json::from_value(value).unwrap() +} + +fn base() -> AppSettings { + settings(|_| {}) +} + +fn merge(current: &AppSettings, baseline: &AppSettings, draft: &AppSettings) -> AppSettings { + merge_settings_draft(current, baseline, draft).unwrap() +} + +#[test] +fn keeps_a_zoom_change_made_elsewhere_while_the_panel_was_open() { + let current = settings(|s| s["uiScalePercent"] = json!(120)); + let draft = settings(|s| s["minimizeOnAccountSwitch"] = json!(true)); + let merged = merge(¤t, &base(), &draft); + assert_eq!(merged.ui_scale_percent, 120); + assert!(merged.minimize_on_account_switch); +} + +#[test] +fn keeps_the_streamer_flag_and_a_rehashed_pin_written_elsewhere() { + let current = settings(|s| { + s["streamerMode"] = json!("off"); + s["pinHash"] = json!("upgraded-hash"); + }); + let draft = settings(|s| s["themeId"] = json!("light")); + let merged = merge(¤t, &base(), &draft); + assert_eq!(merged.streamer_mode, StreamerMode::Off); + assert_eq!(merged.pin_hash, "upgraded-hash"); + assert_eq!(merged.theme_id, "light"); +} + +#[test] +fn merges_nested_fields_one_leaf_at_a_time() { + let current = settings(|s| s["accountDisplay"]["expandedFolders"] = json!(true)); + let draft = settings(|s| s["accountDisplay"]["showUsernames"] = json!(false)); + let merged = merge(¤t, &base(), &draft); + assert!(merged.account_display.expanded_folders); + assert!(!merged.account_display.show_usernames); +} + +#[test] +fn replaces_an_edited_list_whole() { + let current = settings(|s| s["enabledPlatforms"] = json!(["steam", "riot", "roblox"])); + let draft = settings(|s| { + s["enabledPlatforms"] = json!(["riot"]); + s["defaultPlatformId"] = json!("riot"); + }); + let merged = merge(¤t, &base(), &draft); + assert_eq!(merged.enabled_platforms, ["riot"]); + assert_eq!(merged.default_platform_id, "riot"); +} + +#[test] +fn applies_the_users_own_edit_over_a_different_value_written_elsewhere() { + let merged = merge( + &settings(|s| s["uiScalePercent"] = json!(120)), + &base(), + &settings(|s| s["uiScalePercent"] = json!(90)), + ); + assert_eq!(merged.ui_scale_percent, 90); +} + +#[test] +fn leaves_the_current_settings_untouched() { + let current = base(); + merge( + ¤t, + &base(), + &settings(|s| s["language"] = json!("fr")), + ); + assert_eq!(current, base()); +} + +#[test] +fn rebases_an_open_panel_and_keeps_its_own_edits() { + let baseline = base(); + let draft = settings(|s| s["language"] = json!("fr")); + let stored = settings(|s| s["uiScalePercent"] = json!(110)); + let shown = merge(&stored, &baseline, &draft); + assert_eq!(shown.ui_scale_percent, 110); + assert_eq!(shown.language.code(), "fr"); + let saved = merge(&stored, &stored, &shown); + assert_eq!( + saved, + settings(|s| { + s["language"] = json!("fr"); + s["uiScalePercent"] = json!(110); + }) + ); + assert_eq!( + diff_settings(Some(&stored.to_value()), Some(&shown.to_value())), + [SettingsChange { + path: vec!["language".to_string()], + value: Some(json!("fr")), + }] + ); +} + +#[test] +fn diff_reports_nothing_when_the_draft_matches() { + assert!(diff_settings(Some(&base().to_value()), Some(&base().to_value())).is_empty()); +} + +#[test] +fn diff_reports_the_path_of_each_changed_leaf() { + let after = settings(|s| { + s["dataRefresh"] = json!({ "avatarCacheDays": 3, "banCheckDays": 7 }); + s["enabledPlatforms"] = json!(["steam"]); + }); + assert_eq!( + diff_settings(Some(&base().to_value()), Some(&after.to_value())), + [ + SettingsChange { + path: vec!["dataRefresh".into(), "avatarCacheDays".into()], + value: Some(json!(3)), + }, + SettingsChange { + path: vec!["enabledPlatforms".into()], + value: Some(json!(["steam"])), + }, + ] + ); +} + +// ------------------------------------------------------------ apiKeyDraft, onboardingConsent + +#[test] +fn clears_the_api_key_input_when_it_still_holds_the_saved_key() { + let draft = api_key_draft_after_successful_save(" steam-key ", "steam-key"); + assert_eq!(draft.api_key, ""); + assert!(!draft.touched); +} + +#[test] +fn keeps_a_replacement_key_typed_while_the_first_save_was_in_flight() { + let draft = api_key_draft_after_successful_save("next-key", "steam-key"); + assert_eq!(draft.api_key, "next-key"); + assert!(draft.touched); +} + +#[test] +fn onboarding_consent_returns_saved_when_persisted() { + assert_eq!( + complete_onboarding_consent(|| Ok::<(), String>(())), + OnboardingConsentOutcome::Saved + ); +} + +#[test] +fn onboarding_consent_returns_failed_without_panicking() { + assert_eq!( + complete_onboarding_consent(|| Err::<(), _>("disk")), + OnboardingConsentOutcome::Failed + ); +} + +// ------------------------------------------------------------ folders + +fn stores_with(store_id: &str, value: Value) -> ClientStores { + let snapshot = ClientStorageSnapshot { + manifest: Default::default(), + stores: BTreeMap::from([(store_id.to_string(), value)]), + }; + ClientStores::new( + ClientStorage::from_snapshot(snapshot), + StoreContext::shipped(), + ) +} + +fn saved(stores: &mut ClientStores, store_id: &str) -> Value { + stores + .take_all_saves() + .into_iter() + .find(|save| save.store_id == store_id) + .expect("saved") + .value +} + +#[test] +fn drops_an_item_order_bucket_keyed_by_a_folder_that_no_longer_exists() { + let mut stores = stores_with( + CLIENT_STORE_FOLDERS, + json!({ + "version": 1, + "folders": [{ "id": "live", "name": "Live", "parentId": null, "platform": "steam" }], + "itemOrder": { + "root:steam": [{ "type": "folder", "id": "live" }], + "live": [{ "type": "account", "id": "account-1" }], + "ghost": [{ "type": "account", "id": "account-2" }] + } + }), + ); + assert_eq!( + stores.items_in_folder(Some("live"), "steam"), + [ItemRef::account("account-1")] + ); + assert!(stores.items_in_folder(Some("ghost"), "steam").is_empty()); + stores.create_folder("New", None, "steam"); + let value = saved(&mut stores, CLIENT_STORE_FOLDERS); + assert!(value["itemOrder"].get("ghost").is_none()); +} + +#[test] +fn keeps_every_platform_root_bucket() { + let stores = stores_with( + CLIENT_STORE_FOLDERS, + json!({ + "version": 1, + "folders": [], + "itemOrder": { + "root:steam": [{ "type": "account", "id": "account-1" }], + "root:riot": [{ "type": "account", "id": "account-2" }] + } + }), + ); + assert_eq!( + stores.items_in_folder(None, "steam"), + [ItemRef::account("account-1")] + ); + assert_eq!( + stores.items_in_folder(None, "riot"), + [ItemRef::account("account-2")] + ); +} + +#[test] +fn drops_a_bare_root_prefix() { + let mut stores = stores_with( + CLIENT_STORE_FOLDERS, + json!({ "version": 1, "folders": [], "itemOrder": { "root:": [{ "type": "account", "id": "account-1" }] } }), + ); + stores.create_folder("New", None, "steam"); + assert!(saved(&mut stores, CLIENT_STORE_FOLDERS)["itemOrder"] + .get("root:") + .is_none()); +} + +fn move_store() -> ClientStores { + stores_with( + CLIENT_STORE_FOLDERS, + json!({ + "version": 1, + "folders": [ + { "id": "smurfs", "name": "Smurfs", "parentId": null, "platform": "steam" }, + { "id": "old", "name": "Old", "parentId": "smurfs", "platform": "steam" }, + { "id": "riot-folder", "name": "Ranked", "parentId": null, "platform": "riot" } + ], + "itemOrder": { + "root:steam": [{ "type": "folder", "id": "smurfs" }, { "type": "account", "id": "at-root" }], + "smurfs": [{ "type": "folder", "id": "old" }, { "type": "account", "id": "in-smurfs" }], + "old": [{ "type": "account", "id": "in-old" }], + "root:riot": [{ "type": "folder", "id": "riot-folder" }] + } + }), + ) +} + +fn ids(folders: &FolderStore, platform: &str) -> Vec { + folders + .list_folders(platform) + .iter() + .map(|folder| folder.id.clone()) + .collect() +} + +#[test] +fn lists_only_the_folders_of_the_active_platform() { + let stores = move_store(); + assert_eq!(ids(stores.folders(), "steam"), ["smurfs", "old"]); + assert_eq!(ids(stores.folders(), "riot"), ["riot-folder"]); +} + +#[test] +fn finds_the_folder_holding_an_account() { + let stores = move_store(); + let folders = stores.folders(); + assert_eq!( + folders.find_item_folder_id(&ItemRef::account("in-smurfs"), "steam"), + Some("smurfs") + ); + assert_eq!( + folders.find_item_folder_id(&ItemRef::account("in-old"), "steam"), + Some("old") + ); +} + +#[test] +fn reads_an_account_at_the_platform_root_as_no_folder() { + let stores = move_store(); + let folders = stores.folders(); + assert_eq!( + folders.find_item_folder_id(&ItemRef::account("at-root"), "steam"), + None + ); + assert_eq!( + folders.find_item_folder_id(&ItemRef::account("unknown"), "steam"), + None + ); +} + +#[test] +fn puts_a_root_account_into_a_folder() { + let mut stores = move_store(); + stores.move_item( + &ItemRef::account("at-root"), + None, + Some("old"), + "steam", + None, + ); + assert_eq!( + stores.items_in_folder(Some("old"), "steam"), + [ItemRef::account("in-old"), ItemRef::account("at-root")] + ); + assert_eq!( + stores.items_in_folder(None, "steam"), + [ItemRef::folder("smurfs")] + ); +} + +#[test] +fn sends_an_account_back_to_the_root() { + let mut stores = move_store(); + stores.move_item( + &ItemRef::account("in-old"), + Some("old"), + None, + "steam", + None, + ); + assert!(stores.items_in_folder(Some("old"), "steam").is_empty()); + assert_eq!( + stores.items_in_folder(None, "steam"), + [ + ItemRef::folder("smurfs"), + ItemRef::account("at-root"), + ItemRef::account("in-old") + ] + ); +} + +// ------------------------------------------------------------ personaAssignmentsForSave + +fn steam() -> PersonaAssignment { + PersonaAssignment { + platform_id: "steam".into(), + account_id: "steam-1".into(), + } +} + +fn discord() -> PersonaAssignment { + PersonaAssignment { + platform_id: "discord".into(), + account_id: "discord-1".into(), + } +} + +struct Save { + offered: Vec, + selection: BTreeMap, + accounts: BTreeMap>, + failed: Vec, +} + +impl Save { + fn new() -> Self { + Self { + offered: vec!["steam".into()], + selection: map(&[("steam", "steam-1")]), + accounts: BTreeMap::from([("steam".into(), vec!["steam-1".into()])]), + failed: Vec::new(), + } + } + fn run(&self) -> Vec { + let original = [steam(), discord()]; + persona_assignments_for_save(&AssignmentsForSave { + original: &original, + offered_platform_ids: &self.offered, + selection: &self.selection, + accounts_by_platform: &self.accounts, + loading: false, + failed_platform_ids: &self.failed, + }) + } +} + +fn map(entries: &[(&str, &str)]) -> BTreeMap { + entries + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect() +} + +#[test] +fn keeps_an_assignment_for_a_platform_the_wizard_is_not_offering() { + assert_eq!(Save::new().run(), [steam(), discord()]); +} + +#[test] +fn keeps_an_assignment_when_that_platforms_account_read_failed() { + let mut save = Save::new(); + save.offered = vec!["steam".into(), "discord".into()]; + save.selection = map(&[("steam", "steam-1"), ("discord", "discord-1")]); + save.accounts.insert("discord".into(), Vec::new()); + save.failed = vec!["discord".into()]; + assert_eq!(save.run(), [steam(), discord()]); +} + +#[test] +fn drops_an_assignment_a_successful_read_proved_is_gone() { + let mut save = Save::new(); + save.offered = vec!["steam".into(), "discord".into()]; + save.selection = map(&[("steam", "steam-1"), ("discord", "discord-1")]); + save.accounts.insert("discord".into(), Vec::new()); + assert_eq!(save.run(), [steam()]); +} + +#[test] +fn drops_a_cleared_platform_and_keeps_the_one_it_could_not_edit() { + let mut save = Save::new(); + save.selection = map(&[("steam", "")]); + assert_eq!(save.run(), [discord()]); +} + +#[test] +fn appends_a_newly_picked_platform_after_the_original_assignments() { + let mut save = Save::new(); + save.offered = vec!["steam".into(), "riot".into()]; + save.selection = map(&[("steam", "steam-1"), ("riot", "riot-1")]); + save.accounts.insert("riot".into(), vec!["riot-1".into()]); + let riot = PersonaAssignment { + platform_id: "riot".into(), + account_id: "riot-1".into(), + }; + assert_eq!(save.run(), [steam(), discord(), riot]); +} + +// ------------------------------------------------------------ cachedMapStore + +fn identity(_key: &str, value: &str) -> Option { + Some(value.to_string()) +} + +fn refuse_bad(_key: &str, value: &str) -> Option { + (value != "bad").then(|| value.to_string()) +} + +#[test] +fn map_store_accepts_valid_entries() { + fn non_empty(_key: &str, value: &str) -> Option { + (!value.is_empty()).then(|| value.to_string()) + } + let mut store = StringMapStore::new(CLIENT_STORE_ACCOUNT_CARD_NOTES, non_empty); + store.set("a", "hello"); + assert_eq!(store.get("a"), "hello"); +} + +#[test] +fn map_store_get_returns_empty_for_unknown_keys() { + let store = StringMapStore::new(CLIENT_STORE_ACCOUNT_CARD_NOTES, identity); + assert_eq!(store.get("nonexistent"), ""); +} + +#[test] +fn map_store_set_stores_and_retrieves_values() { + let mut store = StringMapStore::new(CLIENT_STORE_ACCOUNT_CARD_NOTES, identity); + store.set("acc1", "blue"); + assert_eq!(store.get("acc1"), "blue"); +} + +#[test] +fn map_store_refused_value_removes_the_entry() { + let mut store = StringMapStore::new(CLIENT_STORE_ACCOUNT_CARD_NOTES, refuse_bad); + store.set("acc1", "good"); + assert_eq!(store.get("acc1"), "good"); + store.set("acc1", "bad"); + assert_eq!(store.get("acc1"), ""); +} + +#[test] +fn set_many_writes_every_id_in_one_store_write() { + let mut stores = stores_with(CLIENT_STORE_ACCOUNT_CARD_COLORS, json!({})); + stores.set_account_card_color("keep", "#000"); + let before = stores.storage().revision(CLIENT_STORE_ACCOUNT_CARD_COLORS); + let abc: Vec = ["a", "b", "c"].map(String::from).to_vec(); + stores.set_account_card_colors(&abc, "#f00"); + assert_eq!( + stores.storage().revision(CLIENT_STORE_ACCOUNT_CARD_COLORS), + before + 1 + ); + let read = |stores: &ClientStores, ids: &[&str]| -> Vec { + ids.iter() + .map(|id| stores.account_card_color(id).to_string()) + .collect() + }; + assert_eq!( + read(&stores, &["a", "b", "c", "keep"]), + ["#f00", "#f00", "#f00", "#000"] + ); + stores.set_account_card_colors(&abc[..2], "bad"); + assert_eq!(read(&stores, &["a", "b", "c"]), ["", "", "#f00"]); +} + +#[test] +fn map_store_remove_deletes_entries() { + let mut store = StringMapStore::new(CLIENT_STORE_ACCOUNT_CARD_NOTES, identity); + store.set("acc1", "value"); + store.remove("acc1"); + assert_eq!(store.get("acc1"), ""); +} + +// ------------------------------------------------------------ accountCardColors + +#[test] +fn account_card_color_is_empty_for_an_unknown_account() { + assert_eq!( + stores_with(CLIENT_STORE_ACCOUNT_CARD_COLORS, Value::Null).account_card_color("unknown"), + "" + ); +} + +#[test] +fn account_card_color_stores_valid_hex_colors() { + let mut stores = stores_with(CLIENT_STORE_ACCOUNT_CARD_COLORS, Value::Null); + for (id, color) in [("acc1", "#ff0000"), ("acc2", "#abc"), ("acc3", "#aabbccdd")] { + stores.set_account_card_color(id, color); + assert_eq!(stores.account_card_color(id), color); + } +} + +#[test] +fn account_card_color_rejects_invalid_colors() { + let mut stores = stores_with(CLIENT_STORE_ACCOUNT_CARD_COLORS, Value::Null); + for (id, color) in [ + ("acc1", "red"), + ("acc2", "rgb(255,0,0)"), + ("acc3", "#xyz"), + ("acc4", "not-a-color"), + ] { + stores.set_account_card_color(id, color); + assert_eq!(stores.account_card_color(id), ""); + } +} + +#[test] +fn account_card_color_presets_have_the_expected_structure() { + assert!(!ACCOUNT_CARD_COLOR_PRESETS.is_empty()); + let none = ACCOUNT_CARD_COLOR_PRESETS + .iter() + .find(|preset| preset.id == "none") + .unwrap(); + assert_eq!(none.color, ""); +} + +// ------------------------------------------------------------ accountCardNotes + +#[test] +fn account_card_note_is_empty_for_an_unknown_account() { + assert_eq!( + stores_with(CLIENT_STORE_ACCOUNT_CARD_NOTES, Value::Null).account_card_note("unknown"), + "" + ); +} + +#[test] +fn account_card_note_is_trimmed() { + let mut stores = stores_with(CLIENT_STORE_ACCOUNT_CARD_NOTES, Value::Null); + stores.set_account_card_note("acc1", " hello world "); + assert_eq!(stores.account_card_note("acc1"), "hello world"); +} + +#[test] +fn account_card_note_strips_control_characters() { + let mut stores = stores_with(CLIENT_STORE_ACCOUNT_CARD_NOTES, Value::Null); + stores.set_account_card_note("acc1", "line1\u{0}line2\u{1}line3"); + assert_eq!(stores.account_card_note("acc1"), "line1 line2 line3"); +} + +#[test] +fn account_card_note_is_cut_at_180_characters() { + let mut stores = stores_with(CLIENT_STORE_ACCOUNT_CARD_NOTES, Value::Null); + stores.set_account_card_note("acc1", &"a".repeat(300)); + assert_eq!(stores.account_card_note("acc1").len(), 180); +} + +#[test] +fn clearing_an_account_card_note_removes_it() { + let mut stores = stores_with(CLIENT_STORE_ACCOUNT_CARD_NOTES, Value::Null); + stores.set_account_card_note("acc1", "some note"); + assert_eq!(stores.account_card_note("acc1"), "some note"); + stores.clear_account_card_note("acc1"); + assert_eq!(stores.account_card_note("acc1"), ""); +} + +// ------------------------------------------------------------ store wiring + +#[test] +fn a_refresh_rebuilds_the_typed_copy_and_a_local_write_does_not() { + let mut stores = stores_with("client.settings", json!({ "animations": "off" })); + assert_eq!(stores.settings().animations, AnimationsMode::Off); + assert!(stores.has_stored_settings()); + let mut next = stores.settings().clone(); + next.ui_scale_percent = 500; + stores.save_settings(&next); + assert_eq!(stores.settings().ui_scale_percent, 150); + stores.apply_snapshot(ClientStorageSnapshot { + manifest: Default::default(), + stores: BTreeMap::from([("client.settings".to_string(), json!({ "language": "ru" }))]), + }); + assert_eq!(stores.settings().language.code(), "ru"); + assert_eq!(stores.settings().animations, AnimationsMode::System); +} diff --git a/crates/accshift-app/tests/i18n.rs b/crates/accshift-app/tests/i18n.rs new file mode 100644 index 0000000..b225564 --- /dev/null +++ b/crates/accshift-app/tests/i18n.rs @@ -0,0 +1,288 @@ +//! The TS i18n tests (`src/lib/i18n/{index,messages}.test.ts`) against the +//! embedded dictionaries, plus the export check: every key of the TS message +//! files must be in the JSON `tools/i18n.dump.ts` wrote. + +use accshift_app::i18n::{ + boot_locale, detect_preferred_locale, dictionary, format_template, is_locale, normalize_locale, + translate, translate_with, Locale, TranslationValue, DEFAULT_LOCALE, LANGUAGE_OPTIONS, LOCALES, +}; +use serde_json::{json, Value}; +use std::collections::BTreeSet; +use std::path::Path; + +// ------------------------------------------------------------ isLocale, normalizeLocale + +#[test] +fn is_locale_accepts_en() { + assert!(is_locale(Some(&json!("en")))); +} + +#[test] +fn is_locale_accepts_fr() { + assert!(is_locale(Some(&json!("fr")))); +} + +#[test] +fn is_locale_rejects_de() { + assert!(!is_locale(Some(&json!("de")))); +} + +#[test] +fn is_locale_rejects_null() { + assert!(!is_locale(Some(&Value::Null))); + assert!(!is_locale(None)); +} + +#[test] +fn is_locale_rejects_a_number() { + assert!(!is_locale(Some(&json!(42)))); +} + +#[test] +fn normalize_locale_passes_a_valid_locale_through() { + assert_eq!(normalize_locale(Some(&json!("fr"))), Locale::Fr); +} + +#[test] +fn normalize_locale_defaults_an_invalid_one_to_en() { + assert_eq!(normalize_locale(Some(&json!("xx"))), Locale::En); +} + +#[test] +fn normalize_locale_defaults_null_to_en() { + assert_eq!(normalize_locale(Some(&Value::Null)), Locale::En); +} + +// ------------------------------------------------------------ translate + +#[test] +fn translates_a_known_key_in_en() { + let result = translate(Locale::En, "common.close"); + assert!(!result.is_empty()); + assert_ne!(result, "common.close"); +} + +#[test] +fn interpolates_parameters() { + let result = translate_with( + Locale::En, + "time.minutesAgo", + &[("count", TranslationValue::from(5))], + ); + assert!(result.contains('5'), "{result}"); +} + +#[test] +fn serves_fr_strings() { + assert_eq!(translate(Locale::Fr, "common.close"), "Fermer"); +} + +#[test] +fn an_unknown_key_renders_as_itself() { + assert_eq!(translate(Locale::Fr, "no.such.key"), "no.such.key"); +} + +#[test] +fn format_template_empties_missing_params_and_renders_values() { + assert_eq!( + format_template( + "{a} {b} {missing}", + Some(&[ + ("a", TranslationValue::from("x")), + ("b", TranslationValue::from(true)) + ]) + ), + "x true " + ); + assert_eq!(format_template("{a}", None), "{a}"); +} + +// ------------------------------------------------------------ detectPreferredLocale + +#[test] +fn detect_returns_an_exact_match() { + assert_eq!(detect_preferred_locale(&["fr"]), Locale::Fr); +} + +#[test] +fn detect_falls_back_from_a_regional_tag_to_its_base() { + assert_eq!(detect_preferred_locale(&["fr-CA"]), Locale::Fr); +} + +#[test] +fn detect_defaults_unsupported_locales_to_en() { + assert_eq!(detect_preferred_locale(&["de-DE"]), Locale::En); +} + +#[test] +fn detect_prefers_the_regional_dictionary_when_both_ship() { + assert_eq!(detect_preferred_locale(&["pt-BR"]), Locale::PtBr); +} + +#[test] +fn detect_maps_a_regional_tag_without_a_dictionary_to_its_base() { + assert_eq!(detect_preferred_locale(&["pt-PT"]), Locale::Pt); + assert_eq!(detect_preferred_locale(&["es-419"]), Locale::Es); + assert_eq!(detect_preferred_locale(&["zh-CN"]), Locale::Zh); +} + +#[test] +fn detect_defaults_to_en_with_no_languages() { + assert_eq!(detect_preferred_locale::<&str>(&[]), Locale::En); +} + +#[test] +fn detect_skips_an_unsupported_entry_and_matches_a_later_one() { + assert_eq!(detect_preferred_locale(&["de-DE", "en-GB"]), Locale::En); +} + +#[test] +fn boot_locale_reads_stored_settings_then_the_os() { + let stored = json!({ "language": "ru" }); + assert_eq!(boot_locale(Some(&stored), &["fr-FR"]), Locale::Ru); + assert_eq!( + boot_locale(Some(&json!({ "language": "xx" })), &["fr-FR"]), + Locale::En + ); + assert_eq!(boot_locale(None, &["fr-FR"]), Locale::Fr); + assert_eq!(boot_locale(Some(&json!({})), &["zh-CN"]), Locale::Zh); +} + +// ------------------------------------------------------------ messages + +fn placeholders(value: &str) -> Vec { + let mut found = Vec::new(); + let mut rest = value; + while let Some(start) = rest.find('{') { + let after = &rest[start + 1..]; + let len = after + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .unwrap_or(after.len()); + if len > 0 && after[len..].starts_with('}') { + found.push(format!("{{{}}}", &after[..len])); + } + rest = after; + } + found.sort(); + found +} + +fn keys(locale: Locale) -> BTreeSet<&'static str> { + dictionary(locale).keys().map(String::as_str).collect() +} + +#[test] +fn has_a_dictionary_for_every_shipped_locale() { + let shipped: BTreeSet<&str> = LANGUAGE_OPTIONS + .iter() + .map(|option| option.code.code()) + .collect(); + let registered: BTreeSet<&str> = LOCALES.iter().map(|locale| locale.code()).collect(); + assert_eq!(registered, shipped); + assert_eq!(DEFAULT_LOCALE, Locale::En); +} + +#[test] +fn every_locale_covers_exactly_the_en_key_set() { + let en = keys(Locale::En); + for locale in LOCALES { + assert_eq!(keys(locale), en, "{}", locale.code()); + } +} + +#[test] +fn every_locale_keeps_the_en_placeholders() { + let en = dictionary(Locale::En); + for locale in LOCALES { + let dict = dictionary(locale); + for (key, value) in en { + assert_eq!( + placeholders(dict.get(key).map_or("", String::as_str)), + placeholders(value), + "placeholders in {}:{key}", + locale.code() + ); + } + } +} + +#[test] +fn no_locale_has_an_em_or_en_dash() { + for locale in LOCALES { + for (key, value) in dictionary(locale) { + assert!( + !value.contains(['\u{2013}', '\u{2014}']), + "dash in {}:{key}", + locale.code() + ); + } + } +} + +#[test] +fn no_locale_has_an_empty_string() { + for locale in LOCALES { + for (key, value) in dictionary(locale) { + assert!( + !value.trim().is_empty(), + "empty message for {}:{key}", + locale.code() + ); + } + } +} + +// ------------------------------------------------------------ export check + +fn ts_file(locale: Locale) -> &'static str { + match locale { + Locale::En => "messages.ts", + Locale::Es => "messages.es.ts", + Locale::Fr => "messages.fr.ts", + Locale::Pt => "messages.pt.ts", + Locale::PtBr => "messages.pt-br.ts", + Locale::Ru => "messages.ru.ts", + Locale::Zh => "messages.zh.ts", + } +} + +/// The keys a TS message file declares: every entry line is two spaces, a +/// quoted key and a colon. +fn ts_keys(source: &str) -> BTreeSet { + source + .lines() + .filter_map(|line| line.strip_prefix(" \"")) + .filter_map(|rest| rest.split_once("\":")) + .map(|(key, _)| key.to_string()) + .collect() +} + +#[test] +fn every_ts_key_is_in_the_exported_json() { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../src/lib/i18n"); + let mut counts = Vec::new(); + for locale in LOCALES { + let path = dir.join(ts_file(locale)); + let source = std::fs::read_to_string(&path).expect("TS message file"); + let declared = ts_keys(&source); + assert!( + !declared.is_empty(), + "no key parsed from {}", + path.display() + ); + let exported = keys(locale); + let missing: Vec<&String> = declared + .iter() + .filter(|key| !exported.contains(key.as_str())) + .collect(); + assert!( + missing.is_empty(), + "{}: keys missing from i18n/{}.json, rerun tools/i18n.dump.ts: {missing:?}", + locale.code(), + locale.code() + ); + assert_eq!(declared.len(), exported.len(), "{}", locale.code()); + counts.push(format!("{} {}", locale.code(), exported.len())); + } + eprintln!("i18n keys: {}", counts.join(", ")); +} diff --git a/crates/accshift-app/tests/stores_roundtrip.rs b/crates/accshift-app/tests/stores_roundtrip.rs new file mode 100644 index 0000000..dfe92f1 --- /dev/null +++ b/crates/accshift-app/tests/stores_roundtrip.rs @@ -0,0 +1,182 @@ +//! Round trip against the TypeScript stores: every fixture of +//! `tests/data/stores/roundtrip.json` (written by `tools/stores.dump.ts`) is +//! a stored value, an operation and what the TS store saved after it. The +//! Rust store loads the same value, runs the same operation and must save the +//! same JSON, byte for byte once serialized the way the backend writes it. + +use accshift_app::folders::ItemRef; +use accshift_app::personas::{PersonaAssignment, PersonaPatch}; +use accshift_app::storage::{ClientStorage, CLIENT_STORE_IDS}; +use accshift_app::stores::{ + ClientStores, DefaultGame, StoreContext, BUILT_IN_THEME_IDS, SHIPPED_PLATFORM_IDS, +}; +use accshift_app::types::{ClientStorageSnapshot, StorageManifest}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::path::Path; + +fn data(name: &str) -> Value { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/data/stores") + .join(name); + serde_json::from_str(&std::fs::read_to_string(&path).expect("fixture")).expect("json") +} + +fn strings(value: &Value) -> Vec { + value + .as_array() + .expect("array") + .iter() + .map(|item| item.as_str().expect("string").to_string()) + .collect() +} + +fn context() -> StoreContext { + let raw = data("context.json"); + StoreContext { + platform_ids: strings(&raw["platformIds"]), + theme_ids: strings(&raw["themeIds"]), + preferred_languages: strings(&raw["preferredLanguages"]), + } +} + +#[test] +fn shipped_lists_match_the_ts_registry() { + let ctx = context(); + assert_eq!(ctx.platform_ids, SHIPPED_PLATFORM_IDS.map(String::from)); + assert_eq!(ctx.theme_ids, BUILT_IN_THEME_IDS.map(String::from)); +} + +fn opt_str(value: &Value) -> Option<&str> { + value.as_str() +} + +fn item_ref(value: &Value) -> ItemRef { + serde_json::from_value(value.clone()).expect("item ref") +} + +fn run(stores: &mut ClientStores, op: &Value) { + let s = |key: &str| op[key].as_str().unwrap_or_default().to_string(); + match op["kind"].as_str().expect("kind") { + "saveSettings" => { + let settings = stores.settings().clone(); + stores.save_settings(&settings); + } + "reorderRoot" => { + let platform = s("platform"); + let items = stores.items_in_folder(None, &platform).to_vec(); + stores.reorder_items(None, &platform, items); + } + "moveItem" => stores.move_item( + &item_ref(&op["item"]), + opt_str(&op["from"]), + opt_str(&op["to"]), + &s("platform"), + op["index"].as_u64().map(|index| index as usize), + ), + "deleteFolder" => stores.delete_folder(&s("id")), + "renameFolder" => stores.rename_folder(&s("id"), &s("name")), + "syncAccounts" => stores.sync_accounts(&strings(&op["ids"]), &s("platform")), + "deletePersona" => stores.delete_persona(&s("id")), + "updatePersona" => { + let patch = &op["patch"]; + let assignments: Option> = patch + .get("assignments") + .map(|value| serde_json::from_value(value.clone()).expect("assignments")); + stores.update_persona( + &s("id"), + &PersonaPatch { + name: patch["name"].as_str().map(String::from), + color: patch["color"].as_str().map(String::from), + image: patch + .get("image") + .map(|image| image.as_str().map(String::from)), + assignments, + }, + ); + } + "clearNote" => stores.clear_account_card_note(&s("id")), + "setNote" => stores.set_account_card_note(&s("id"), &s("note")), + "setColor" => stores.set_account_card_color(&s("id"), &s("color")), + "setColors" => stores.set_account_card_colors(&strings(&op["ids"]), &s("color")), + "setFolderColor" => stores.set_folder_card_color(&s("id"), &s("color")), + "setDefaultGame" => { + let game: DefaultGame = serde_json::from_value(op["game"].clone()).expect("game"); + stores.set_default_game(&s("id"), &game); + } + "resaveViewMode" => { + let mode = stores.view_mode(); + stores.set_view_mode(mode); + } + other => panic!("unknown op {other}"), + } +} + +#[test] +fn every_fixture_saves_what_the_ts_store_saved() { + let fixtures = data("roundtrip.json"); + let fixtures = fixtures.as_array().expect("fixtures"); + let ctx = context(); + let mut failures = Vec::new(); + let mut by_store: BTreeMap = BTreeMap::new(); + for fixture in fixtures { + let name = fixture["name"].as_str().expect("name"); + let store_id = fixture["storeId"].as_str().expect("store id"); + *by_store.entry(store_id.to_string()).or_default() += 1; + let snapshot = ClientStorageSnapshot { + manifest: StorageManifest { + schema_version: 1, + stores: BTreeMap::new(), + }, + stores: BTreeMap::from([(store_id.to_string(), fixture["input"].clone())]), + }; + let mut stores = ClientStores::new(ClientStorage::from_snapshot(snapshot), ctx.clone()); + run(&mut stores, &fixture["op"]); + let saves = stores.take_all_saves(); + let wrote = fixture["wrote"].as_bool().expect("wrote"); + let saved = saves.iter().find(|save| save.store_id == store_id); + match (wrote, saved) { + (false, None) => {} + (false, Some(save)) => failures.push(format!("{name}: wrote {}", save.value)), + (true, None) => failures.push(format!("{name}: wrote nothing")), + (true, Some(save)) => { + let expected = serde_json::to_string_pretty(&fixture["expected"]).unwrap(); + let actual = serde_json::to_string_pretty(&save.value).unwrap(); + if expected != actual { + failures.push(format!("{name}:\n expected {expected}\n actual {actual}")); + } + } + } + assert!( + saves.iter().all(|save| save.store_id == store_id), + "{name}: wrote another store" + ); + } + eprintln!("round-trip fixtures: {}", fixtures.len()); + eprintln!("by store: {by_store:?}"); + assert!(failures.is_empty(), "{}", failures.join("\n")); + let typed = CLIENT_STORE_IDS.len() - 4; + assert_eq!(by_store.len(), typed, "every typed store has fixtures"); +} + +#[test] +fn a_fixture_with_no_write_is_listed() { + let fixtures = data("roundtrip.json"); + let silent: Vec<&str> = fixtures + .as_array() + .unwrap() + .iter() + .filter(|fixture| fixture["wrote"] == Value::Bool(false)) + .map(|fixture| fixture["name"].as_str().unwrap()) + .collect(); + assert_eq!( + silent, + [ + "folders move folder into its own subtree", + "folders delete missing folder", + "folders sync unchanged", + "colors set many empty list", + "default game invalid set", + ] + ); +} diff --git a/crates/accshift-app/tools/i18n.dump.ts b/crates/accshift-app/tools/i18n.dump.ts new file mode 100644 index 0000000..bbe5203 --- /dev/null +++ b/crates/accshift-app/tools/i18n.dump.ts @@ -0,0 +1,40 @@ +// Writes the message dictionaries of src/lib/i18n to crates/accshift-app/i18n, +// one JSON file per locale. The Rust i18n module embeds these files. +// +// From the repo root: +// build-safe node node_modules/vitest/vitest.mjs run --config crates/accshift-app/tools/vitest.dump.config.mjs +import { mkdirSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { LANGUAGE_OPTIONS } from "$lib/i18n"; +import { EN_MESSAGES } from "$lib/i18n/messages"; +import { ES_MESSAGES } from "$lib/i18n/messages.es"; +import { FR_MESSAGES } from "$lib/i18n/messages.fr"; +import { PT_MESSAGES } from "$lib/i18n/messages.pt"; +import { PT_BR_MESSAGES } from "$lib/i18n/messages.pt-br"; +import { RU_MESSAGES } from "$lib/i18n/messages.ru"; +import { ZH_MESSAGES } from "$lib/i18n/messages.zh"; + +const OUT_DIR = fileURLToPath(new URL("../i18n/", import.meta.url)); + +const DICTIONARIES: Record> = { + en: EN_MESSAGES, + es: ES_MESSAGES, + fr: FR_MESSAGES, + pt: PT_MESSAGES, + "pt-br": PT_BR_MESSAGES, + ru: RU_MESSAGES, + zh: ZH_MESSAGES, +}; + +describe("i18n dump", () => { + it("writes one dictionary per shipped locale", () => { + const shipped = LANGUAGE_OPTIONS.map((option) => option.code as string).sort(); + expect(Object.keys(DICTIONARIES).sort()).toEqual(shipped); + mkdirSync(OUT_DIR, { recursive: true }); + for (const [locale, dictionary] of Object.entries(DICTIONARIES)) { + writeFileSync(`${OUT_DIR}${locale}.json`, `${JSON.stringify(dictionary, null, 2)}\n`); + } + }); +}); diff --git a/crates/accshift-app/tools/stores.dump.ts b/crates/accshift-app/tools/stores.dump.ts new file mode 100644 index 0000000..64a7a24 --- /dev/null +++ b/crates/accshift-app/tools/stores.dump.ts @@ -0,0 +1,598 @@ +// Runs the real TypeScript client stores over a set of stored values and +// writes what each one saves to crates/accshift-app/tests/data/stores. The +// Rust round-trip test loads the same input, runs the same operation and +// must save the same JSON. +// +// The inputs are the client stores of every mock scenario in +// crates/accshift-egui/parity/data, the stores the TS tests build, and +// payloads with legacy keys or bad values that walk every normalization path. +// +// From the repo root: +// build-safe node node_modules/vitest/vitest.mjs run --config crates/accshift-app/tools/vitest.dump.config.mjs +import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: () => Promise.resolve(undefined) })); +vi.mock("@tauri-apps/api/window", () => ({ getCurrentWindow: () => ({}), Effect: {} })); + +// The stores read and write through clientStorage; an in-memory copy stands in +// for the backend and records the last value each store wrote. +const memory = vi.hoisted(() => ({ + values: new Map(), + revisions: new Map(), + saved: new Map(), +})); + +vi.mock("$lib/storage/clientStorage", async () => { + const ids = await import("$lib/storage/storeIds"); + const bump = (id: string) => memory.revisions.set(id, (memory.revisions.get(id) ?? 0) + 1); + return { + ...ids, + getClientStoreValue: (id: string) => memory.values.get(id), + getClientStoreRevision: (id: string) => memory.revisions.get(id) ?? 0, + onClientStoreChange: () => () => {}, + setClientStoreValue: (id: string, value: unknown) => { + const copy = value === undefined ? undefined : JSON.parse(JSON.stringify(value)); + memory.values.set(id, copy); + memory.saved.set(id, copy); + bump(id); + }, + __load: (id: string, value: unknown) => { + memory.values.set(id, value); + memory.saved.clear(); + bump(id); + }, + }; +}); + +import * as storage from "$lib/storage/clientStorage"; +import { getSettings, saveSettings } from "$lib/features/settings/store"; +import { + deleteFolder, + getItemsInFolder, + moveItem, + renameFolder, + reorderItems, + syncAccounts, +} from "$lib/features/folders/store"; +import type { ItemRef } from "$lib/features/folders/types"; +import { deletePersona, updatePersona } from "$lib/features/personas/store"; +import { clearAccountCardNote, setAccountCardNote } from "$lib/shared/accountCardNotes"; +import { setAccountCardColor, setAccountCardColors } from "$lib/shared/accountCardColors"; +import { setFolderCardColor } from "$lib/shared/folderCardColors"; +import { getViewMode, setViewMode } from "$lib/shared/viewMode"; +import { setDefaultGame } from "$lib/platforms/steam/defaultGameStore"; +import { PLATFORM_DEFS } from "$lib/platforms/registry"; +import { BUILT_IN_THEMES } from "$lib/theme/themes"; + +const ROOT = fileURLToPath(new URL("../../../", import.meta.url)); +const OUT_DIR = fileURLToPath(new URL("../tests/data/stores/", import.meta.url)); +const PARITY_DIR = `${ROOT}crates/accshift-egui/parity/data/`; + +type Op = { kind: string; [key: string]: unknown }; +interface Case { + name: string; + storeId: string; + input: unknown; + op: Op; +} + +const S = { + settings: storage.CLIENT_STORE_SETTINGS, + folders: storage.CLIENT_STORE_FOLDERS, + personas: storage.CLIENT_STORE_PERSONAS, + notes: storage.CLIENT_STORE_ACCOUNT_CARD_NOTES, + colors: storage.CLIENT_STORE_ACCOUNT_CARD_COLORS, + defaultGame: storage.CLIENT_STORE_ACCOUNT_DEFAULT_GAME, + folderColors: storage.CLIENT_STORE_FOLDER_CARD_COLORS, + viewMode: storage.CLIENT_STORE_VIEW_MODE, +} as const; + +/** The operation each store runs when nothing more specific is asked: one + * that loads the store, keeps its content and writes it back. */ +const RESAVE: Record = { + [S.settings]: { kind: "saveSettings" }, + [S.folders]: { kind: "reorderRoot", platform: "steam" }, + [S.personas]: { kind: "deletePersona", id: "__none__" }, + [S.notes]: { kind: "clearNote", id: "__none__" }, + [S.colors]: { kind: "setColor", id: "__none__", color: "" }, + [S.folderColors]: { kind: "setFolderColor", id: "__none__", color: "" }, + [S.defaultGame]: { + kind: "setDefaultGame", + id: "__roundtrip__", + game: { appId: " 730 ", name: "Counter-Strike 2" }, + }, + [S.viewMode]: { kind: "resaveViewMode" }, +}; + +function run(op: Op) { + switch (op.kind) { + case "saveSettings": + return saveSettings(getSettings()); + case "reorderRoot": + return reorderItems( + null, + op.platform as string, + getItemsInFolder(null, op.platform as string).map((item) => ({ ...item })), + ); + case "moveItem": + return moveItem( + op.item as ItemRef, + op.from as string | null, + op.to as string | null, + op.platform as string, + op.index as number | undefined, + ); + case "deleteFolder": + return deleteFolder(op.id as string); + case "renameFolder": + return renameFolder(op.id as string, op.name as string); + case "syncAccounts": + return syncAccounts(op.ids as string[], op.platform as string); + case "deletePersona": + return deletePersona(op.id as string); + case "updatePersona": + return updatePersona(op.id as string, op.patch as never); + case "clearNote": + return clearAccountCardNote(op.id as string); + case "setNote": + return setAccountCardNote(op.id as string, op.note as string); + case "setColor": + return setAccountCardColor(op.id as string, op.color as string); + case "setColors": + return setAccountCardColors(op.ids as string[], op.color as string); + case "setFolderColor": + return setFolderCardColor(op.id as string, op.color as string); + case "setDefaultGame": + return setDefaultGame(op.id as string, op.game as { appId: string; name: string }); + case "resaveViewMode": + return setViewMode(getViewMode()); + default: + throw new Error(`unknown op ${op.kind}`); + } +} + +function scenarioCases(): Case[] { + const cases: Case[] = []; + const seen = new Set(); + for (const file of readdirSync(PARITY_DIR) + .filter((name) => name.endsWith(".json")) + .sort()) { + const scenario = JSON.parse(readFileSync(`${PARITY_DIR}${file}`, "utf8")); + const stores = scenario.commands.load_client_storage_snapshot[0].result.stores; + for (const storeId of Object.values(S)) { + if (!(storeId in stores)) continue; + const key = `${storeId}:${JSON.stringify(stores[storeId])}`; + if (seen.has(key)) continue; + seen.add(key); + cases.push({ + name: `${file.replace(/\.json$/, "")} ${storeId}`, + storeId, + input: stores[storeId], + op: RESAVE[storeId], + }); + } + } + return cases; +} + +const HASH = `${"A".repeat(32)}:${"B".repeat(64)}`; + +/** The folder store of the "moving an account without a drag" TS test. */ +const MOVE_STORE = { + version: 1, + folders: [ + { id: "smurfs", name: "Smurfs", parentId: null, platform: "steam" }, + { id: "old", name: "Old", parentId: "smurfs", platform: "steam" }, + { id: "riot-folder", name: "Ranked", parentId: null, platform: "riot" }, + ], + itemOrder: { + "root:steam": [ + { type: "folder", id: "smurfs" }, + { type: "account", id: "at-root" }, + ], + smurfs: [ + { type: "folder", id: "old" }, + { type: "account", id: "in-smurfs" }, + ], + old: [{ type: "account", id: "in-old" }], + "root:riot": [{ type: "folder", id: "riot-folder" }], + }, +}; + +/** The settings of the settingsPatch TS test. */ +const PATCH_SETTINGS = { + language: "en", + themeId: "dark", + backgroundOpacity: 100, + uiScalePercent: 100, + animations: "system", + streamerMode: "auto", + suspendGraphicsWhenMinimized: true, + minimizeOnAccountSwitch: false, + dataRefresh: { avatarCacheDays: 7, banCheckDays: 7 }, + enabledPlatforms: ["steam", "riot"], + healthCheckPerPlatform: {}, + personasEnabled: true, + defaultPlatformId: "steam", + inactivityBlurSeconds: 60, + deepLinksEnabled: true, + cliEnabled: true, + platformSettings: { steam: { runAsAdmin: false, launchOptions: "", shutdownMode: "graceful" } }, + accountDisplay: { + showUsernames: true, + showLastLoginPerPlatform: {}, + showCardNotesInline: false, + expandedFolders: false, + }, + pinEnabled: true, + pinHash: "legacy-hash", +}; + +function testCases(): Case[] { + const resave = (name: string, storeId: string, input: unknown): Case => ({ + name, + storeId, + input, + op: RESAVE[storeId], + }); + const folder = (name: string, input: unknown, op: Op): Case => ({ + name, + storeId: S.folders, + input, + op, + }); + return [ + // Fresh install: nothing stored yet. + ...Object.values(S).map((storeId) => resave(`missing ${storeId}`, storeId, undefined)), + + // settings + resave("settings from settingsPatch test", S.settings, PATCH_SETTINGS), + resave("settings language only", S.settings, { language: "fr" }), + resave("settings ui scale only", S.settings, { uiScalePercent: 110 }), + resave("settings not an object", S.settings, ["steam"]), + resave("settings null language", S.settings, { language: null }), + resave("settings legacy flat keys", S.settings, { + theme: "light", + avatarCacheDays: "30", + banCheckDays: null, + showUsernames: false, + showCardNotesInline: 1, + expandedFolders: "", + steamRunAsAdmin: true, + steamLaunchOptions: " -novid -console ", + accountDisplay: { showLastLogin: true, showRiotLastLogin: 0 }, + }), + resave("settings bad values", S.settings, { + language: "de", + themeId: "nope", + backgroundOpacity: 140.5, + uiScalePercent: "abc", + animations: "ON", + streamerMode: "off", + suspendGraphicsWhenMinimized: 0, + minimizeOnAccountSwitch: "yes", + dataRefresh: { avatarCacheDays: -3, banCheckDays: [45] }, + enabledPlatforms: ["riot", "riot", 42, "nope", "steam"], + defaultPlatformId: "roblox", + healthCheckPerPlatform: { roblox: null }, + inactivityBlurSeconds: 1e9, + deepLinksEnabled: null, + cliEnabled: false, + personasEnabled: false, + pinEnabled: true, + pinHash: ` ${HASH} `, + platformSettings: { + steam: { runAsAdmin: "", launchOptions: "x".repeat(300), shutdownMode: "force" }, + }, + accountDisplay: { + showUsernames: true, + showLastLoginPerPlatform: { steam: 1, discord: false, ghost: true }, + showCardNotesInline: null, + expandedFolders: true, + }, + }), + resave("settings pin without hash", S.settings, { pinEnabled: true, pinHash: "1234" }), + resave("settings legacy pin hash", S.settings, { pinEnabled: 1, pinHash: "C".repeat(64) }), + resave("settings hash without pin", S.settings, { pinEnabled: false, pinHash: HASH }), + resave("settings empty platforms", S.settings, { + enabledPlatforms: [], + defaultPlatformId: 5, + backgroundOpacity: " 42.5 ", + uiScalePercent: "0x80", + inactivityBlurSeconds: [], + themeId: "", + theme: "light", + }), + resave("settings custom theme id unknown here", S.settings, { + themeId: "midnight", + animations: "off", + dataRefresh: { avatarCacheDays: 0, banCheckDays: 90.5 }, + healthCheckPerPlatform: { roblox: false }, + platformSettings: { steam: "broken" }, + }), + + // folders: the TS test stores + folder( + "folders ghost bucket", + { + version: 1, + folders: [{ id: "live", name: "Live", parentId: null, platform: "steam" }], + itemOrder: { + "root:steam": [{ type: "folder", id: "live" }], + live: [{ type: "account", id: "account-1" }], + ghost: [{ type: "account", id: "account-2" }], + }, + }, + RESAVE[S.folders], + ), + folder( + "folders root buckets of two platforms", + { + version: 1, + folders: [], + itemOrder: { + "root:steam": [{ type: "account", id: "account-1" }], + "root:riot": [{ type: "account", id: "account-2" }], + }, + }, + RESAVE[S.folders], + ), + folder( + "folders bare root prefix", + { version: 1, folders: [], itemOrder: { "root:": [{ type: "account", id: "account-1" }] } }, + RESAVE[S.folders], + ), + folder("folders move root account into folder", MOVE_STORE, { + kind: "moveItem", + item: { type: "account", id: "at-root" }, + from: null, + to: "old", + platform: "steam", + }), + folder("folders move account back to root", MOVE_STORE, { + kind: "moveItem", + item: { type: "account", id: "in-old" }, + from: "old", + to: null, + platform: "steam", + }), + folder("folders move at index", MOVE_STORE, { + kind: "moveItem", + item: { type: "account", id: "at-root" }, + from: null, + to: "smurfs", + platform: "steam", + index: 0, + }), + folder("folders move folder into its own subtree", MOVE_STORE, { + kind: "moveItem", + item: { type: "folder", id: "smurfs" }, + from: null, + to: "old", + platform: "steam", + }), + folder("folders move folder to riot root", MOVE_STORE, { + kind: "moveItem", + item: { type: "folder", id: "old" }, + from: "smurfs", + to: null, + platform: "steam", + }), + folder("folders delete nested folder tree", MOVE_STORE, { + kind: "deleteFolder", + id: "smurfs", + }), + folder("folders delete missing folder", MOVE_STORE, { kind: "deleteFolder", id: "nope" }), + folder("folders rename", MOVE_STORE, { kind: "renameFolder", id: "old", name: "Older" }), + folder("folders sync accounts", MOVE_STORE, { + kind: "syncAccounts", + ids: ["at-root", "in-old", "new-1", "new-2"], + platform: "steam", + }), + folder("folders sync unchanged", MOVE_STORE, { + kind: "syncAccounts", + ids: ["at-root", "in-smurfs", "in-old"], + platform: "steam", + }), + folder("folders sync new platform", MOVE_STORE, { + kind: "syncAccounts", + ids: ["epic-1"], + platform: "epic", + }), + folder( + "folders bad values", + { + folders: [ + { id: " a ", name: " A ", parentId: " ", platform: " steam " }, + { id: "b", name: "", platform: "steam" }, + { id: "c", name: "C", parentId: 5, platform: "steam" }, + "junk", + null, + ], + itemOrder: { + "root:steam": [ + { type: "folder", id: "a" }, + { type: "folder", id: "b" }, + { type: "account", id: " x " }, + { type: "account", id: "" }, + { type: "user", id: "y" }, + null, + ], + a: "not a list", + c: [{ type: "folder", id: "c" }], + " ": [{ type: "account", id: "z" }], + }, + }, + RESAVE[S.folders], + ), + folder("folders not an object", [1, 2], RESAVE[S.folders]), + + // personas + resave("personas bad values", S.personas, [ + { + id: "p1", + name: " Big brother ", + color: "#A1b2C3", + image: "data:image/png;base64,AAAA", + assignments: [ + { platformId: "steam", accountId: "s1" }, + { platformId: "steam", accountId: "s2" }, + { platformId: "ghost", accountId: "g1" }, + { platformId: "riot", accountId: "" }, + { platformId: "riot", accountId: 7 }, + { platformId: "discord", accountId: "d1" }, + [], + ], + }, + { id: "p1", name: "Duplicate" }, + { id: "", name: "No id" }, + { name: "No id" }, + { + id: "p2", + name: " ", + color: "#abc", + image: "https://example.com/a.png", + assignments: "nope", + }, + { id: "p3", name: "n".repeat(50), color: 5, image: `data:image/png;${"a".repeat(400_001)}` }, + { id: "p4", name: "Emoji \u{1f600} name", image: null }, + "junk", + ]), + resave("personas not a list", S.personas, { id: "p1" }), + { + name: "personas update", + storeId: S.personas, + input: [ + { id: "p1", name: "One", color: "", image: null, assignments: [] }, + { id: "p2", name: "Two", color: "", image: null, assignments: [] }, + ], + op: { + kind: "updatePersona", + id: "p2", + patch: { + name: " Renamed ", + color: "#123456", + assignments: [{ platformId: "roblox", accountId: "r1" }], + }, + }, + }, + { + name: "personas delete", + storeId: S.personas, + input: [ + { id: "p1", name: "One", color: "", image: null, assignments: [] }, + { id: "p2", name: "Two", color: "", image: null, assignments: [] }, + ], + op: { kind: "deletePersona", id: "p1" }, + }, + + // string maps + resave("notes bad values", S.notes, { + acc1: " hello ", + acc2: "line1\u0000line2\u0001line3\u007f\u0085end", + acc3: "a".repeat(300), + acc4: " ", + acc5: 5, + " ": "blank key", + acc6: "\u{1f600} emoji", + }), + { + name: "notes set", + storeId: S.notes, + input: { acc1: "keep" }, + op: { kind: "setNote", id: "acc2", note: " new\tnote " }, + }, + { + name: "notes set empty removes", + storeId: S.notes, + input: { acc1: "keep", acc2: "gone" }, + op: { kind: "setNote", id: "acc2", note: " \u0000 " }, + }, + resave("colors bad values", S.colors, { + a: "#ff0000", + b: "#abc", + c: "#aabbccdd", + d: "red", + e: "#xyz", + f: "#abcd", + g: 5, + "": "#ffffff", + }), + { + name: "colors set many", + storeId: S.colors, + input: { keep: "#000" }, + op: { kind: "setColors", ids: ["a", "b"], color: "#3b82f6" }, + }, + { + name: "colors set many invalid removes", + storeId: S.colors, + input: { a: "#000", b: "#111", c: "#222" }, + op: { kind: "setColors", ids: ["a", "b"], color: "bad" }, + }, + { + name: "colors set many empty list", + storeId: S.colors, + input: { a: "#000" }, + op: { kind: "setColors", ids: [], color: "#fff" }, + }, + resave("folder colors bad values", S.folderColors, { f1: "#123456", f2: "blue" }), + + // default game + resave("default game bad values", S.defaultGame, { + s1: { appId: " 730 ", name: "CS2" }, + s2: { appId: "abc", name: "Bad" }, + s3: { appId: "570" }, + s4: { appId: "440", name: 5 }, + s5: [], + s6: "730", + " ": { appId: "1", name: "blank key" }, + }), + { + name: "default game invalid set", + storeId: S.defaultGame, + input: { s1: { appId: "730", name: "CS2" } }, + op: { kind: "setDefaultGame", id: "s2", game: { appId: "x1", name: "Bad" } }, + }, + + // view mode + resave("view mode list", S.viewMode, "list"), + resave("view mode other", S.viewMode, 5), + ]; +} + +describe("client store round-trip dump", () => { + it("writes what each store saves", () => { + const load = (storage as unknown as { __load: (id: string, value: unknown) => void }).__load; + const cases = [...scenarioCases(), ...testCases()]; + const fixtures = cases.map((entry) => { + load(entry.storeId, entry.input === undefined ? undefined : structuredClone(entry.input)); + run(entry.op); + const wrote = memory.saved.has(entry.storeId); + return { + name: entry.name, + storeId: entry.storeId, + input: entry.input ?? null, + op: entry.op, + wrote, + expected: wrote ? memory.saved.get(entry.storeId) : null, + }; + }); + const names = new Set(fixtures.map((fixture) => fixture.name)); + expect(names.size).toBe(fixtures.length); + + mkdirSync(OUT_DIR, { recursive: true }); + for (const name of readdirSync(OUT_DIR)) { + if (name.endsWith(".json")) rmSync(`${OUT_DIR}${name}`); + } + const context = { + platformIds: PLATFORM_DEFS.map((platform) => platform.id), + themeIds: BUILT_IN_THEMES.map((theme) => theme.id), + preferredLanguages: [...(globalThis.navigator?.languages ?? [])], + }; + writeFileSync(`${OUT_DIR}context.json`, `${JSON.stringify(context, null, 2)}\n`); + writeFileSync(`${OUT_DIR}roundtrip.json`, `${JSON.stringify(fixtures, null, 2)}\n`); + }); +}); diff --git a/crates/accshift-app/tools/vitest.dump.config.mjs b/crates/accshift-app/tools/vitest.dump.config.mjs new file mode 100644 index 0000000..81cab93 --- /dev/null +++ b/crates/accshift-app/tools/vitest.dump.config.mjs @@ -0,0 +1,20 @@ +// Vitest config for the accshift-app dumps only. The files it runs are named +// `*.dump.ts` so the app's own `vitest run` never picks them up. +import { fileURLToPath } from "node:url"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; +import { defineConfig } from "vitest/config"; + +const root = fileURLToPath(new URL("../../..", import.meta.url)); + +export default defineConfig({ + root, + // The i18n runtime is a `.svelte.ts` module ($state runes). + plugins: [svelte()], + resolve: { + alias: [{ find: "$lib", replacement: `${root}src/lib` }], + }, + test: { + include: ["crates/accshift-app/tools/*.dump.ts"], + environment: "node", + }, +}); From 0b896a2b5f364beccf6b125425b65659db257002 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:54:03 +0200 Subject: [PATCH 010/106] docs(app): note the fixture format step of the stores dump --- crates/accshift-app/tools/stores.dump.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/accshift-app/tools/stores.dump.ts b/crates/accshift-app/tools/stores.dump.ts index 64a7a24..d8797db 100644 --- a/crates/accshift-app/tools/stores.dump.ts +++ b/crates/accshift-app/tools/stores.dump.ts @@ -9,6 +9,8 @@ // // From the repo root: // build-safe node node_modules/vitest/vitest.mjs run --config crates/accshift-app/tools/vitest.dump.config.mjs +// vp fmt crates/accshift-app/tests/data/stores +// The second step matches what the pre-commit hook does to staged JSON. import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { describe, expect, it, vi } from "vitest"; From 570957a172f04e11d8d71d0d98b9bdefac595c91 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:07:46 +0200 Subject: [PATCH 011/106] feat(app): port the real backend for boot, storage, themes, PIN and descriptors --- crates/accshift-app/Cargo.lock | 268 ++++++++- crates/accshift-app/Cargo.toml | 26 +- crates/accshift-app/src/lib.rs | 3 +- crates/accshift-app/src/real.rs | 238 ++++---- crates/accshift-app/src/real/app.rs | 177 ++++++ crates/accshift-app/src/real/boot.rs | 205 +++++++ crates/accshift-app/src/real/context.rs | 64 +++ crates/accshift-app/src/real/descriptors.rs | 60 ++ crates/accshift-app/src/real/pin.rs | 33 ++ crates/accshift-app/src/real/utility.rs | 43 ++ crates/accshift-app/src/real/wallpaper.rs | 276 ++++++++++ .../tests/data/real/descriptor.json | 56 ++ .../tests/data/real/legacy-config.json | 10 + .../accshift-app/tests/data/real/theme.json | 13 + crates/accshift-app/tests/mock_parity.rs | 11 +- crates/accshift-app/tests/real_backend.rs | 512 ++++++++++++++++++ crates/accshift-app/tests/real_paths.rs | 101 ++++ 17 files changed, 1996 insertions(+), 100 deletions(-) create mode 100644 crates/accshift-app/src/real/app.rs create mode 100644 crates/accshift-app/src/real/boot.rs create mode 100644 crates/accshift-app/src/real/context.rs create mode 100644 crates/accshift-app/src/real/descriptors.rs create mode 100644 crates/accshift-app/src/real/pin.rs create mode 100644 crates/accshift-app/src/real/utility.rs create mode 100644 crates/accshift-app/src/real/wallpaper.rs create mode 100644 crates/accshift-app/tests/data/real/descriptor.json create mode 100644 crates/accshift-app/tests/data/real/legacy-config.json create mode 100644 crates/accshift-app/tests/data/real/theme.json create mode 100644 crates/accshift-app/tests/real_backend.rs create mode 100644 crates/accshift-app/tests/real_paths.rs diff --git a/crates/accshift-app/Cargo.lock b/crates/accshift-app/Cargo.lock index 91a7d81..2f77604 100644 --- a/crates/accshift-app/Cargo.lock +++ b/crates/accshift-app/Cargo.lock @@ -7,9 +7,14 @@ name = "accshift-app" version = "0.0.0" dependencies = [ "accshift-core", - "directories", + "base64 0.23.1", + "dirs", + "image", + "reqwest", + "rfd", "serde", "serde_json", + "windows-sys 0.61.2", ] [[package]] @@ -249,6 +254,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "blocking" version = "1.7.0" @@ -268,6 +282,18 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -478,10 +504,10 @@ dependencies = [ ] [[package]] -name = "directories" +name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ "dirs-sys", ] @@ -498,6 +524,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -509,6 +547,21 @@ dependencies = [ "syn 3.0.7", ] +[[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 = "dunce" version = "1.0.5" @@ -1021,6 +1074,20 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "zune-core", + "zune-jpeg", +] + [[package]] name = "indexmap" version = "2.14.2" @@ -1123,6 +1190,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libredox" version = "0.1.25" @@ -1202,6 +1279,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "nix" version = "0.29.0" @@ -1296,6 +1383,27 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "block2", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -1303,6 +1411,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags", + "dispatch2", + "objc2", +] + +[[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.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", ] [[package]] @@ -1416,6 +1543,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + [[package]] name = "potential_utf" version = "0.1.6" @@ -1452,6 +1585,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.12" @@ -1579,6 +1727,12 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + [[package]] name = "redox_users" version = "0.5.3" @@ -1629,6 +1783,33 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "percent-encoding", + "pollster", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "web-sys", + "windows-sys 0.61.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -1740,6 +1921,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "secret-service" version = "4.0.0" @@ -2359,6 +2546,66 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.106" @@ -2825,6 +3072,21 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "4.2.0" diff --git a/crates/accshift-app/Cargo.toml b/crates/accshift-app/Cargo.toml index 35741a4..f18246a 100644 --- a/crates/accshift-app/Cargo.toml +++ b/crates/accshift-app/Cargo.toml @@ -14,4 +14,28 @@ publish = false accshift-core = { path = "../accshift-core" } serde = { version = "1", features = ["derive"] } serde_json = "1" -directories = "6" +# The crate Tauri's path resolver calls (tauri 2.11 depends on dirs 6), so +# `EguiAppContext` lands on the very same folders as the Tauri app. +dirs = "6" +# The shared HTTP client the Tauri app keeps in its state. Same spec as the +# root workspace so features unify with accshift-core's. +reqwest = { version = "0.12", default-features = false, features = [ + "rustls-tls", + "json", + "blocking", +] } +# `descriptor_select_file`. +rfd = "0.17" + +[target.'cfg(windows)'.dependencies] +# `get_desktop_wallpaper`: the Progman capture of +# src-tauri/src/commands/backdrop.rs, on windows-sys rather than the windows +# crate (accshift-core already builds windows-sys 0.61). +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Graphics_Gdi", + "Win32_Storage_Xps", + "Win32_UI_WindowsAndMessaging", +] } +image = { version = "0.25", default-features = false, features = ["jpeg"] } +base64 = "0.23" diff --git a/crates/accshift-app/src/lib.rs b/crates/accshift-app/src/lib.rs index ce34b8d..6e7744e 100644 --- a/crates/accshift-app/src/lib.rs +++ b/crates/accshift-app/src/lib.rs @@ -3,7 +3,8 @@ //! - [`backend::Backend`]: one blocking method per Tauri command the frontend //! calls, with the same payloads ([`types`]) and error strings ([`error`]). //! - [`mock::MockBackend`]: the TS mock's data, for parity work. -//! - [`real::RealBackend`]: the real backend, a skeleton until it is ported. +//! - [`real::RealBackend`]: the real backend on `accshift-core`, the commands +//! in [`real::PORTED`] filled, the others still "not ported". //! - [`jobs`]: runs calls off the UI thread and wakes the UI on each result. //! - [`dispatch`]: the same commands by name, with JSON arguments. //! - [`storage`]: the raw client stores, their saves and refreshes. diff --git a/crates/accshift-app/src/real.rs b/crates/accshift-app/src/real.rs index 0c2123b..624e8f1 100644 --- a/crates/accshift-app/src/real.rs +++ b/crates/accshift-app/src/real.rs @@ -1,126 +1,171 @@ -//! The real backend, a skeleton for now. +//! The real backend: every command runs on `accshift-core` the way +//! `src-tauri/src/commands` does, on the same app directories. //! -//! [`RealBackend`] will run every command on `accshift-core` the way -//! `src-tauri/src/commands` does. Until a later job ports each body, every -//! method answers a "not ported" error, so nothing here can touch Steam, the -//! keyring or the registry yet. +//! Part 1 of the port (boot, storage, themes, PIN, descriptors, utilities) +//! is filled; the methods listed outside [`PORTED`] still answer "not +//! ported". No trait method touches Steam, the keyring or the registry yet; +//! [`RealBackend::spawn_maintenance`] reaches the keyring, and only when the +//! shell calls it. +//! The state the Tauri app keeps with `.manage(..)` (`BootState`, +//! `PinSession`, the HTTP client) lives in [`RealBackend`]. Window work +//! (show, backdrop subclass, geometry, the boot failsafe) is the shell's. + +mod app; +mod boot; +mod context; +mod descriptors; +mod pin; +mod utility; +mod wallpaper; + +pub use app::{UI_DETAILS_MAX_BYTES, UI_LOG_CAP}; +pub use boot::BootState; +pub use context::{EguiAppContext, BUNDLE_IDENTIFIER}; +pub use descriptors::{SELECT_FILTERS, SELECT_TITLE}; use crate::backend::Backend; use crate::error::{BackendError, BackendResult}; use crate::types::*; -use accshift_core::AppContext; -use directories::BaseDirs; +use accshift_core::pin::PinSession; use serde_json::Value; use std::collections::BTreeMap; -use std::path::PathBuf; -use std::sync::Arc; - -/// Must match `identifier` in `src-tauri/tauri.conf.json`, like the CLI's. -const BUNDLE_IDENTIFIER: &str = "com.accshift.desktop"; - -/// The app directories, laid out like the Tauri GUI's `AppHandle::path()` -/// (`/`) so config, state and cache stay -/// shared with the Tauri app and the CLI. Same as -/// `crates/accshift-cli/src/context.rs`. -pub struct EguiAppContext { - base: BaseDirs, - identifier: String, -} - -impl EguiAppContext { - pub fn new() -> Result { - let base = BaseDirs::new().ok_or_else(|| { - "Could not resolve base directories (no HOME or %APPDATA%?)".to_string() - })?; - Ok(Self { - base, - identifier: BUNDLE_IDENTIFIER.to_string(), - }) - } -} +use std::sync::atomic::AtomicU32; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; -impl AppContext for EguiAppContext { - fn app_config_dir(&self) -> Result { - Ok(self.base.config_dir().join(&self.identifier)) - } +/// Cross-process lock budget of every mutating command (`LOCK_TIMEOUT` in +/// `src-tauri/src/commands/mod.rs`). +pub const LOCK_TIMEOUT: Duration = Duration::from_secs(2); - fn app_data_dir(&self) -> Result { - Ok(self.base.data_dir().join(&self.identifier)) - } +/// The commands [`RealBackend`] runs for real. The others still answer +/// [`BackendError::not_ported`]. +pub const PORTED: &[&str] = &[ + "get_boot_payload", + "get_runtime_os", + "detect_streaming_software", + "migrate_legacy_config", + "load_client_storage_snapshot", + "save_client_storage_store", + "get_storage_manifest", + "log_app_event", + "finish_boot", + "pin_unlock", + "pin_lock", + "reload_user_platforms", + "descriptor_select_file", + "descriptor_preview_file", + "descriptor_install_file", + "descriptor_remove", + "open_descriptors_folder", + "open_url", + "open_logs_folder", + "get_desktop_wallpaper", + "list_custom_themes", + "save_custom_theme", + "delete_custom_theme", +]; - fn app_local_data_dir(&self) -> Result { - Ok(self.base.data_local_dir().join(&self.identifier)) - } - - fn app_cache_dir(&self) -> Result { - Ok(self.base.cache_dir().join(&self.identifier)) - } -} - -/// See the module documentation. +/// See the module documentation. One per process: the PIN session, the boot +/// state and the UI log cap are per instance. pub struct RealBackend { ctx: accshift_core::AppCtx, + boot: BootState, + pin: PinSession, + http: OnceLock, + ui_logs_written: AtomicU32, } impl RealBackend { - /// On the user's real app directories. + /// On the user's real app directories, with the HTTP client built up + /// front like the Tauri app does before its first plugin. pub fn new() -> Result { - Ok(Self::with_context(Arc::new(EguiAppContext::new()?))) + let backend = Self::with_context(Arc::new(EguiAppContext::new()?)); + backend.http_client()?; + Ok(backend) } - /// On any directory layout (tests, a parity scratch folder). + /// On any directory layout (tests, a parity scratch folder). The HTTP + /// client is built on first use. pub fn with_context(ctx: accshift_core::AppCtx) -> Self { - Self { ctx } + Self { + ctx, + boot: BootState::default(), + pin: PinSession::new(), + http: OnceLock::new(), + ui_logs_written: AtomicU32::new(0), + } } pub fn context(&self) -> &accshift_core::AppCtx { &self.ctx } + + /// The boot state `finish_boot` marks; the shell reads it for its + /// failsafe. + pub fn boot_state(&self) -> &BootState { + &self.boot + } + + /// The PIN session every switch command checks + /// (`PinSession::ensure_unlocked`). + pub fn pin_session(&self) -> &PinSession { + &self.pin + } + + /// The shared HTTP client (`boot::build_http_client`). + pub fn http_client(&self) -> Result<&reqwest::Client, String> { + if let Some(client) = self.http.get() { + return Ok(client); + } + let client = boot::build_http_client()?; + Ok(self.http.get_or_init(|| client)) + } } impl Backend for RealBackend { fn get_boot_payload(&self) -> BackendResult { - Err(BackendError::not_ported("get_boot_payload")) + app::boot_payload(&self.ctx) } fn get_runtime_os(&self) -> BackendResult { - Err(BackendError::not_ported("get_runtime_os")) + Ok(app::runtime_os()) } fn detect_streaming_software(&self) -> BackendResult { - Err(BackendError::not_ported("detect_streaming_software")) + Ok(utility::streaming_software_running()) } fn migrate_legacy_config(&self) -> BackendResult { - Err(BackendError::not_ported("migrate_legacy_config")) + Ok(app::migrate_legacy_config(&self.ctx)) } fn load_client_storage_snapshot(&self) -> BackendResult { - Err(BackendError::not_ported("load_client_storage_snapshot")) + app::load_snapshot(&self.ctx) } fn save_client_storage_store( &self, - _store_id: &str, - _value: &Value, + store_id: &str, + value: &Value, ) -> BackendResult> { - Err(BackendError::not_ported("save_client_storage_store")) + app::save_store(self, store_id, value) } fn get_storage_manifest(&self) -> BackendResult { - Err(BackendError::not_ported("get_storage_manifest")) + app::manifest(&self.ctx) } fn log_app_event( &self, - _level: &str, - _source: &str, - _message: &str, - _details: Option<&str>, + level: &str, + source: &str, + message: &str, + details: Option<&str>, ) -> BackendResult<()> { - Err(BackendError::not_ported("log_app_event")) + app::log_app_event(self, level, source, message, details) } - fn finish_boot(&self, _source: &str, _marks: Option<&Value>) -> BackendResult<()> { - Err(BackendError::not_ported("finish_boot")) + fn finish_boot(&self, source: &str, marks: Option<&Value>) -> BackendResult<()> { + app::finish_boot(self, source, marks) } - fn pin_unlock(&self, _code: &str) -> BackendResult { - Err(BackendError::not_ported("pin_unlock")) + fn pin_unlock(&self, code: &str) -> BackendResult { + pin::unlock(self, code) } fn pin_lock(&self) -> BackendResult<()> { - Err(BackendError::not_ported("pin_lock")) + pin::lock(self); + Ok(()) } fn platform_get_accounts(&self, _platform_id: &str) -> BackendResult { Err(BackendError::not_ported("platform_get_accounts")) @@ -183,31 +228,40 @@ impl Backend for RealBackend { Err(BackendError::not_ported("platform_dry_run")) } fn reload_user_platforms(&self) -> BackendResult { - Err(BackendError::not_ported("reload_user_platforms")) - } + Ok(descriptors::reload(&self.ctx)) + } + /// Thread constraint: the `rfd` dialog runs on the thread that calls this + /// and blocks it until the user picks or cancels, so the egui shell + /// chooses that thread. On Windows and Linux (xdg portal) any thread + /// works: give it a job thread, never the UI thread, or the window + /// freezes behind the dialog. On macOS `rfd` hops to the main thread + /// itself, so the UI thread must keep running its event loop while the + /// job waits. The dialog has no parent window: it is not modal to ours. fn descriptor_select_file(&self) -> BackendResult { - Err(BackendError::not_ported("descriptor_select_file")) + descriptors::select_file() } - fn descriptor_preview_file(&self, _path: &str) -> BackendResult { - Err(BackendError::not_ported("descriptor_preview_file")) + fn descriptor_preview_file(&self, path: &str) -> BackendResult { + descriptors::preview(&self.ctx, path) } - fn descriptor_install_file(&self, _path: &str) -> BackendResult { - Err(BackendError::not_ported("descriptor_install_file")) + fn descriptor_install_file(&self, path: &str) -> BackendResult { + descriptors::install(&self.ctx, path) } - fn descriptor_remove(&self, _platform_id: &str) -> BackendResult { - Err(BackendError::not_ported("descriptor_remove")) + fn descriptor_remove(&self, platform_id: &str) -> BackendResult { + descriptors::remove(&self.ctx, platform_id) } fn open_descriptors_folder(&self) -> BackendResult<()> { - Err(BackendError::not_ported("open_descriptors_folder")) + descriptors::open_folder(&self.ctx) } - fn open_url(&self, _url: &str) -> BackendResult<()> { - Err(BackendError::not_ported("open_url")) + fn open_url(&self, url: &str) -> BackendResult<()> { + utility::open_url(url) } fn open_logs_folder(&self) -> BackendResult<()> { - Err(BackendError::not_ported("open_logs_folder")) + utility::open_logs_folder(&self.ctx) } + /// Captures on the calling thread (a job thread): see + /// `real/wallpaper.rs` for how that differs from the Tauri command. fn get_desktop_wallpaper(&self) -> BackendResult> { - Err(BackendError::not_ported("get_desktop_wallpaper")) + Ok(wallpaper::desktop_wallpaper()) } fn steam_set_api_key(&self, _key: &str) -> BackendResult<()> { Err(BackendError::not_ported("steam_set_api_key")) @@ -302,13 +356,13 @@ impl Backend for RealBackend { Err(BackendError::not_ported("roblox_check_sessions")) } fn list_custom_themes(&self) -> BackendResult> { - Err(BackendError::not_ported("list_custom_themes")) + utility::list_themes(&self.ctx) } - fn save_custom_theme(&self, _theme: &CustomTheme) -> BackendResult<()> { - Err(BackendError::not_ported("save_custom_theme")) + fn save_custom_theme(&self, theme: &CustomTheme) -> BackendResult<()> { + utility::save_theme(&self.ctx, theme) } - fn delete_custom_theme(&self, _theme_id: &str) -> BackendResult<()> { - Err(BackendError::not_ported("delete_custom_theme")) + fn delete_custom_theme(&self, theme_id: &str) -> BackendResult<()> { + utility::delete_theme(&self.ctx, theme_id) } fn telemetry_get_state(&self) -> BackendResult { Err(BackendError::not_ported("telemetry_get_state")) diff --git a/crates/accshift-app/src/real/app.rs b/crates/accshift-app/src/real/app.rs new file mode 100644 index 0000000..484bbf4 --- /dev/null +++ b/crates/accshift-app/src/real/app.rs @@ -0,0 +1,177 @@ +//! App lifecycle commands: boot payload, logs, `finish_boot` and client +//! storage. Port of `src-tauri/src/commands/app.rs`. + +use super::{RealBackend, LOCK_TIMEOUT}; +use crate::error::BackendResult; +use crate::types::{BootPayload, ClientStorageSnapshot, StorageManifest}; +use accshift_core::error::PlatformError; +use accshift_core::{config, logging, platforms, storage, themes, AppContext}; +use serde_json::Value; +use std::sync::atomic::Ordering; + +/// Per-session ceiling on UI-originated log records (`WEBVIEW_LOG_CAP`). +pub const UI_LOG_CAP: u32 = 2_000; + +/// UI log details are cut to this many bytes before redaction runs +/// (`WEBVIEW_DETAILS_MAX_BYTES`). +pub const UI_DETAILS_MAX_BYTES: usize = 4_096; + +/// `get_runtime_os`, app.rs lines 6-9. +pub(super) fn runtime_os() -> String { + std::env::consts::OS.to_string() +} + +/// `migrate_legacy_config_inner`, app.rs lines 26-32. +pub(super) fn migrate_legacy_config(c: &dyn AppContext) -> String { + match config::migrate_legacy_config(c) { + None => "none".to_string(), + Some(Ok(())) => "migrated".to_string(), + Some(Err(e)) => format!("error:{e}"), + } +} + +/// `get_boot_payload`, app.rs lines 51-74. +pub(super) fn boot_payload(c: &dyn AppContext) -> BackendResult { + // Migration must land before anything reads config or stores. + let migration = migrate_legacy_config(c); + let storage_snapshot = storage::load_client_storage_snapshot(c).map_err(PlatformError::from)?; + // A missing themes dir is normal on a first run. + let custom_themes = themes::list_custom_themes(c).unwrap_or_default(); + // A rejected descriptor is reported, never fatal. + let user_platforms = platforms::reload_user_platforms(c); + Ok(BootPayload { + migration, + runtime_os: runtime_os(), + storage_snapshot, + custom_themes, + user_platforms, + }) +} + +/// `log_app_event`, app.rs lines 84-117. The cap counts per backend +/// instance, which is one per process like the Tauri command's static. +pub(super) fn log_app_event( + backend: &RealBackend, + level: &str, + source: &str, + message: &str, + details: Option<&str>, +) -> BackendResult<()> { + let written = backend.ui_logs_written.fetch_add(1, Ordering::Relaxed); + if written >= UI_LOG_CAP { + if written == UI_LOG_CAP { + let _ = logging::append_app_log( + &backend.ctx, + "warn", + "logging", + "Webview log cap reached; dropping further webview records this session", + None, + ); + } + return Ok(()); + } + let details = details + .map(|text| accshift_core::diagnostics::redact::trim_text(text, UI_DETAILS_MAX_BYTES)); + logging::append_app_log(&backend.ctx, level, source, message, details.as_deref())?; + Ok(()) +} + +/// `finish_boot`, app.rs lines 119-170, minus `show_main_window` (the egui +/// shell shows its own window). +pub(super) fn finish_boot( + backend: &RealBackend, + source: &str, + marks: Option<&Value>, +) -> BackendResult<()> { + let was_first_completion = backend.boot.mark_completed(); + if was_first_completion { + if let Some(marks) = marks { + accshift_core::diagnostics::event( + &accshift_core::diagnostics::catalog::STARTUP_FRONTEND, + ) + .source("frontend.boot") + .msg("Frontend startup profile") + .field("marks", marks.clone()) + .field("trigger", source.to_string()) + .emit(&backend.ctx); + } + } + let message = if was_first_completion { + "Boot completed" + } else { + "Boot completion requested again" + }; + let _ = logging::append_app_log(&backend.ctx, "info", source, message, None); + if was_first_completion { + backend.after_first_boot(); + } + Ok(()) +} + +/// `load_client_storage_snapshot`, app.rs lines 243-263. +pub(super) fn load_snapshot(c: &dyn AppContext) -> BackendResult { + let snapshot = storage::load_client_storage_snapshot(c)?; + let details = serde_json::json!({ + "storeCount": snapshot.stores.len(), + "manifestCount": snapshot.manifest.stores.len(), + "schemaVersion": snapshot.manifest.schema_version, + }) + .to_string(); + let _ = logging::append_app_log( + c, + "info", + "storage.load_snapshot", + "Loaded client storage snapshot", + Some(&details), + ); + Ok(snapshot) +} + +/// `save_client_storage_store`, app.rs lines 265-299. +pub(super) fn save_store( + backend: &RealBackend, + store_id: &str, + value: &Value, +) -> BackendResult> { + let c = &backend.ctx; + // The cross-process lock config writes take, held across the write. + let _write_lock = + accshift_core::lock::acquire_for_write(c, LOCK_TIMEOUT).map_err(|e| e.to_string())?; + // The PIN lives in this store: while the session is locked, a write may + // not turn it off or replace it. + if store_id == storage::STORE_SETTINGS { + backend.pin.check_settings_write(c, value)?; + } + let fingerprint = storage::save_client_store(c, store_id, value)?; + let details = serde_json::json!({ + "storeId": store_id, + "isNull": value.is_null(), + }) + .to_string(); + let _ = logging::append_app_log( + c, + "info", + "storage.save_store", + "Saved client storage store", + Some(&details), + ); + Ok(Some(fingerprint)) +} + +/// `get_storage_manifest`, app.rs lines 301-320. +pub(super) fn manifest(c: &dyn AppContext) -> BackendResult { + let manifest = storage::build_storage_manifest(c)?; + let details = serde_json::json!({ + "storeCount": manifest.stores.len(), + "schemaVersion": manifest.schema_version, + }) + .to_string(); + let _ = logging::append_app_log( + c, + "info", + "storage.get_manifest", + "Built storage manifest", + Some(&details), + ); + Ok(manifest) +} diff --git a/crates/accshift-app/src/real/boot.rs b/crates/accshift-app/src/real/boot.rs new file mode 100644 index 0000000..fa1d3b2 --- /dev/null +++ b/crates/accshift-app/src/real/boot.rs @@ -0,0 +1,205 @@ +//! What `src-tauri/src/lib.rs` (`run`, the `setup` closure) and +//! `src-tauri/src/boot.rs` do for the backend, without the window: the boot +//! state, the log session and panic hook, the startup maintenance thread and +//! the shared HTTP client. The window steps (`build_main_window`, the close +//! handler, the geometry saver, the boot failsafe that shows the window) and +//! the deep links belong to the egui shell. + +use super::RealBackend; +use accshift_core::{logging, AppCtx}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +/// `app_runtime::BootState`: whether the UI reported its first frame done. +#[derive(Debug, Default)] +pub struct BootState { + completed: AtomicBool, +} + +impl BootState { + /// True the first time only. + pub fn mark_completed(&self) -> bool { + !self.completed.swap(true, Ordering::SeqCst) + } + + pub fn is_completed(&self) -> bool { + self.completed.load(Ordering::SeqCst) + } +} + +/// `boot::build_http_client`, boot.rs lines 117-133: 10 s per request, 5 s to +/// connect. The Tauri app exits when it cannot build one; here the caller +/// gets the error. +pub(super) fn build_http_client() -> Result { + reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .connect_timeout(Duration::from_secs(5)) + .build() + .map_err(|e| format!("Fatal: failed to create HTTP client: {e}")) +} + +impl RealBackend { + /// The steps of the Tauri `setup` closure that only need the app + /// directories (lib.rs lines 121-153): rotate the log chain, write + /// "App setup started", install the panic hook. Call once per process, + /// before the first frame; tests never call it (it replaces the + /// process-wide panic hook). + pub fn begin_session(&self) { + let _ = logging::begin_log_session(&self.ctx); + let _ = logging::append_app_log( + &self.ctx, + "info", + "backend.startup", + "App setup started", + None, + ); + logging::install_panic_hook(self.ctx.clone()); + } + + /// `boot::spawn_snapshot_upgrade` (boot.rs lines 747-858) and the Riot + /// rollback sweep (lines 860-885): arms the keyring index, then on a + /// background thread re-encrypts legacy plaintext snapshots, purges the + /// plaintext backups, sweeps Riot rollback copies and frees orphaned + /// keyring entries, each step under the cross-process write lock. Call + /// once after [`Self::begin_session`]. Touches the real keyring, so + /// tests never call it. + pub fn spawn_maintenance(&self) -> std::thread::JoinHandle<()> { + spawn_snapshot_upgrade(self.ctx.clone()) + } + + /// The telemetry half of `finish_boot` (app.rs lines 153-167: + /// `emit_first_run_once`, `AppLaunched`, `emit_accounts_snapshots`). It + /// needs the telemetry runtime (`telemetry_runtime.rs`), which part 2 of + /// the port brings; until then a first boot sends nothing, which is what + /// the Tauri app does without consent. + pub(super) fn after_first_boot(&self) {} +} + +fn spawn_snapshot_upgrade(upgrade_ctx: AppCtx) -> std::thread::JoinHandle<()> { + accshift_core::secrets::init(&upgrade_ctx); + std::thread::spawn(move || { + let locked = |step: &str| match accshift_core::lock::acquire_for_write( + &upgrade_ctx, + Duration::from_secs(2), + ) { + Ok(guard) => Some(guard), + Err(error) => { + let _ = logging::append_app_log( + &upgrade_ctx, + "warn", + "backend.snapshot-upgrade", + &format!("Skipped {step} because the store could not be locked"), + Some(&error.to_string()), + ); + None + } + }; + + let mut failures: Vec = Vec::new(); + let mut stats = accshift_core::snapshot_crypto::LegacyUpgradeStats::default(); + for platform_id in accshift_core::snapshot_crypto::SNAPSHOT_PLATFORM_IDS { + let Some(_operation) = locked("snapshot upgrade") else { + return; + }; + stats.merge( + accshift_core::snapshot_crypto::upgrade_legacy_plaintext_platform( + &upgrade_ctx, + platform_id, + &mut |message, detail| failures.push(format!("{message} ({detail})")), + ), + ); + } + if stats.touched_anything() { + let level = if stats.failed > 0 { "warn" } else { "info" }; + let _ = logging::append_app_log( + &upgrade_ctx, + level, + "backend.snapshot-upgrade", + &format!( + "Re-encrypted {} legacy plaintext snapshot file(s), {} failed", + stats.upgraded, stats.failed + ), + (!failures.is_empty()) + .then(|| failures.join("; ")) + .as_deref(), + ); + } + + if let Some(_operation) = locked("snapshot backup purge") { + let mut purge_failures: Vec = Vec::new(); + let purged = accshift_core::storage::purge_migrated_snapshot_backups( + &upgrade_ctx, + &mut |message, detail| purge_failures.push(format!("{message} ({detail})")), + ); + if purged > 0 || !purge_failures.is_empty() { + let _ = logging::append_app_log( + &upgrade_ctx, + if purge_failures.is_empty() { + "info" + } else { + "warn" + }, + "backend.snapshot-upgrade", + &format!("Deleted {purged} plaintext pre-migration snapshot backup(s)"), + (!purge_failures.is_empty()) + .then(|| purge_failures.join("; ")) + .as_deref(), + ); + } + } + + if let Some(_operation) = locked("rollback sweep") { + sweep_riot_rollback_copies(&upgrade_ctx); + } + + let Some(_operation) = locked("keyring sweep") else { + return; + }; + let mut sweep_failures: Vec = Vec::new(); + let swept = accshift_core::secrets::gc(&upgrade_ctx, &mut |message, detail| { + sweep_failures.push(format!("{message} ({detail})")) + }); + if swept.touched_anything() { + let level = if swept.failed > 0 { "warn" } else { "info" }; + let _ = logging::append_app_log( + &upgrade_ctx, + level, + "backend.secrets-gc", + &format!( + "Freed {} orphaned keyring entry(ies), {} failed", + swept.freed, swept.failed + ), + (!sweep_failures.is_empty()) + .then(|| sweep_failures.join("; ")) + .as_deref(), + ); + } + }) +} + +#[cfg(windows)] +fn sweep_riot_rollback_copies(upgrade_ctx: &AppCtx) { + let mut failures: Vec = Vec::new(); + let swept = accshift_core::platforms::riot::sweep_rollback_dirs(upgrade_ctx, &mut |m, d| { + failures.push(format!("{m} ({d})")) + }); + if !swept.touched_anything() { + return; + } + let level = if swept.failed > 0 { "warn" } else { "info" }; + let _ = logging::append_app_log( + upgrade_ctx, + level, + "backend.riot-rollback-sweep", + &format!( + "Removed {} leftover Riot rollback copy(ies), {} failed", + swept.removed, swept.failed + ), + (!failures.is_empty()) + .then(|| failures.join("; ")) + .as_deref(), + ); +} + +#[cfg(not(windows))] +fn sweep_riot_rollback_copies(_upgrade_ctx: &AppCtx) {} diff --git a/crates/accshift-app/src/real/context.rs b/crates/accshift-app/src/real/context.rs new file mode 100644 index 0000000..83ab584 --- /dev/null +++ b/crates/accshift-app/src/real/context.rs @@ -0,0 +1,64 @@ +//! Where the real backend reads and writes: the Tauri app's own folders. + +use accshift_core::AppContext; +use std::path::PathBuf; + +/// Must match `identifier` in `src-tauri/tauri.conf.json`, like the CLI's. +pub const BUNDLE_IDENTIFIER: &str = "com.accshift.desktop"; + +/// The app directories exactly as Tauri's path resolver computes them +/// (`tauri-2.11/src/path/desktop.rs`): `dirs::config_dir()`, +/// `dirs::data_dir()`, `dirs::data_local_dir()` and `dirs::cache_dir()`, each +/// joined with the bundle identifier. Same `dirs` major as Tauri, so config, +/// state, stores, themes, descriptors and logs stay shared with the Tauri app +/// and the CLI. +/// +/// | | Windows | Linux | macOS | +/// |---|---|---|---| +/// | config | `%APPDATA%\` | `$XDG_CONFIG_HOME/` or `~/.config/` | `~/Library/Application Support/` | +/// | data | `%APPDATA%\` | `$XDG_DATA_HOME/` or `~/.local/share/` | `~/Library/Application Support/` | +/// | local data | `%LOCALAPPDATA%\` | same as data | `~/Library/Application Support/` | +/// | cache | `%LOCALAPPDATA%\` | `$XDG_CACHE_HOME/` or `~/.cache/` | `~/Library/Caches/` | +/// +/// Logs are not Tauri's `app_log_dir`: the app writes them under +/// `accshift_core::storage::app_log_root`, `/logs` (`/logs/dev` +/// in a debug build). +#[derive(Debug, Clone)] +pub struct EguiAppContext { + identifier: String, +} + +impl EguiAppContext { + /// Never fails today: as with Tauri's resolver, a missing home folder + /// surfaces when a directory is asked for. `Result` keeps the skeleton's + /// signature. + pub fn new() -> Result { + Ok(Self { + identifier: BUNDLE_IDENTIFIER.to_string(), + }) + } + + fn resolve(&self, what: &str, base: Option) -> Result { + // Same message shape as `TauriAppContext::resolve`. + base.map(|dir| dir.join(&self.identifier)) + .ok_or_else(|| format!("Could not resolve app {what} dir: unknown path")) + } +} + +impl AppContext for EguiAppContext { + fn app_config_dir(&self) -> Result { + self.resolve("config", dirs::config_dir()) + } + + fn app_data_dir(&self) -> Result { + self.resolve("data", dirs::data_dir()) + } + + fn app_local_data_dir(&self) -> Result { + self.resolve("local data", dirs::data_local_dir()) + } + + fn app_cache_dir(&self) -> Result { + self.resolve("cache", dirs::cache_dir()) + } +} diff --git a/crates/accshift-app/src/real/descriptors.rs b/crates/accshift-app/src/real/descriptors.rs new file mode 100644 index 0000000..ed3f24d --- /dev/null +++ b/crates/accshift-app/src/real/descriptors.rs @@ -0,0 +1,60 @@ +//! User descriptors: the folder is the truth, these read it, preview a file, +//! install or remove one. Port of `src-tauri/src/commands/descriptors.rs`. + +use crate::error::BackendResult; +use crate::types::{DescriptorPreview, UserPlatformReport}; +use accshift_core::error::{AppError, PlatformError}; +use accshift_core::platforms::descriptor::library; +use accshift_core::{os, platforms, AppContext}; +use std::path::Path; + +/// Title and filters of the Tauri picker (descriptors.rs lines 25-34, the +/// `accshift_core::os::select_file` arguments +/// `"Platform descriptor (*.json)|*.json|All files (*.*)|*.*"`). +pub const SELECT_TITLE: &str = "Select a platform descriptor"; +pub const SELECT_FILTERS: &[(&str, &[&str])] = + &[("Platform descriptor", &["json"]), ("All files", &["*"])]; + +/// `reload_user_platforms`, descriptors.rs lines 6-19. +pub(super) fn reload(c: &dyn AppContext) -> UserPlatformReport { + platforms::reload_user_platforms(c) +} + +/// `descriptor_select_file`, descriptors.rs lines 21-34, on `rfd` instead of +/// the PowerShell `OpenFileDialog`. Cancelling answers `Dialog cancelled` +/// (`AppError::Cancelled`), as the Tauri command does. +pub(super) fn select_file() -> BackendResult { + let mut dialog = rfd::FileDialog::new().set_title(SELECT_TITLE); + for (name, extensions) in SELECT_FILTERS { + dialog = dialog.add_filter(*name, extensions); + } + match dialog.pick_file() { + Some(path) => Ok(path.display().to_string()), + None => Err(PlatformError::from(AppError::Cancelled).into()), + } +} + +/// `descriptor_preview_file`, descriptors.rs lines 36-49. +pub(super) fn preview(c: &dyn AppContext, path: &str) -> BackendResult { + library::preview_file(c, Path::new(path)) + .map_err(|e| PlatformError::other(e.to_string()).into()) +} + +/// `descriptor_install_file`, descriptors.rs lines 51-68. +pub(super) fn install(c: &dyn AppContext, path: &str) -> BackendResult { + library::install_file(c, Path::new(path)).map_err(PlatformError::other)?; + Ok(reload(c)) +} + +/// `descriptor_remove`, descriptors.rs lines 70-83. +pub(super) fn remove(c: &dyn AppContext, platform_id: &str) -> BackendResult { + library::remove(c, platform_id).map_err(PlatformError::other)?; + Ok(reload(c)) +} + +/// `open_descriptors_folder`, descriptors.rs lines 85-92: creates the folder +/// first, then reveals it. +pub(super) fn open_folder(c: &dyn AppContext) -> BackendResult<()> { + let dir = library::ensure_user_dir(c).map_err(PlatformError::other)?; + os::open_folder(&dir).map_err(|e| PlatformError::from(e).into()) +} diff --git a/crates/accshift-app/src/real/pin.rs b/crates/accshift-app/src/real/pin.rs new file mode 100644 index 0000000..6b2977f --- /dev/null +++ b/crates/accshift-app/src/real/pin.rs @@ -0,0 +1,33 @@ +//! The PIN lock. Port of `src-tauri/src/commands/pin.rs`. + +use super::RealBackend; +use crate::error::BackendResult; +use crate::types::PinUnlockResult; +use accshift_core::pin::UnlockOutcome; + +/// `impl From for PinUnlockResult`, pin.rs lines 24-50. +pub(super) fn unlock_result(outcome: UnlockOutcome) -> PinUnlockResult { + let ms = |d: std::time::Duration| d.as_millis().min(u128::from(u64::MAX)) as u64; + let (status, legacy, retry_after_ms) = match outcome { + UnlockOutcome::Unlocked { legacy } => ("unlocked", legacy, 0), + UnlockOutcome::Invalid { retry_after } => ("invalid", false, ms(retry_after)), + UnlockOutcome::RetryLater { retry_after } => ("retry_later", false, ms(retry_after)), + UnlockOutcome::NotConfigured => ("not_configured", false, 0), + }; + PinUnlockResult { + status: status.to_string(), + legacy, + retry_after_ms, + } +} + +/// `pin_unlock`, pin.rs lines 52-67. PBKDF2 at 100k rounds: run it on the +/// job pool, never on the UI thread. +pub(super) fn unlock(backend: &RealBackend, code: &str) -> BackendResult { + Ok(unlock_result(backend.pin.unlock(&backend.ctx, code)?)) +} + +/// `pin_lock`, pin.rs lines 69-74. +pub(super) fn lock(backend: &RealBackend) { + backend.pin.lock(); +} diff --git a/crates/accshift-app/src/real/utility.rs b/crates/accshift-app/src/real/utility.rs new file mode 100644 index 0000000..d01680a --- /dev/null +++ b/crates/accshift-app/src/real/utility.rs @@ -0,0 +1,43 @@ +//! Utility commands: open a URL, open the logs folder, custom themes, +//! streaming detection. Port of `src-tauri/src/commands/utility.rs`, +//! `themes.rs` and `detect_streaming_software` in `app.rs`. + +use crate::error::BackendResult; +use crate::types::CustomTheme; +use accshift_core::error::PlatformError; +use accshift_core::{os, storage, themes, AppContext}; + +/// `open_url`, utility.rs lines 6-9: `accshift_core::os::open_url`, which +/// trims, refuses any scheme outside `http`, `https`, `mailto`, `steam` and +/// `roblox-player`, then hands the URL to `open::that_detached`. +pub(super) fn open_url(url: &str) -> BackendResult<()> { + os::open_url(url).map_err(|e| PlatformError::from(e).into()) +} + +/// `open_logs_folder`, utility.rs lines 11-20. +pub(super) fn open_logs_folder(c: &dyn AppContext) -> BackendResult<()> { + let dir = storage::app_log_root(c).map_err(PlatformError::other)?; + std::fs::create_dir_all(&dir) + .map_err(|e| PlatformError::other(format!("create log dir: {e}")))?; + os::open_folder(&dir).map_err(|e| PlatformError::from(e).into()) +} + +/// `detect_streaming_software`, app.rs lines 11-17. +pub(super) fn streaming_software_running() -> bool { + os::is_streaming_software_running() +} + +/// `list_custom_themes`, themes.rs lines 6-11. +pub(super) fn list_themes(c: &dyn AppContext) -> BackendResult> { + Ok(themes::list_custom_themes(c)?) +} + +/// `save_custom_theme`, themes.rs lines 13-19. +pub(super) fn save_theme(c: &dyn AppContext, theme: &CustomTheme) -> BackendResult<()> { + Ok(themes::save_custom_theme(c, theme)?) +} + +/// `delete_custom_theme`, themes.rs lines 21-24. +pub(super) fn delete_theme(c: &dyn AppContext, theme_id: &str) -> BackendResult<()> { + Ok(themes::delete_custom_theme(c, theme_id)?) +} diff --git a/crates/accshift-app/src/real/wallpaper.rs b/crates/accshift-app/src/real/wallpaper.rs new file mode 100644 index 0000000..0da8418 --- /dev/null +++ b/crates/accshift-app/src/real/wallpaper.rs @@ -0,0 +1,276 @@ +//! `get_desktop_wallpaper`: the desktop picture behind the window, for the +//! glass themes. Port of the capture in `src-tauri/src/commands/backdrop.rs` +//! (lines 60-321) on `windows-sys`. `None` outside Windows, as there. + +use crate::types::WallpaperSnapshot; + +/// The capture, the cache and the encoding of backdrop.rs, called on the +/// calling thread. +/// +/// The Tauri command runs `PrintWindow` on the main thread (its event loop) +/// and the encoding on the blocking pool. The egui backend runs both on the +/// thread that calls it, which is a job thread: `PrintWindow` on Progman +/// talks to Explorer's own thread across processes, so it needs no window or +/// message loop of ours. +#[cfg(windows)] +pub(super) fn desktop_wallpaper() -> Option { + let key = capture::current_key(); + if let Some(key) = key.as_ref() { + if let Some(hit) = capture::cached(key) { + return Some(hit); + } + } + let snapshot = capture::encode(capture::capture()?)?; + if let Some(key) = key { + capture::store(key, &snapshot); + } + Some(snapshot) +} + +#[cfg(not(windows))] +pub(super) fn desktop_wallpaper() -> Option { + None +} + +#[cfg(windows)] +mod capture { + use super::WallpaperSnapshot; + use base64::Engine; + use std::path::PathBuf; + use std::sync::Mutex; + use std::time::SystemTime; + use windows_sys::core::BOOL; + use windows_sys::Win32::Foundation::{HWND, LPARAM, RECT}; + use windows_sys::Win32::Graphics::Gdi::{ + CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDC, GetDIBits, + ReleaseDC, SelectObject, SetBrushOrgEx, SetStretchBltMode, StretchBlt, BITMAPINFO, + BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, HALFTONE, SRCCOPY, + }; + use windows_sys::Win32::Storage::Xps::{PrintWindow, PRINT_WINDOW_FLAGS}; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + EnumWindows, GetClassNameW, GetSystemMetrics, GetWindowRect, SM_CXVIRTUALSCREEN, + SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, + }; + + /// Makes PrintWindow capture DWM-composed content, where the wallpaper + /// lives on Windows 11. + const PW_RENDERFULLCONTENT: PRINT_WINDOW_FLAGS = 2; + /// Longest edge of the returned snapshot. + const MAX_EDGE: i32 = 3200; + + pub(super) struct CapturedWallpaper { + bgra: Vec, + output_width: i32, + output_height: i32, + x: i32, + y: i32, + width: i32, + height: i32, + } + + unsafe extern "system" fn find_progman(hwnd: HWND, lparam: LPARAM) -> BOOL { + let mut class = [0u16; 16]; + let len = unsafe { GetClassNameW(hwnd, class.as_mut_ptr(), class.len() as i32) }; + let len = usize::try_from(len).unwrap_or(0).min(class.len()); + if String::from_utf16_lossy(&class[..len]) == "Progman" { + unsafe { *(lparam as *mut HWND) = hwnd }; + return 0; + } + 1 + } + + /// Snapshot of the shell's wallpaper as rendered (Progman + + /// PW_RENDERFULLCONTENT), backdrop.rs lines 119-252. + pub(super) fn capture() -> Option { + unsafe { + let mut progman: HWND = std::ptr::null_mut(); + // Fails when the callback stops the enumeration: ignore it. + let _ = EnumWindows(Some(find_progman), &mut progman as *mut HWND as LPARAM); + if progman.is_null() { + return None; + } + let mut rect: RECT = std::mem::zeroed(); + if GetWindowRect(progman, &mut rect) == 0 { + return None; + } + let width = rect.right - rect.left; + let height = rect.bottom - rect.top; + if width <= 0 || height <= 0 { + return None; + } + let shrink = (width.max(height) + MAX_EDGE - 1) / MAX_EDGE; + let (out_w, out_h) = ( + (width / shrink.max(1)).max(1), + (height / shrink.max(1)).max(1), + ); + + let screen_dc = GetDC(std::ptr::null_mut()); + if screen_dc.is_null() { + return None; + } + let full_dc = CreateCompatibleDC(screen_dc); + let full_bmp = CreateCompatibleBitmap(screen_dc, width, height); + let small_dc = CreateCompatibleDC(screen_dc); + let small_bmp = CreateCompatibleBitmap(screen_dc, out_w, out_h); + if full_dc.is_null() || small_dc.is_null() || full_bmp.is_null() || small_bmp.is_null() + { + if !full_bmp.is_null() { + DeleteObject(full_bmp); + } + if !small_bmp.is_null() { + DeleteObject(small_bmp); + } + if !full_dc.is_null() { + DeleteDC(full_dc); + } + if !small_dc.is_null() { + DeleteDC(small_dc); + } + ReleaseDC(std::ptr::null_mut(), screen_dc); + return None; + } + + let mut pixels: Option> = None; + let old_full = SelectObject(full_dc, full_bmp); + let old_small = SelectObject(small_dc, small_bmp); + if PrintWindow(progman, full_dc, PW_RENDERFULLCONTENT) != 0 { + SetStretchBltMode(small_dc, HALFTONE); + SetBrushOrgEx(small_dc, 0, 0, std::ptr::null_mut()); + if StretchBlt( + small_dc, 0, 0, out_w, out_h, full_dc, 0, 0, width, height, SRCCOPY, + ) != 0 + { + // GetDIBits wants the bitmap deselected from its DC. + SelectObject(small_dc, old_small); + let mut info: BITMAPINFO = std::mem::zeroed(); + info.bmiHeader = BITMAPINFOHEADER { + biSize: std::mem::size_of::() as u32, + biWidth: out_w, + biHeight: -out_h, // top-down + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB, + ..std::mem::zeroed() + }; + let mut bgra = vec![0u8; (out_w as usize) * (out_h as usize) * 4]; + if GetDIBits( + small_dc, + small_bmp, + 0, + out_h as u32, + bgra.as_mut_ptr().cast(), + &mut info, + DIB_RGB_COLORS, + ) == out_h + { + pixels = Some(bgra); + } + } else { + SelectObject(small_dc, old_small); + } + } else { + SelectObject(small_dc, old_small); + } + SelectObject(full_dc, old_full); + DeleteObject(small_bmp); + DeleteObject(full_bmp); + DeleteDC(small_dc); + DeleteDC(full_dc); + ReleaseDC(std::ptr::null_mut(), screen_dc); + + Some(CapturedWallpaper { + bgra: pixels?, + output_width: out_w, + output_height: out_h, + x: rect.left, + y: rect.top, + width, + height, + }) + } + } + + /// BGRA to RGB in place, JPEG at quality 82, base64 data URL, + /// backdrop.rs lines 254-292. + pub(super) fn encode(capture: CapturedWallpaper) -> Option { + let mut bgra = capture.bgra; + let n = bgra.len() / 4; + for i in 0..n { + let (b, g, r) = (bgra[4 * i], bgra[4 * i + 1], bgra[4 * i + 2]); + bgra[3 * i] = r; + bgra[3 * i + 1] = g; + bgra[3 * i + 2] = b; + } + bgra.truncate(n * 3); + let mut jpeg = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 82) + .encode( + &bgra, + capture.output_width as u32, + capture.output_height as u32, + image::ExtendedColorType::Rgb8, + ) + .ok()?; + const PREFIX: &str = "data:image/jpeg;base64,"; + let mut data_url = String::with_capacity(PREFIX.len() + jpeg.len().div_ceil(3) * 4); + data_url.push_str(PREFIX); + base64::engine::general_purpose::STANDARD.encode_string(&jpeg, &mut data_url); + Some(WallpaperSnapshot { + data_url, + x: capture.x, + y: capture.y, + width: capture.width, + height: capture.height, + }) + } + + /// What the last answer was computed from: `TranscodedWallpaper`'s size + /// and mtime plus the virtual-screen rect, backdrop.rs lines 294-350. + #[derive(PartialEq, Eq)] + pub(super) struct WallpaperKey { + modified: Option, + len: u64, + virtual_rect: (i32, i32, i32, i32), + } + + static CACHE: Mutex> = Mutex::new(None); + + fn transcoded_wallpaper_path() -> Option { + let appdata = std::env::var_os("APPDATA")?; + let mut path = PathBuf::from(appdata); + path.push("Microsoft"); + path.push("Windows"); + path.push("Themes"); + path.push("TranscodedWallpaper"); + Some(path) + } + + /// `None` when the identity cannot be read, which disables caching. + pub(super) fn current_key() -> Option { + let meta = std::fs::metadata(transcoded_wallpaper_path()?).ok()?; + let virtual_rect = unsafe { + ( + GetSystemMetrics(SM_XVIRTUALSCREEN), + GetSystemMetrics(SM_YVIRTUALSCREEN), + GetSystemMetrics(SM_CXVIRTUALSCREEN), + GetSystemMetrics(SM_CYVIRTUALSCREEN), + ) + }; + Some(WallpaperKey { + modified: meta.modified().ok(), + len: meta.len(), + virtual_rect, + }) + } + + pub(super) fn cached(key: &WallpaperKey) -> Option { + let guard = CACHE.lock().unwrap_or_else(|error| error.into_inner()); + let (stored, snapshot) = guard.as_ref()?; + (stored == key).then(|| snapshot.clone()) + } + + pub(super) fn store(key: WallpaperKey, snapshot: &WallpaperSnapshot) { + let mut guard = CACHE.lock().unwrap_or_else(|error| error.into_inner()); + *guard = Some((key, snapshot.clone())); + } +} diff --git a/crates/accshift-app/tests/data/real/descriptor.json b/crates/accshift-app/tests/data/real/descriptor.json new file mode 100644 index 0000000..44beac7 --- /dev/null +++ b/crates/accshift-app/tests/data/real/descriptor.json @@ -0,0 +1,56 @@ +{ + "id": "egui-fixture", + "schemaVersion": 1, + "name": "Fixture Launcher", + "shortName": "Fixture", + "os": { + "windows": { + "roots": { "files": ["${LIVE}"] }, + "detect": { "pathExists": ["${LIVE}/never-here"] }, + "identity": { + "source": { "kind": "synthetic" }, + "format": { "charset": "alphanumeric", "maxLength": 64 }, + "current": "config" + }, + "state": { + "files": [ + { "live": "${LIVE}/session.json", "snapshot": "session.json", "snapshotMarker": true } + ] + }, + "close": { "processes": ["nothing-here.exe"] }, + "setup": { "missingSnapshotHint": "Add this account through setup first." } + }, + "linux": { + "roots": { "files": ["${LIVE}"] }, + "detect": { "pathExists": ["${LIVE}/never-here"] }, + "identity": { + "source": { "kind": "synthetic" }, + "format": { "charset": "alphanumeric", "maxLength": 64 }, + "current": "config" + }, + "state": { + "files": [ + { "live": "${LIVE}/session.json", "snapshot": "session.json", "snapshotMarker": true } + ] + }, + "close": { "processes": ["nothing-here"] }, + "setup": { "missingSnapshotHint": "Add this account through setup first." } + }, + "macos": { + "roots": { "files": ["${LIVE}"] }, + "detect": { "pathExists": ["${LIVE}/never-here"] }, + "identity": { + "source": { "kind": "synthetic" }, + "format": { "charset": "alphanumeric", "maxLength": 64 }, + "current": "config" + }, + "state": { + "files": [ + { "live": "${LIVE}/session.json", "snapshot": "session.json", "snapshotMarker": true } + ] + }, + "close": { "processes": ["nothing-here"] }, + "setup": { "missingSnapshotHint": "Add this account through setup first." } + } + } +} diff --git a/crates/accshift-app/tests/data/real/legacy-config.json b/crates/accshift-app/tests/data/real/legacy-config.json new file mode 100644 index 0000000..05ebf6b --- /dev/null +++ b/crates/accshift-app/tests/data/real/legacy-config.json @@ -0,0 +1,10 @@ +{ + "riot": { + "profiles": [ + { "id": "riot-fixture-1", "label": "Main" }, + { "id": "riot-fixture-2", "label": "Smurf" } + ] + }, + "window_width": 1180, + "window_height": 760 +} diff --git a/crates/accshift-app/tests/data/real/theme.json b/crates/accshift-app/tests/data/real/theme.json new file mode 100644 index 0000000..56236a3 --- /dev/null +++ b/crates/accshift-app/tests/data/real/theme.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 2, + "id": "egui-fixture-theme", + "name": "Fixture Violet", + "author": "accshift tests", + "version": "1.0.0", + "colorScheme": "dark", + "extends": "dark", + "tokens": { + "accent": "#a78bfa", + "background": "#0f0d17" + } +} diff --git a/crates/accshift-app/tests/mock_parity.rs b/crates/accshift-app/tests/mock_parity.rs index 67dd123..b7c9fa0 100644 --- a/crates/accshift-app/tests/mock_parity.rs +++ b/crates/accshift-app/tests/mock_parity.rs @@ -3,7 +3,7 @@ use accshift_app::dispatch::dispatch; use accshift_app::jobs::{Jobs, Poll}; use accshift_app::mock::{cs2_settings_from_fixture, MockBackend, AVATAR_DIR, NOW}; -use accshift_app::real::RealBackend; +use accshift_app::real::{RealBackend, PORTED}; use accshift_app::{Backend, BackendError, COMMANDS}; use serde_json::{json, Value}; use std::path::{Path, PathBuf}; @@ -362,7 +362,7 @@ fn jobs_deliver_off_thread_and_wake() { } #[test] -fn the_real_skeleton_dispatches_every_command_and_ports_none() { +fn the_real_backend_dispatches_every_command_and_refuses_the_unported() { // A context on a scratch folder: nothing reads the user's real dirs. struct Scratch(PathBuf); impl accshift_core::AppContext for Scratch { @@ -381,7 +381,12 @@ fn the_real_skeleton_dispatches_every_command_and_ports_none() { } let real = RealBackend::with_context(Arc::new(Scratch(std::env::temp_dir()))); assert_eq!(COMMANDS.len(), 73); - for command in COMMANDS { + // The ported commands are not called here: some open a dialog, a URL or + // a folder. tests/real_*.rs covers them on a scratch context. + for command in PORTED { + assert!(COMMANDS.contains(command), "{command} is not a command"); + } + for command in COMMANDS.iter().filter(|command| !PORTED.contains(command)) { let answer = dispatch(&real, command, &sample_args(command)) .unwrap_or_else(|| panic!("{command} is not dispatched")); assert_eq!(answer, Err(BackendError::not_ported(command)), "{command}"); diff --git a/crates/accshift-app/tests/real_backend.rs b/crates/accshift-app/tests/real_backend.rs new file mode 100644 index 0000000..9e0bb2f --- /dev/null +++ b/crates/accshift-app/tests/real_backend.rs @@ -0,0 +1,512 @@ +//! `RealBackend` part 1 on scratch app directories: storage, legacy +//! migration, themes, descriptors, the PIN session, logs and boot. Nothing +//! here opens a window, a dialog, a URL or a folder, and nothing reads or +//! writes the real `%APPDATA%\com.accshift.desktop`. + +use accshift_app::dispatch::dispatch; +use accshift_app::real::{RealBackend, UI_DETAILS_MAX_BYTES, UI_LOG_CAP}; +use accshift_app::types::CustomTheme; +use accshift_app::{Backend, BackendError}; +use accshift_core::pin::{hash_pin_code, PIN_LOCKED_MESSAGE}; +use accshift_core::storage::{STORE_FOLDERS, STORE_SETTINGS, STORE_VIEW_MODE}; +use accshift_core::AppContext; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, MutexGuard}; + +/// A context on a scratch folder, removed on drop. +struct Scratch { + root: PathBuf, +} + +impl Scratch { + fn new(tag: &str) -> Arc { + let root = + std::env::temp_dir().join(format!("accshift-app-real-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + Arc::new(Self { root }) + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +impl AppContext for Scratch { + fn app_config_dir(&self) -> Result { + Ok(self.root.join("config")) + } + fn app_data_dir(&self) -> Result { + Ok(self.root.join("data")) + } + fn app_local_data_dir(&self) -> Result { + Ok(self.root.join("local")) + } + fn app_cache_dir(&self) -> Result { + Ok(self.root.join("cache")) + } +} + +fn backend(tag: &str) -> (Arc, RealBackend) { + let scratch = Scratch::new(tag); + let real = RealBackend::with_context(scratch.clone()); + (scratch, real) +} + +/// `reload_user_platforms` swaps a process-wide registry: the tests that +/// reload take turns. +fn registry_turn() -> MutexGuard<'static, ()> { + static TURN: Mutex<()> = Mutex::new(()); + TURN.lock().unwrap_or_else(|e| e.into_inner()) +} + +fn fixture(name: &str) -> String { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/data/real") + .join(name); + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())) +} + +fn app_log(scratch: &Scratch) -> String { + let path = accshift_core::logging::log_file_path(scratch).unwrap(); + std::fs::read_to_string(path).unwrap_or_default() +} + +// ---------------------------------------------------------------- storage + +#[test] +fn a_saved_store_comes_back_in_the_snapshot_and_the_manifest() { + let (scratch, real) = backend("storage"); + + // A fresh install lists every store, each `null`. + let empty = real.load_client_storage_snapshot().unwrap(); + assert_eq!(empty.stores.len(), 12); + assert!( + empty.stores.values().all(Value::is_null), + "{:?}", + empty.stores + ); + + let folders = json!({ "version": 2, "folders": { "f1": { "name": "Main" } } }); + let fingerprint = real + .save_client_storage_store(STORE_FOLDERS, &folders) + .unwrap() + .expect("the real command answers the fingerprint"); + let view = json!("grid"); + real.save_client_storage_store(STORE_VIEW_MODE, &view) + .unwrap(); + + let snapshot = real.load_client_storage_snapshot().unwrap(); + assert_eq!(snapshot.stores.get(STORE_FOLDERS), Some(&folders)); + assert_eq!(snapshot.stores.get(STORE_VIEW_MODE), Some(&view)); + + let manifest = real.get_storage_manifest().unwrap(); + assert_eq!(manifest.stores.get(STORE_FOLDERS), Some(&fingerprint)); + assert!(manifest.stores.contains_key(STORE_VIEW_MODE)); + assert_eq!(snapshot.manifest.stores, manifest.stores); + assert_eq!(snapshot.manifest.schema_version, manifest.schema_version); + + // A null value deletes the store: it reads back as `null`. + real.save_client_storage_store(STORE_VIEW_MODE, &Value::Null) + .unwrap(); + let snapshot = real.load_client_storage_snapshot().unwrap(); + assert_eq!(snapshot.stores.get(STORE_VIEW_MODE), Some(&Value::Null)); + + // Same log lines as the Tauri commands. + let log = app_log(&scratch); + assert!(log.contains("Saved client storage store"), "{log}"); + assert!(log.contains("Loaded client storage snapshot"), "{log}"); + assert!(log.contains("Built storage manifest"), "{log}"); +} + +#[test] +fn an_unknown_store_id_is_refused_with_the_core_message() { + let (_scratch, real) = backend("storage-unknown"); + let error = real + .save_client_storage_store("client.nope", &json!({})) + .unwrap_err(); + assert_eq!(error.message, "Unknown client store id: client.nope"); +} + +#[test] +fn the_manifest_and_snapshot_dispatch_to_the_webview_shape() { + let (_scratch, real) = backend("storage-dispatch"); + let saved = dispatch( + &real, + "save_client_storage_store", + &json!({ "storeId": STORE_VIEW_MODE, "value": "list" }), + ) + .unwrap() + .unwrap(); + assert!(saved.is_string(), "{saved}"); + let manifest = dispatch(&real, "get_storage_manifest", &json!({})) + .unwrap() + .unwrap(); + assert!(manifest["schemaVersion"].is_u64(), "{manifest}"); + assert_eq!(manifest["stores"][STORE_VIEW_MODE], saved); +} + +// ---------------------------------------------------------------- legacy migration and boot + +#[test] +fn a_legacy_config_is_migrated_once_and_kept_aside() { + let (scratch, real) = backend("legacy"); + let legacy = accshift_core::storage::legacy_config_path(&*scratch).unwrap(); + std::fs::create_dir_all(legacy.parent().unwrap()).unwrap(); + std::fs::write(&legacy, fixture("legacy-config.json")).unwrap(); + + assert_eq!(real.migrate_legacy_config().unwrap(), "migrated"); + + assert!(!legacy.exists()); + let mut retired = legacy.clone().into_os_string(); + retired.push(".migrated"); + assert_eq!( + std::fs::read_to_string(retired).unwrap(), + fixture("legacy-config.json") + ); + let config = accshift_core::config::load_config(&*scratch); + let labels: Vec<&str> = config + .riot + .profiles + .iter() + .map(|profile| profile.label.as_str()) + .collect(); + assert_eq!(labels, ["Main", "Smurf"]); + + assert_eq!(real.migrate_legacy_config().unwrap(), "none"); +} + +#[test] +fn a_corrupt_legacy_config_answers_an_error_string() { + let (scratch, real) = backend("legacy-corrupt"); + let legacy = accshift_core::storage::legacy_config_path(&*scratch).unwrap(); + std::fs::create_dir_all(legacy.parent().unwrap()).unwrap(); + std::fs::write(&legacy, b"{ not json").unwrap(); + + let answer = real.migrate_legacy_config().unwrap(); + assert!( + answer.starts_with("error:Legacy config is corrupt: "), + "{answer}" + ); + assert!(legacy.exists(), "the only copy stays"); +} + +#[test] +fn the_boot_payload_migrates_then_reads_everything() { + let _turn = registry_turn(); + let (scratch, real) = backend("boot"); + let legacy = accshift_core::storage::legacy_config_path(&*scratch).unwrap(); + std::fs::create_dir_all(legacy.parent().unwrap()).unwrap(); + std::fs::write(&legacy, fixture("legacy-config.json")).unwrap(); + let theme: CustomTheme = serde_json::from_str(&fixture("theme.json")).unwrap(); + real.save_custom_theme(&theme).unwrap(); + real.save_client_storage_store(STORE_VIEW_MODE, &json!("grid")) + .unwrap(); + + let payload = real.get_boot_payload().unwrap(); + assert_eq!(payload.migration, "migrated"); + assert_eq!(payload.runtime_os, std::env::consts::OS); + assert_eq!( + payload.storage_snapshot.stores.get(STORE_VIEW_MODE), + Some(&json!("grid")) + ); + assert_eq!(payload.custom_themes.len(), 1); + assert_eq!(payload.custom_themes[0].id, "egui-fixture-theme"); + assert!(payload.user_platforms.loaded.is_empty()); + assert!(payload + .user_platforms + .dir + .ends_with(&format!("{}descriptors", std::path::MAIN_SEPARATOR))); + + let json = serde_json::to_value(&payload).unwrap(); + for key in [ + "migration", + "runtimeOs", + "storageSnapshot", + "customThemes", + "userPlatforms", + ] { + assert!(json.get(key).is_some(), "{key} missing from {json}"); + } +} + +#[test] +fn finish_boot_completes_once_and_logs_both_calls() { + let (scratch, real) = backend("finish-boot"); + assert!(!real.boot_state().is_completed()); + + real.finish_boot("app.boot", Some(&json!({ "firstFrameMs": 120 }))) + .unwrap(); + assert!(real.boot_state().is_completed()); + real.finish_boot("app.boot", None).unwrap(); + + let log = app_log(&scratch); + assert!(log.contains("Boot completed"), "{log}"); + assert!(log.contains("Boot completion requested again"), "{log}"); + assert!(log.contains("Frontend startup profile"), "{log}"); +} + +#[test] +fn runtime_os_and_streaming_detection_answer() { + let (_scratch, real) = backend("runtime"); + assert_eq!(real.get_runtime_os().unwrap(), std::env::consts::OS); + // A process scan: the answer depends on the machine, only its shape is + // checked. + let _: bool = real.detect_streaming_software().unwrap(); +} + +// ---------------------------------------------------------------- themes + +#[test] +fn a_custom_theme_is_saved_listed_and_deleted() { + let (_scratch, real) = backend("themes"); + assert!(real.list_custom_themes().unwrap().is_empty()); + + let theme: CustomTheme = serde_json::from_str(&fixture("theme.json")).unwrap(); + real.save_custom_theme(&theme).unwrap(); + let mut second = theme.clone(); + second.id = "another-fixture".into(); + second.name = "Another".into(); + real.save_custom_theme(&second).unwrap(); + + let listed = real.list_custom_themes().unwrap(); + let names: Vec<&str> = listed.iter().map(|t| t.name.as_str()).collect(); + assert_eq!(names, ["Another", "Fixture Violet"], "sorted by name"); + assert_eq!( + serde_json::to_value(&listed[1]).unwrap(), + serde_json::from_str::(&fixture("theme.json")).unwrap() + ); + + real.delete_custom_theme("another-fixture").unwrap(); + let listed = real.list_custom_themes().unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, "egui-fixture-theme"); + // Deleting what is not there is not an error. + real.delete_custom_theme("another-fixture").unwrap(); +} + +#[test] +fn unsafe_theme_ids_are_refused_with_the_core_messages() { + let (_scratch, real) = backend("themes-bad"); + let mut theme: CustomTheme = serde_json::from_str(&fixture("theme.json")).unwrap(); + theme.id = "../escape".into(); + assert_eq!( + real.save_custom_theme(&theme).unwrap_err().message, + "Invalid theme ID: only alphanumeric characters, hyphens, and underscores are allowed" + ); + assert_eq!( + real.delete_custom_theme("../escape").unwrap_err().message, + "Invalid theme ID" + ); +} + +// ---------------------------------------------------------------- descriptors + +#[test] +fn a_descriptor_is_previewed_installed_and_removed() { + let _turn = registry_turn(); + let (scratch, real) = backend("descriptors"); + let live = scratch.root.join("live"); + let body = fixture("descriptor.json") + .replace("${LIVE}", &live.display().to_string().replace('\\', "/")); + let picked = scratch.root.join("picked-by-user.json"); + std::fs::write(&picked, &body).unwrap(); + let picked_path = picked.display().to_string(); + + let preview = real.descriptor_preview_file(&picked_path).unwrap(); + assert_eq!(preview.descriptor.id, "egui-fixture"); + assert_eq!(preview.file_name, "egui-fixture.json"); + assert!(preview.blocked.is_empty(), "{}", preview.blocked); + assert!(!preview.replaces); + + let installed = real.descriptor_install_file(&picked_path).unwrap(); + let ids: Vec<&str> = installed + .loaded + .iter() + .map(|descriptor| descriptor.id.as_str()) + .collect(); + assert_eq!(ids, ["egui-fixture"]); + let in_folder = Path::new(&installed.dir).join("egui-fixture.json"); + assert_eq!(std::fs::read_to_string(&in_folder).unwrap(), body); + assert!(picked.exists(), "the picked file stays the user's"); + assert_eq!(real.reload_user_platforms().unwrap().loaded.len(), 1); + assert!( + real.descriptor_preview_file(&picked_path).unwrap().replaces, + "a second preview says it would replace the installed one" + ); + + let removed = real.descriptor_remove("egui-fixture").unwrap(); + assert!(removed.loaded.is_empty()); + assert!(!in_folder.exists()); + assert!(real.reload_user_platforms().unwrap().loaded.is_empty()); +} + +#[test] +fn descriptor_errors_keep_the_core_messages() { + let _turn = registry_turn(); + let (scratch, real) = backend("descriptors-bad"); + assert_eq!( + real.descriptor_remove("steam").unwrap_err().message, + "`steam` is a platform this build ships" + ); + assert_eq!( + real.descriptor_remove("../x").unwrap_err().message, + "`../x` is not a platform id" + ); + let missing = scratch.root.join("missing.json"); + let error = real + .descriptor_install_file(&missing.display().to_string()) + .unwrap_err(); + assert!(error.message.contains("could not be read"), "{error}"); +} + +// ---------------------------------------------------------------- PIN + +fn settings_with_pin(enabled: bool, pin: &str) -> Value { + json!({ + "pinEnabled": enabled, + "pinHash": if enabled { hash_pin_code(pin).unwrap() } else { String::new() }, + }) +} + +#[test] +fn without_a_pin_unlock_is_not_configured() { + let (_scratch, real) = backend("pin-none"); + let result = real.pin_unlock("1234").unwrap(); + assert_eq!(result.status, "not_configured"); + assert_eq!(result.retry_after_ms, 0); + assert!(real.pin_session().ensure_unlocked(real.context()).is_ok()); +} + +#[test] +fn the_pin_session_locks_unlocks_and_guards_the_settings_store() { + let (scratch, real) = backend("pin"); + // Saved through the backend: no PIN on disk yet, so the session decides + // "unlocked" on this first write, as in the Tauri app. + real.save_client_storage_store(STORE_SETTINGS, &settings_with_pin(true, "1234")) + .unwrap(); + assert!(real.pin_session().ensure_unlocked(real.context()).is_ok()); + + real.pin_lock().unwrap(); + let refused = real + .pin_session() + .ensure_unlocked(real.context()) + .unwrap_err(); + assert_eq!(refused.message, PIN_LOCKED_MESSAGE); + assert!(BackendError::from(refused).is_pin_locked()); + + // Locked: the PIN cannot be turned off through the store. + let error = real + .save_client_storage_store(STORE_SETTINGS, &settings_with_pin(false, "")) + .unwrap_err(); + assert_eq!(error.message, PIN_LOCKED_MESSAGE); + + let wrong = real.pin_unlock("0000").unwrap(); + assert_eq!(wrong.status, "invalid"); + assert_eq!(wrong.retry_after_ms, 1000); + let too_soon = real.pin_unlock("1234").unwrap(); + assert_eq!(too_soon.status, "retry_later"); + assert!(too_soon.retry_after_ms > 0 && too_soon.retry_after_ms <= 1000); + + std::thread::sleep(std::time::Duration::from_millis(1050)); + let right = dispatch(&real, "pin_unlock", &json!({ "code": "1234" })) + .unwrap() + .unwrap(); + assert_eq!( + right, + json!({ "status": "unlocked", "legacy": false, "retryAfterMs": 0 }) + ); + assert!(real.pin_session().ensure_unlocked(real.context()).is_ok()); + + // Unlocked: the same write goes through. + real.save_client_storage_store(STORE_SETTINGS, &settings_with_pin(false, "")) + .unwrap(); + + // A new process with a PIN on disk starts locked. + real.save_client_storage_store(STORE_SETTINGS, &settings_with_pin(true, "4321")) + .unwrap(); + let next = RealBackend::with_context(scratch.clone()); + assert!(next.pin_session().ensure_unlocked(next.context()).is_err()); + assert_eq!(next.pin_unlock("4321").unwrap().status, "unlocked"); +} + +// ---------------------------------------------------------------- logs and URLs + +#[test] +fn ui_log_records_are_trimmed_and_capped_per_session() { + let (scratch, real) = backend("logs"); + let long = "x".repeat(UI_DETAILS_MAX_BYTES * 2); + real.log_app_event("info", "ui.test", "first record", Some(&long)) + .unwrap(); + let log = app_log(&scratch); + assert!(log.contains("first record"), "{log}"); + assert!( + !log.contains(&long), + "details are cut to {UI_DETAILS_MAX_BYTES} bytes" + ); + + for i in 1..UI_LOG_CAP { + real.log_app_event("debug", "ui.test", &format!("record {i}"), None) + .unwrap(); + } + real.log_app_event("info", "ui.test", "over the cap", None) + .unwrap(); + real.log_app_event("info", "ui.test", "still over", None) + .unwrap(); + let log = app_log(&scratch); + assert_eq!( + log.matches("Webview log cap reached").count(), + 1, + "warned once" + ); + assert!(!log.contains("over the cap")); + assert!(!log.contains("still over")); +} + +#[test] +fn open_url_refuses_what_the_tauri_command_refuses() { + let (_scratch, real) = backend("open-url"); + // Refused before anything is launched: nothing opens. + for url in [ + "file:///C:/Windows/System32/calc.exe", + "javascript:alert(1)", + "not a url", + ] { + let error = real.open_url(url).unwrap_err(); + assert_eq!( + error.message, + format!("Could not start process: Refusing to open URL with disallowed scheme: {url}") + ); + } +} + +// ---------------------------------------------------------------- wallpaper + +/// Captures the real desktop picture (read only, no window). Ignored by +/// default: run with `--ignored` on a desktop session. +#[test] +#[ignore] +fn the_wallpaper_capture_answers_a_jpeg_data_url() { + let (_scratch, real) = backend("wallpaper"); + let snapshot = real.get_desktop_wallpaper().unwrap(); + if cfg!(windows) { + let snapshot = snapshot.expect("a capture on a Windows desktop session"); + assert!(snapshot.data_url.starts_with("data:image/jpeg;base64,")); + assert!(snapshot.width > 0 && snapshot.height > 0); + println!( + "wallpaper: {}x{} at {},{}, {} bytes of data URL", + snapshot.width, + snapshot.height, + snapshot.x, + snapshot.y, + snapshot.data_url.len() + ); + // The second call is served from the cache: same answer. + assert_eq!(real.get_desktop_wallpaper().unwrap(), Some(snapshot)); + } else { + assert_eq!(snapshot, None); + } +} diff --git a/crates/accshift-app/tests/real_paths.rs b/crates/accshift-app/tests/real_paths.rs new file mode 100644 index 0000000..10cd50b --- /dev/null +++ b/crates/accshift-app/tests/real_paths.rs @@ -0,0 +1,101 @@ +//! The app directories of `EguiAppContext` against what Tauri's path resolver +//! returns for `com.accshift.desktop` (`tauri-2.11/src/path/desktop.rs`: +//! `dirs::config_dir()`, `data_dir()`, `data_local_dir()`, `cache_dir()` +//! joined with the identifier). Pinned from the environment each OS reads, +//! not from `dirs`, so a drift in the resolver crate shows up here. Only +//! resolves paths: nothing is created or written. + +use accshift_app::real::{EguiAppContext, RealBackend, BUNDLE_IDENTIFIER}; +use accshift_core::AppContext; +use std::path::PathBuf; + +const ID: &str = "com.accshift.desktop"; + +struct Expected { + config: PathBuf, + data: PathBuf, + local: PathBuf, + cache: PathBuf, +} + +#[cfg(windows)] +fn expected() -> Expected { + let roaming = PathBuf::from(std::env::var_os("APPDATA").expect("APPDATA")); + let local = PathBuf::from(std::env::var_os("LOCALAPPDATA").expect("LOCALAPPDATA")); + Expected { + config: roaming.join(ID), + data: roaming.join(ID), + local: local.join(ID), + cache: local.join(ID), + } +} + +#[cfg(target_os = "linux")] +fn expected() -> Expected { + let home = PathBuf::from(std::env::var_os("HOME").expect("HOME")); + let xdg = |var: &str, fallback: &str| { + std::env::var_os(var) + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .unwrap_or_else(|| home.join(fallback)) + }; + let data = xdg("XDG_DATA_HOME", ".local/share"); + Expected { + config: xdg("XDG_CONFIG_HOME", ".config").join(ID), + data: data.join(ID), + local: data.join(ID), + cache: xdg("XDG_CACHE_HOME", ".cache").join(ID), + } +} + +#[cfg(target_os = "macos")] +fn expected() -> Expected { + let home = PathBuf::from(std::env::var_os("HOME").expect("HOME")); + let support = home.join("Library/Application Support"); + Expected { + config: support.join(ID), + data: support.join(ID), + local: support.join(ID), + cache: home.join("Library/Caches").join(ID), + } +} + +#[test] +fn the_identifier_is_the_tauri_one() { + assert_eq!(BUNDLE_IDENTIFIER, ID); + let conf = include_str!("../../../src-tauri/tauri.conf.json"); + let conf: serde_json::Value = serde_json::from_str(conf).unwrap(); + assert_eq!(conf["identifier"], ID); +} + +#[test] +fn every_app_dir_is_the_one_tauri_resolves() { + let ctx = EguiAppContext::new().unwrap(); + let want = expected(); + assert_eq!(ctx.app_config_dir().unwrap(), want.config); + assert_eq!(ctx.app_data_dir().unwrap(), want.data); + assert_eq!(ctx.app_local_data_dir().unwrap(), want.local); + assert_eq!(ctx.app_cache_dir().unwrap(), want.cache); +} + +#[test] +fn the_logs_dir_is_under_the_config_dir() { + let ctx = EguiAppContext::new().unwrap(); + let mut want = expected().config.join("logs"); + if cfg!(debug_assertions) { + // accshift-core scopes the log root under `dev` in a debug build, the + // Tauri dev build included: `scope_root(/logs)`. + want = want.join("dev"); + } + assert_eq!(accshift_core::storage::app_log_root(&ctx).unwrap(), want); +} + +#[test] +fn the_real_backend_builds_on_the_real_dirs_and_its_http_client() { + // Resolves the directories and builds the HTTP client; writes nothing. + let real = RealBackend::new().unwrap(); + assert_eq!(real.context().app_config_dir().unwrap(), expected().config); + let first: *const reqwest::Client = real.http_client().unwrap(); + let second: *const reqwest::Client = real.http_client().unwrap(); + assert_eq!(first, second, "one shared client"); +} From 01e4d4d9e198f89cfc0b5564a04bfbbdca670171 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:08:40 +0200 Subject: [PATCH 012/106] docs(app): fix source line references in the real backend --- crates/accshift-app/src/real/pin.rs | 6 +++--- crates/accshift-app/src/real/wallpaper.rs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/accshift-app/src/real/pin.rs b/crates/accshift-app/src/real/pin.rs index 6b2977f..82adac9 100644 --- a/crates/accshift-app/src/real/pin.rs +++ b/crates/accshift-app/src/real/pin.rs @@ -5,7 +5,7 @@ use crate::error::BackendResult; use crate::types::PinUnlockResult; use accshift_core::pin::UnlockOutcome; -/// `impl From for PinUnlockResult`, pin.rs lines 24-50. +/// `impl From for PinUnlockResult`, pin.rs lines 25-51. pub(super) fn unlock_result(outcome: UnlockOutcome) -> PinUnlockResult { let ms = |d: std::time::Duration| d.as_millis().min(u128::from(u64::MAX)) as u64; let (status, legacy, retry_after_ms) = match outcome { @@ -21,13 +21,13 @@ pub(super) fn unlock_result(outcome: UnlockOutcome) -> PinUnlockResult { } } -/// `pin_unlock`, pin.rs lines 52-67. PBKDF2 at 100k rounds: run it on the +/// `pin_unlock`, pin.rs lines 53-68. PBKDF2 at 100k rounds: run it on the /// job pool, never on the UI thread. pub(super) fn unlock(backend: &RealBackend, code: &str) -> BackendResult { Ok(unlock_result(backend.pin.unlock(&backend.ctx, code)?)) } -/// `pin_lock`, pin.rs lines 69-74. +/// `pin_lock`, pin.rs lines 70-75. pub(super) fn lock(backend: &RealBackend) { backend.pin.lock(); } diff --git a/crates/accshift-app/src/real/wallpaper.rs b/crates/accshift-app/src/real/wallpaper.rs index 0da8418..b321516 100644 --- a/crates/accshift-app/src/real/wallpaper.rs +++ b/crates/accshift-app/src/real/wallpaper.rs @@ -1,6 +1,6 @@ //! `get_desktop_wallpaper`: the desktop picture behind the window, for the //! glass themes. Port of the capture in `src-tauri/src/commands/backdrop.rs` -//! (lines 60-321) on `windows-sys`. `None` outside Windows, as there. +//! (lines 60-400) on `windows-sys`. `None` outside Windows, as there. use crate::types::WallpaperSnapshot; @@ -80,7 +80,7 @@ mod capture { } /// Snapshot of the shell's wallpaper as rendered (Progman + - /// PW_RENDERFULLCONTENT), backdrop.rs lines 119-252. + /// PW_RENDERFULLCONTENT), backdrop.rs lines 120-252. pub(super) fn capture() -> Option { unsafe { let mut progman: HWND = std::ptr::null_mut(); From c8da1dc6ae506d60170a1e93e1d817adc8601981 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:10:33 +0200 Subject: [PATCH 013/106] feat(egui): scene runner, CSS box painter and animation timing --- crates/accshift-egui/Cargo.lock | 2 + crates/accshift-egui/Cargo.toml | 4 + crates/accshift-egui/README.md | 37 +- crates/accshift-egui/parity/README.md | 3 +- .../parity/fragments/gradients.html | 59 + .../accshift-egui/parity/fragments/inset.html | 68 + .../parity/fragments/radius.html | 104 ++ .../parity/fragments/shadows.html | 62 + crates/accshift-egui/parity/ref.mjs | 31 +- .../scenes/primitives-gradients-100.json | 21 + .../scenes/primitives-gradients-125.json | 21 + .../scenes/primitives-gradients-150.json | 21 + .../parity/scenes/primitives-inset-100.json | 21 + .../parity/scenes/primitives-inset-125.json | 21 + .../parity/scenes/primitives-inset-150.json | 21 + .../parity/scenes/primitives-radius-100.json | 21 + .../parity/scenes/primitives-radius-125.json | 21 + .../parity/scenes/primitives-radius-150.json | 21 + .../parity/scenes/primitives-shadows-100.json | 21 + .../parity/scenes/primitives-shadows-125.json | 21 + .../parity/scenes/primitives-shadows-150.json | 21 + crates/accshift-egui/src/capture.rs | 195 --- crates/accshift-egui/src/harness/mod.rs | 581 +++++++ crates/accshift-egui/src/lib.rs | 39 +- crates/accshift-egui/src/main.rs | 102 +- .../src/{app.rs => screens/main_spike.rs} | 4 +- crates/accshift-egui/src/screens/mod.rs | 62 + .../accshift-egui/src/screens/primitives.rs | 71 + crates/accshift-egui/src/ui/anim.rs | 905 ++++++++++ crates/accshift-egui/src/ui/css.rs | 524 ++++++ crates/accshift-egui/src/ui/mod.rs | 7 + crates/accshift-egui/src/ui/paint.rs | 1520 +++++++++++++++++ 32 files changed, 4368 insertions(+), 264 deletions(-) create mode 100644 crates/accshift-egui/parity/fragments/gradients.html create mode 100644 crates/accshift-egui/parity/fragments/inset.html create mode 100644 crates/accshift-egui/parity/fragments/radius.html create mode 100644 crates/accshift-egui/parity/fragments/shadows.html create mode 100644 crates/accshift-egui/parity/scenes/primitives-gradients-100.json create mode 100644 crates/accshift-egui/parity/scenes/primitives-gradients-125.json create mode 100644 crates/accshift-egui/parity/scenes/primitives-gradients-150.json create mode 100644 crates/accshift-egui/parity/scenes/primitives-inset-100.json create mode 100644 crates/accshift-egui/parity/scenes/primitives-inset-125.json create mode 100644 crates/accshift-egui/parity/scenes/primitives-inset-150.json create mode 100644 crates/accshift-egui/parity/scenes/primitives-radius-100.json create mode 100644 crates/accshift-egui/parity/scenes/primitives-radius-125.json create mode 100644 crates/accshift-egui/parity/scenes/primitives-radius-150.json create mode 100644 crates/accshift-egui/parity/scenes/primitives-shadows-100.json create mode 100644 crates/accshift-egui/parity/scenes/primitives-shadows-125.json create mode 100644 crates/accshift-egui/parity/scenes/primitives-shadows-150.json delete mode 100644 crates/accshift-egui/src/capture.rs create mode 100644 crates/accshift-egui/src/harness/mod.rs rename crates/accshift-egui/src/{app.rs => screens/main_spike.rs} (99%) create mode 100644 crates/accshift-egui/src/screens/mod.rs create mode 100644 crates/accshift-egui/src/screens/primitives.rs create mode 100644 crates/accshift-egui/src/ui/anim.rs create mode 100644 crates/accshift-egui/src/ui/css.rs create mode 100644 crates/accshift-egui/src/ui/mod.rs create mode 100644 crates/accshift-egui/src/ui/paint.rs diff --git a/crates/accshift-egui/Cargo.lock b/crates/accshift-egui/Cargo.lock index a3a1a29..1775f88 100644 --- a/crates/accshift-egui/Cargo.lock +++ b/crates/accshift-egui/Cargo.lock @@ -147,8 +147,10 @@ dependencies = [ "egui-winit", "egui_extras", "egui_software_backend", + "png 0.18.1", "raw-window-handle", "resvg", + "serde_json", "softbuffer", "window-vibrancy", "windows-sys 0.61.2", diff --git a/crates/accshift-egui/Cargo.toml b/crates/accshift-egui/Cargo.toml index 424d6fe..70cde37 100644 --- a/crates/accshift-egui/Cargo.toml +++ b/crates/accshift-egui/Cargo.toml @@ -51,6 +51,10 @@ egui_extras = { version = "0.36", default-features = false, features = ["svg"] } # The same resvg egui_extras rasterizes with, for its options type. resvg = { version = "0.45", default-features = false } raw-window-handle = "0.6" +# Scene runner only (`--scene`): scene files in, PNG shots out. Neither is +# touched on the normal startup path. +serde_json = "1" +png = "0.18" [target.'cfg(not(windows))'.dependencies] egui = { version = "0.36", features = ["default_fonts"] } diff --git a/crates/accshift-egui/README.md b/crates/accshift-egui/README.md index 233b931..908773e 100644 --- a/crates/accshift-egui/README.md +++ b/crates/accshift-egui/README.md @@ -25,14 +25,14 @@ What the software path does to stay cheap: ```powershell cargo run --release # normal window, software renderer -cargo run --release -- --capture # scripted shots, hidden window +cargo run --release -- --scene parity/scenes/spike-main.json --out # scripted shots, hidden window cargo build --release --features startup-bench cargo build --release --no-default-features --features glow ``` -- `--capture ` runs a fixed script (hover, arm, search, switch, tabs, folder, list) and writes one PAM file per shot, read back from the frame the renderer presented at 860x440 points and 1.5 pixels per point, over the recorder's stand-in desktop (`src/backdrop.rs`). `ACCSHIFT_EGUI_CAPTURE_SCALE` changes the scale. `ffmpeg -i shot.pam shot.png` converts them. +- `--scene ... --out ` plays parity scenes (format in `parity/README.md`): each one sets the viewport and scale from its file, feeds its steps as egui raw input, and writes `//.png` from the frame the renderer presented. `spike-main.json` is the old capture script (hover, arm, search, switch, tabs, folder, list over the recorder's stand-in desktop, `src/backdrop.rs`). A scene with an `html` fragment is drawn by the primitives screen with `ui::paint`, the calibration of the CSS box painter; any other scene the spike cannot draw (another scenario, theme, locale or os, a seed) fails with a message and a non-zero exit. A scene that never gets its size fails after 5 s, the whole run after 60 s plus 30 s per scene. - `startup-bench` never shows the window: it is created inactive, outside the taskbar, at -32000,-32000, and DWM-cloaked before the runner makes it visible. Boot marks go to `%TEMP%\accshift-egui-bench\.jsonl` (`main`, `window`, `app_created`, `ui_done`, `first_frame`, `ready` once every avatar is on screen, `core_accounts`, and one `frame` line per paint with its timings in the software build). The binary carries the string `accshift-egui-startup-bench-build`, and a harness refuses to launch any build without it. -- `--capture` uses the same hidden-window recipe in any build. +- `--scene` uses the same hidden-window recipe in any build. - `ACCSHIFT_EGUI_OPAQUE` (no transparency, no acrylic) and, for glow, `ACCSHIFT_EGUI_NO_VSYNC` exist only for the boot breakdown. ## Results (Windows 11, NVIDIA desktop GPU) @@ -59,16 +59,21 @@ Not verified: resizing by dragging a window edge, a visible window on Windows 11 ## Layout -| File | Role | -| ----------------- | ---------------------------------------------------------------- | -| `src/app.rs` | The screen: title bar, header, grid, list, cards, animations | -| `src/theme.rs` | Glass Dark tokens, CSS `color-mix`, the avatar fallback gradient | -| `src/data.rs` | The demo dataset, with the mock SVG avatars embedded | -| `src/avatars.rs` | SVG rasterizing and the pre-blurred textures, on a worker thread | -| `src/icons.rs` | The Lucide and platform icons of the Svelte components | -| `src/capture.rs` | The scripted capture run | -| `src/bench.rs` | Boot marks | -| `src/backdrop.rs` | The recorder's stand-in desktop, for captures only | -| `src/soft.rs` | The software runner: winit loop, damaged-tiles present, pacing | -| `vendor/` | egui_software_backend 0.0.3, ported to egui 0.36 | -| `build.rs` | Delay-loaded DLLs on Windows | +| File | Role | +| --------------------------- | ------------------------------------------------------------------------------- | +| `src/screens/main_spike.rs` | The spike screen: title bar, header, grid, list, cards, animations | +| `src/screens/primitives.rs` | Draws a parity HTML fragment with `ui::paint` (calibration) | +| `src/screens/mod.rs` | The screen a scene asks for | +| `src/ui/paint.rs` | CSS boxes as Chromium paints them: radii, borders, shadows, gradients, clipping | +| `src/ui/anim.rs` | CSS timing functions, transitions, keyframes, reduced motion | +| `src/ui/css.rs` | Parser for the inline CSS of the parity fragments | +| `src/harness/` | The scene runner: scene files to raw input, PNG shots | +| `src/theme.rs` | Glass Dark tokens, CSS `color-mix`, the avatar fallback gradient | +| `src/data.rs` | The demo dataset, with the mock SVG avatars embedded | +| `src/avatars.rs` | SVG rasterizing and the pre-blurred textures, on a worker thread | +| `src/icons.rs` | The Lucide and platform icons of the Svelte components | +| `src/bench.rs` | Boot marks | +| `src/backdrop.rs` | The recorder's stand-in desktop, for captures only | +| `src/soft.rs` | The software runner: winit loop, damaged-tiles present, pacing | +| `vendor/` | egui_software_backend 0.0.3, ported to egui 0.36 | +| `build.rs` | Delay-loaded DLLs on Windows | diff --git a/crates/accshift-egui/parity/README.md b/crates/accshift-egui/parity/README.md index c85dae0..f3f0dfc 100644 --- a/crates/accshift-egui/parity/README.md +++ b/crates/accshift-egui/parity/README.md @@ -6,7 +6,7 @@ Every screen of the egui port is compared, pixel by pixel, with the same screen - `diff.mjs` compares reference frames with egui frames and writes a report. - `dump-data.mjs` exports the mock's command results to `data/.json`, so the egui side runs on the same data. - `harness.mjs` is the shared plumbing: vite dev server, app launch, CDP client, window and data-dir checks. -- `scenes/*.json` are the scenes, `data/*.json` the dumped mock data. +- `scenes/*.json` are the scenes, `fragments/*.html` the static CSS boxes of the `primitives-*` scenes, `data/*.json` the dumped mock data. Node 22+ and ffmpeg on `PATH`. No npm dependency. @@ -64,6 +64,7 @@ The frontend comes from `vite dev` on port 1420 (the `devUrl` compiled into the - `theme` and `locale` patch `client.settings` (`themeId`, `language`). A `seed` key is a store id (`client.view-mode`) or the suffix of its `CLIENT_STORE_` constant in `src/lib/storage/storeIds.ts` (`SETTINGS`); an object value is merged over the scenario's own store, anything else replaces it. - `key` takes `Ctrl`, `Shift`, `Alt` and `Meta` prefixes joined with `+`, then a character or a key name (`Enter`, `Escape`, `Backspace`, `Tab`, `Delete`, `Home`, `End`, arrow keys, `Space`). `text` types character by character. `scroll` is `[x, y, deltaY]`. - A shot waits for nothing: put a `wait` before it, long enough for transitions to end. +- `html` (optional) names a fragment file, relative to the scene file. `ref.mjs` replaces the page body with it once the scene has settled; the egui runner draws the same file with `ui::paint` instead of the app. The `primitives-*` scenes use `fragments/*.html` this way to calibrate the CSS box painter at 1, 1.25 and 1.5 pixels per point. Only absolutely positioned `
`s with inline `style` are drawn on the egui side. - `alias` on a shot is optional: the file name, without extension, of the same shot in an older capture set (the spike's `--capture` PAMs). `diff.mjs --batch` uses it when `//` does not exist. Before each scene, the page is reloaded into its mock state, and runs once every image is decoded and no avatar is blurred. diff --git a/crates/accshift-egui/parity/fragments/gradients.html b/crates/accshift-egui/parity/fragments/gradients.html new file mode 100644 index 0000000..7ffb9df --- /dev/null +++ b/crates/accshift-egui/parity/fragments/gradients.html @@ -0,0 +1,59 @@ + +
+
+
+
+
diff --git a/crates/accshift-egui/parity/fragments/inset.html b/crates/accshift-egui/parity/fragments/inset.html new file mode 100644 index 0000000..dec80fd --- /dev/null +++ b/crates/accshift-egui/parity/fragments/inset.html @@ -0,0 +1,68 @@ + +
+
+
+
+
diff --git a/crates/accshift-egui/parity/fragments/radius.html b/crates/accshift-egui/parity/fragments/radius.html new file mode 100644 index 0000000..5e44a24 --- /dev/null +++ b/crates/accshift-egui/parity/fragments/radius.html @@ -0,0 +1,104 @@ + +
+
+
+
+
+
+
+
+
diff --git a/crates/accshift-egui/parity/fragments/shadows.html b/crates/accshift-egui/parity/fragments/shadows.html new file mode 100644 index 0000000..e978c4f --- /dev/null +++ b/crates/accshift-egui/parity/fragments/shadows.html @@ -0,0 +1,62 @@ + +
+
+
+
+
diff --git a/crates/accshift-egui/parity/ref.mjs b/crates/accshift-egui/parity/ref.mjs index 8f16426..410cc91 100644 --- a/crates/accshift-egui/parity/ref.mjs +++ b/crates/accshift-egui/parity/ref.mjs @@ -36,9 +36,12 @@ // linear-gradient(160deg, #10121f, #0a0c16) // - input: CDP Input.dispatchMouseEvent / dispatchKeyEvent, so :hover, focus // and keyboard handlers behave as under a real hand. +// - `html` (optional): a fragment file, relative to the scene file, that +// replaces the body once the scene has settled. The primitives scenes use it +// to photograph plain CSS boxes; the egui side draws the same file. import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { basename, join, resolve } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { DEV_URL, PIN_CLOCK, @@ -170,6 +173,9 @@ function loadScene(file) { scene.scenario ??= "demo"; scene.viewport ??= { width: 860, height: 440, scale: 1.5 }; scene.steps ??= []; + // `html`: a fragment file next to the scene, injected in place of the body + // once the app has settled (calibration of the egui painter). + if (scene.html) scene.htmlSource = readFileSync(resolve(dirname(file), scene.html), "utf8"); return scene; } @@ -188,7 +194,19 @@ async function runScene(page, scene, outDir, manifest) { }); const desk = await send("Page.addScriptToEvaluateOnNewDocument", { source: desktop(vp) }); - await js(applyMock(scene)); + // The mock import can hit a module still initialising (a reload in flight): + // reload the dev page and try again before giving up. + for (let attempt = 1; ; attempt++) { + try { + await js(applyMock(scene)); + break; + } catch (err) { + if (attempt >= 3) throw err; + console.error(`${scene.id}: mock setup failed (${err.message}), reloading`); + await send("Page.navigate", { url: DEV_URL }); + await sleep(1500); + } + } await js("window.__parityStale = true"); await send("Page.navigate", { url: DEV_URL }); let settled = false; @@ -199,6 +217,15 @@ async function runScene(page, scene, outDir, manifest) { } if (!settled) console.error(`${scene.id}: page never settled, shooting anyway`); else console.log(`${scene.id}: settled after ${Date.now() - started} ms`); + if (scene.htmlSource) { + // The app stays mounted on a detached #app; the page shows the fragment. + await js( + `(() => { document.activeElement?.blur?.(); document.body.innerHTML = ${JSON.stringify(scene.htmlSource)}; return true; })()`, + ); + await js( + "new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))", + ); + } const mouse = { x: 0, y: 0 }; const mouseEvent = (type, extra = {}) => diff --git a/crates/accshift-egui/parity/scenes/primitives-gradients-100.json b/crates/accshift-egui/parity/scenes/primitives-gradients-100.json new file mode 100644 index 0000000..8c0c5da --- /dev/null +++ b/crates/accshift-egui/parity/scenes/primitives-gradients-100.json @@ -0,0 +1,21 @@ +{ + "id": "primitives-gradients-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 480, + "height": 240, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/gradients.html", + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/primitives-gradients-125.json b/crates/accshift-egui/parity/scenes/primitives-gradients-125.json new file mode 100644 index 0000000..37fd6a8 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/primitives-gradients-125.json @@ -0,0 +1,21 @@ +{ + "id": "primitives-gradients-125", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 480, + "height": 240, + "scale": 1.25 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/gradients.html", + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/primitives-gradients-150.json b/crates/accshift-egui/parity/scenes/primitives-gradients-150.json new file mode 100644 index 0000000..ce0d2b7 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/primitives-gradients-150.json @@ -0,0 +1,21 @@ +{ + "id": "primitives-gradients-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 480, + "height": 240, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/gradients.html", + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/primitives-inset-100.json b/crates/accshift-egui/parity/scenes/primitives-inset-100.json new file mode 100644 index 0000000..63bc3b4 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/primitives-inset-100.json @@ -0,0 +1,21 @@ +{ + "id": "primitives-inset-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 480, + "height": 240, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/inset.html", + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/primitives-inset-125.json b/crates/accshift-egui/parity/scenes/primitives-inset-125.json new file mode 100644 index 0000000..cb548d9 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/primitives-inset-125.json @@ -0,0 +1,21 @@ +{ + "id": "primitives-inset-125", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 480, + "height": 240, + "scale": 1.25 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/inset.html", + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/primitives-inset-150.json b/crates/accshift-egui/parity/scenes/primitives-inset-150.json new file mode 100644 index 0000000..ccb4758 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/primitives-inset-150.json @@ -0,0 +1,21 @@ +{ + "id": "primitives-inset-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 480, + "height": 240, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/inset.html", + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/primitives-radius-100.json b/crates/accshift-egui/parity/scenes/primitives-radius-100.json new file mode 100644 index 0000000..a268705 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/primitives-radius-100.json @@ -0,0 +1,21 @@ +{ + "id": "primitives-radius-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 480, + "height": 240, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/radius.html", + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/primitives-radius-125.json b/crates/accshift-egui/parity/scenes/primitives-radius-125.json new file mode 100644 index 0000000..6a6c015 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/primitives-radius-125.json @@ -0,0 +1,21 @@ +{ + "id": "primitives-radius-125", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 480, + "height": 240, + "scale": 1.25 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/radius.html", + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/primitives-radius-150.json b/crates/accshift-egui/parity/scenes/primitives-radius-150.json new file mode 100644 index 0000000..caf40ca --- /dev/null +++ b/crates/accshift-egui/parity/scenes/primitives-radius-150.json @@ -0,0 +1,21 @@ +{ + "id": "primitives-radius-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 480, + "height": 240, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/radius.html", + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/primitives-shadows-100.json b/crates/accshift-egui/parity/scenes/primitives-shadows-100.json new file mode 100644 index 0000000..075a69f --- /dev/null +++ b/crates/accshift-egui/parity/scenes/primitives-shadows-100.json @@ -0,0 +1,21 @@ +{ + "id": "primitives-shadows-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 480, + "height": 240, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/shadows.html", + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/primitives-shadows-125.json b/crates/accshift-egui/parity/scenes/primitives-shadows-125.json new file mode 100644 index 0000000..76331d0 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/primitives-shadows-125.json @@ -0,0 +1,21 @@ +{ + "id": "primitives-shadows-125", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 480, + "height": 240, + "scale": 1.25 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/shadows.html", + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/primitives-shadows-150.json b/crates/accshift-egui/parity/scenes/primitives-shadows-150.json new file mode 100644 index 0000000..e680bde --- /dev/null +++ b/crates/accshift-egui/parity/scenes/primitives-shadows-150.json @@ -0,0 +1,21 @@ +{ + "id": "primitives-shadows-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 480, + "height": 240, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/shadows.html", + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/src/capture.rs b/crates/accshift-egui/src/capture.rs deleted file mode 100644 index 678ea7a..0000000 --- a/crates/accshift-egui/src/capture.rs +++ /dev/null @@ -1,195 +0,0 @@ -//! Scripted capture run: `accshift-egui --capture `. -//! -//! The window gets the bench recipe (never activated, off screen, cloaked), a -//! fixed script feeds pointer and text events through `raw_input_hook`, and -//! each shot is read back from the GL framebuffer of the real glow renderer. -//! Shots are written as PAM files (a 7-line header plus raw RGBA), so the -//! binary needs no image encoder; `ffmpeg -i shot.pam shot.png` converts them. -//! -//! The script reproduces the frames of the Tauri recorder's `demo-switch` -//! (rest with hover, armed card, search, after the switch), then the other -//! tabs, a folder and the list view. - -use std::io::Write; -use std::path::PathBuf; -use std::time::Instant; - -use egui::{pos2, Event, Modifiers, PointerButton, Pos2, RawInput}; - -#[derive(Clone, Copy)] -enum Step { - Wait(f64), - Move(Pos2), - Press(Pos2), - Release(Pos2), - Text(&'static str), - Away, - Shot(&'static str), - Quit, -} - -pub struct Capture { - dir: PathBuf, - steps: Vec, - next: usize, - wake_at: f64, - clock: Instant, - pending_shot: Option<&'static str>, - pub done: bool, -} - -/// Card centres: 7 columns from x = 45, 110 apart; rows from y = 84, 146 apart. -fn card(col: f32, row: f32) -> Pos2 { - pos2(45.0 + col * 110.0 + 50.0, 84.0 + row * 146.0 + 68.0) -} - -fn click(steps: &mut Vec, p: Pos2) { - steps.extend([Step::Move(p), Step::Press(p), Step::Release(p)]); -} - -fn script() -> Vec { - use Step::*; - let mut s = vec![Wait(0.6)]; - // switch-0: grid at rest, pointer on "practice". - s.extend([Move(card(6.0, 1.0)), Wait(0.5), Shot("egui-0-hover")]); - // switch-2: "bro's account" armed, pointer still on it. - click(&mut s, card(2.0, 0.0)); - s.extend([Wait(0.6), Shot("egui-2-armed")]); - // switch-5: query "ra" typed in the search field. - click(&mut s, pos2(639.0, 60.0)); - s.extend([Text("ra"), Wait(0.6), Shot("egui-5-search")]); - // switch-9: query cleared, switch to "bro's account" done, pointer on - // the folder. - click(&mut s, pos2(741.0, 60.0)); - s.push(Wait(0.4)); - click(&mut s, card(2.0, 0.0)); - s.push(Wait(0.3)); - click(&mut s, card(2.0, 0.0)); - s.extend([ - Wait(1.4), - Move(card(0.0, 0.0)), - Wait(0.5), - Shot("egui-9-switched"), - ]); - // Beyond the recorded frames. - for (name, tab) in [ - ("egui-riot", pos2(447.0, 18.0)), - ("egui-roblox", pos2(481.0, 18.0)), - ] { - click(&mut s, tab); - s.extend([Away, Wait(0.7), Shot(name)]); - } - click(&mut s, pos2(413.0, 18.0)); - s.push(Wait(0.4)); - click(&mut s, card(0.0, 0.0)); - s.extend([Away, Wait(0.7), Shot("egui-folder")]); - click(&mut s, pos2(35.0, 60.0)); - s.push(Wait(0.3)); - click(&mut s, pos2(816.0, 60.0)); - s.extend([Away, Wait(0.7), Shot("egui-list"), Quit]); - s -} - -impl Capture { - pub fn new(dir: PathBuf) -> Self { - Self { - dir, - steps: script(), - next: 0, - wake_at: 0.0, - clock: Instant::now(), - pending_shot: None, - done: false, - } - } - - /// Inject this frame's scripted events. Nothing runs before `ready` - /// (every avatar uploaded) or while a screenshot is in flight. - pub fn feed(&mut self, ctx: &egui::Context, raw: &mut RawInput, ready: bool) { - // The window is never activated; egui must still treat it as focused - // for the text field to take the typed query. - raw.focused = true; - ctx.request_repaint(); - if !ready || self.pending_shot.is_some() || self.done { - return; - } - let now = self.clock.elapsed().as_secs_f64(); - if self.next == 0 && self.wake_at == 0.0 { - self.wake_at = now; - } - while now >= self.wake_at && self.next < self.steps.len() { - let step = self.steps[self.next]; - self.next += 1; - let button = |pos, pressed| Event::PointerButton { - pos, - button: PointerButton::Primary, - pressed, - modifiers: Modifiers::NONE, - }; - match step { - Step::Wait(secs) => { - self.wake_at = now + secs; - return; - } - Step::Move(p) => raw.events.push(Event::PointerMoved(p)), - Step::Press(p) => { - raw.events.push(button(p, true)); - // Release on the next frame, like a real click. - return; - } - Step::Release(p) => raw.events.push(button(p, false)), - Step::Text(text) => raw.events.push(Event::Text(text.into())), - Step::Away => raw.events.push(Event::PointerGone), - Step::Shot(name) => { - ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(Default::default())); - self.pending_shot = Some(name); - return; - } - Step::Quit => { - self.done = true; - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - return; - } - } - } - } - - /// Write the screenshot that arrived this frame, if any. - pub fn collect(&mut self, ctx: &egui::Context) { - let Some(name) = self.pending_shot else { - return; - }; - let image = ctx.input(|i| { - i.raw.events.iter().find_map(|e| match e { - Event::Screenshot { image, .. } => Some(image.clone()), - _ => None, - }) - }); - let Some(image) = image else { return }; - let path = self.dir.join(format!("{name}.pam")); - if let Err(err) = write_pam(&path, &image) { - eprintln!("capture: {}: {err}", path.display()); - } - crate::bench::mark_with( - "shot", - &format!( - "\"name\":\"{name}\",\"w\":{},\"h\":{}", - image.size[0], image.size[1] - ), - ); - self.pending_shot = None; - } -} - -fn write_pam(path: &std::path::Path, image: &egui::ColorImage) -> std::io::Result<()> { - let [w, h] = image.size; - let mut out = std::io::BufWriter::new(std::fs::File::create(path)?); - write!( - out, - "P7\nWIDTH {w}\nHEIGHT {h}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n" - )?; - for px in &image.pixels { - out.write_all(&px.to_srgba_unmultiplied())?; - } - out.flush() -} diff --git a/crates/accshift-egui/src/harness/mod.rs b/crates/accshift-egui/src/harness/mod.rs new file mode 100644 index 0000000..2e43c25 --- /dev/null +++ b/crates/accshift-egui/src/harness/mod.rs @@ -0,0 +1,581 @@ +//! Scene runner: `accshift-egui --scene ... --out `. +//! +//! Replays the scene files shared with `parity/ref.mjs` (format in +//! `parity/README.md`). Each scene resizes the hidden window to its viewport +//! and scale, builds its screen, waits until the screen is ready (every +//! avatar uploaded), then injects its steps as egui raw input on the wall +//! clock, like the reference runner does over CDP. A `shot` reads back the +//! frame the renderer presented and writes `//.png`. +//! +//! Failures (a scene the egui side cannot render yet, a shot of the wrong +//! size, a write error) are printed and counted; the process exits non-zero +//! when any happened ([`failures`]). + +use std::collections::VecDeque; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Instant; + +use egui::{ + pos2, vec2, Event, Key, Modifiers, MouseWheelUnit, PointerButton, Pos2, RawInput, TouchPhase, + ViewportCommand, ViewportId, +}; + +static FAILURES: AtomicUsize = AtomicUsize::new(0); + +/// Failures recorded by every run in this process. +pub fn failures() -> usize { + FAILURES.load(Ordering::Relaxed) +} + +fn fail(msg: String) { + eprintln!("scene: {msg}"); + FAILURES.fetch_add(1, Ordering::Relaxed); +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Viewport { + pub width: f32, + pub height: f32, + pub scale: f32, +} + +impl Viewport { + /// The frame size in device pixels. + pub fn pixels(&self) -> [usize; 2] { + [ + (self.width * self.scale).round() as usize, + (self.height * self.scale).round() as usize, + ] + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Step { + Wait(f64), + Move(Pos2), + Click(Pos2), + DblClick(Pos2), + RClick(Pos2), + Key(String), + Text(String), + Scroll(Pos2, f32), + Shot(String), +} + +#[derive(Clone, Debug)] +pub struct Scene { + pub id: String, + pub scenario: String, + pub os: Option, + pub viewport: Viewport, + pub theme: Option, + pub locale: Option, + pub seed: Option, + /// The `html` fragment (resolved next to the scene file) and its text. + pub html: Option<(PathBuf, String)>, + pub steps: Vec, +} + +impl Scene { + /// The fragment's file stem: which test screen draws it. + pub fn fragment_name(&self) -> Option { + let (path, _) = self.html.as_ref()?; + Some(path.file_stem()?.to_string_lossy().into_owned()) + } +} + +fn point(v: &serde_json::Value) -> Option { + let a = v.as_array()?; + Some(pos2( + a.first()?.as_f64()? as f32, + a.get(1)?.as_f64()? as f32, + )) +} + +fn parse_step(v: &serde_json::Value) -> Result { + let o = v.as_object().ok_or("step is not an object")?; + let bad = || format!("bad step {v}"); + if let Some(ms) = o.get("wait") { + return Ok(Step::Wait(ms.as_f64().ok_or_else(bad)? / 1000.0)); + } + if let Some(p) = o.get("move") { + return Ok(Step::Move(point(p).ok_or_else(bad)?)); + } + if let Some(p) = o.get("click") { + return Ok(Step::Click(point(p).ok_or_else(bad)?)); + } + if let Some(p) = o.get("dblclick") { + return Ok(Step::DblClick(point(p).ok_or_else(bad)?)); + } + if let Some(p) = o.get("rclick") { + return Ok(Step::RClick(point(p).ok_or_else(bad)?)); + } + if let Some(k) = o.get("key") { + return Ok(Step::Key(k.as_str().ok_or_else(bad)?.to_owned())); + } + if let Some(t) = o.get("text") { + return Ok(Step::Text(t.as_str().ok_or_else(bad)?.to_owned())); + } + if let Some(s) = o.get("scroll") { + let a = s.as_array().ok_or_else(bad)?; + let f = |i: usize| a.get(i).and_then(serde_json::Value::as_f64).ok_or_else(bad); + return Ok(Step::Scroll(pos2(f(0)? as f32, f(1)? as f32), f(2)? as f32)); + } + if let Some(s) = o.get("shot") { + return Ok(Step::Shot(s.as_str().ok_or_else(bad)?.to_owned())); + } + Err(bad()) +} + +pub fn load(path: &Path) -> Result { + let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; + let v: serde_json::Value = + serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?; + let s = |k: &str| v.get(k).and_then(|x| x.as_str()).map(str::to_owned); + let id = s("id").unwrap_or_else(|| { + path.file_stem() + .map(|x| x.to_string_lossy().into_owned()) + .unwrap_or_default() + }); + let vp = v.get("viewport"); + let num = |k: &str, d: f64| { + vp.and_then(|o| o.get(k)) + .and_then(serde_json::Value::as_f64) + .unwrap_or(d) as f32 + }; + let viewport = Viewport { + width: num("width", 860.0), + height: num("height", 440.0), + scale: num("scale", 1.5), + }; + let html = match s("html") { + Some(rel) => { + let file = path.parent().unwrap_or(Path::new(".")).join(rel); + let body = std::fs::read_to_string(&file) + .map_err(|e| format!("{}: html {}: {e}", path.display(), file.display()))?; + Some((file, body)) + } + None => None, + }; + let steps = v + .get("steps") + .and_then(|x| x.as_array()) + .map(|a| a.iter().map(parse_step).collect::, _>>()) + .transpose() + .map_err(|e| format!("{}: {e}", path.display()))? + .unwrap_or_default(); + Ok(Scene { + id, + scenario: s("scenario").unwrap_or_else(|| "demo".into()), + os: s("os"), + viewport, + theme: s("theme"), + locale: s("locale"), + seed: v.get("seed").cloned(), + html, + steps, + }) +} + +/// `Ctrl+Shift+K`, `Enter`, `a`: the key event and the text it types, if any. +fn key_events(spec: &str) -> Result, String> { + let mut parts: Vec<&str> = spec.split('+').collect(); + let name = parts.pop().ok_or_else(|| format!("empty key {spec}"))?; + let mut modifiers = Modifiers::NONE; + for m in parts { + match m { + "Ctrl" => { + modifiers.ctrl = true; + modifiers.command = true; + } + "Shift" => modifiers.shift = true, + "Alt" => modifiers.alt = true, + "Meta" => modifiers.mac_cmd = true, + other => return Err(format!("unknown modifier {other} in {spec}")), + } + } + let key = Key::from_name(name) + .or_else(|| Key::from_name(&name.to_uppercase())) + .ok_or_else(|| format!("unknown key {name}"))?; + let ev = |pressed| Event::Key { + key, + physical_key: None, + pressed, + repeat: false, + modifiers, + }; + let mut out = vec![ev(true)]; + let typed = !(modifiers.ctrl || modifiers.alt || modifiers.mac_cmd); + if typed { + if name.chars().count() == 1 { + let text = if modifiers.shift { + name.to_uppercase() + } else { + name.to_owned() + }; + out.push(Event::Text(text)); + } else if name == "Space" { + out.push(Event::Text(" ".into())); + } + } + out.push(ev(false)); + Ok(out) +} + +enum Op { + Wait(f64), + Event(Event), + Shot(String), +} + +fn expand(steps: &[Step]) -> Result, String> { + let mut ops = VecDeque::new(); + let mut pointer = pos2(0.0, 0.0); + let button = |pos, button, pressed| { + Op::Event(Event::PointerButton { + pos, + button, + pressed, + modifiers: Modifiers::NONE, + }) + }; + // Same rhythm as ref.mjs: 40 ms between press and release. + let press = |ops: &mut VecDeque, p: Pos2, b: PointerButton| { + ops.push_back(button(p, b, true)); + ops.push_back(Op::Wait(0.04)); + ops.push_back(button(p, b, false)); + }; + for step in steps { + match step { + Step::Wait(s) => ops.push_back(Op::Wait(*s)), + Step::Move(p) => { + pointer = *p; + ops.push_back(Op::Event(Event::PointerMoved(*p))); + } + Step::Click(p) | Step::DblClick(p) | Step::RClick(p) => { + pointer = *p; + ops.push_back(Op::Event(Event::PointerMoved(*p))); + let b = if matches!(step, Step::RClick(_)) { + PointerButton::Secondary + } else { + PointerButton::Primary + }; + press(&mut ops, *p, b); + if matches!(step, Step::DblClick(_)) { + press(&mut ops, *p, b); + } + } + Step::Key(spec) => { + for e in key_events(spec)? { + ops.push_back(Op::Event(e)); + } + } + Step::Text(text) => { + for ch in text.chars() { + if ch == ' ' { + for e in key_events("Space")? { + ops.push_back(Op::Event(e)); + } + } else { + ops.push_back(Op::Event(Event::Text(ch.to_string()))); + } + ops.push_back(Op::Wait(0.03)); + } + } + Step::Scroll(p, dy) => { + if *p != pointer { + pointer = *p; + ops.push_back(Op::Event(Event::PointerMoved(*p))); + } + // CSS deltaY > 0 scrolls the content up; egui's delta is + // how the content moves. + ops.push_back(Op::Event(Event::MouseWheel { + unit: MouseWheelUnit::Point, + delta: vec2(0.0, -dy), + phase: TouchPhase::Move, + modifiers: Modifiers::NONE, + })); + } + Step::Shot(name) => ops.push_back(Op::Shot(name.clone())), + } + } + Ok(ops) +} + +enum Phase { + /// Next scene to set up. + Next, + /// Window resized and zoom set; waiting for the frame to match. + Sizing { + since: f64, + resent: bool, + }, + /// The screen exists; waiting for it to be ready, then a few frames. + Settling { + frames: u32, + }, + Running, + Done, +} + +pub struct SceneRun { + scenes: Vec, + out: PathBuf, + index: usize, + phase: Phase, + ops: VecDeque, + wake_at: f64, + clock: Instant, + pending_shot: Option, + build: Option, +} + +impl SceneRun { + pub fn new(scenes: Vec, out: PathBuf) -> Self { + Self { + scenes, + out, + index: 0, + phase: Phase::Next, + ops: VecDeque::new(), + wake_at: 0.0, + clock: Instant::now(), + pending_shot: None, + build: None, + } + } + + pub fn scene(&self) -> Option<&Scene> { + self.scenes.get(self.index) + } + + /// A scene whose screen the shell must build now. + pub fn take_build(&mut self) -> Option { + self.build.take() + } + + /// The current scene cannot be drawn: count it and move on. + pub fn skip(&mut self, why: &str) { + if let Some(scene) = self.scenes.get(self.index) { + fail(format!("{}: {why}", scene.id)); + } + self.index += 1; + self.phase = Phase::Next; + self.ops.clear(); + } + + /// Inject this frame's events. `ready` is the current screen's own + /// readiness (avatars uploaded); nothing runs before it. + pub fn feed(&mut self, ctx: &egui::Context, raw: &mut RawInput, ready: bool) { + // The window is never activated; egui must still treat it as focused + // for text fields to take typed text. + raw.focused = true; + ctx.request_repaint(); + let now = self.clock.elapsed().as_secs_f64(); + match self.phase { + Phase::Done => {} + Phase::Next => { + let Some(scene) = self.scenes.get(self.index) else { + self.phase = Phase::Done; + ctx.send_viewport_cmd(ViewportCommand::Close); + return; + }; + let native = raw + .viewports + .get(&ViewportId::ROOT) + .and_then(|v| v.native_pixels_per_point) + .or_else(|| ctx.native_pixels_per_point()) + .unwrap_or(1.0); + let vp = scene.viewport; + ctx.set_zoom_factor(vp.scale / native); + ctx.send_viewport_cmd(ViewportCommand::InnerSize(vec2(vp.width, vp.height))); + match expand(&scene.steps) { + Ok(ops) => self.ops = ops, + Err(e) => { + self.skip(&e); + return; + } + } + // A fresh pointer and fresh widget state for every scene. + raw.events.push(Event::PointerGone); + ctx.data_mut(|d| d.clear()); + ctx.clear_animations(); + self.phase = Phase::Sizing { + since: now, + resent: false, + }; + } + Phase::Sizing { since, resent } => { + let Some(scene) = self.scenes.get(self.index) else { + return; + }; + let vp = scene.viewport; + let rect = ctx.content_rect(); + let zoomed = (ctx.pixels_per_point() - vp.scale).abs() < 1e-4; + let fits = zoomed + && (rect.width() - vp.width).abs() < 0.01 + && (rect.height() - vp.height).abs() < 0.01; + if !fits && zoomed && !resent { + // The size went out with the old zoom: ask again in points + // of the new one. + ctx.send_viewport_cmd(ViewportCommand::InnerSize(vec2(vp.width, vp.height))); + self.phase = Phase::Sizing { + since, + resent: true, + }; + } else if fits { + self.build = Some(scene.clone()); + self.phase = Phase::Settling { frames: 0 }; + } else if now - since > 5.0 { + self.skip(&format!( + "window never reached {}x{} at {} (got {:.1}x{:.1} at {})", + vp.width, + vp.height, + vp.scale, + rect.width(), + rect.height(), + ctx.pixels_per_point() + )); + } + } + Phase::Settling { frames } => { + if self.build.is_some() { + return; + } + if !ready { + return; + } + // Two frames with everything uploaded, then the clock starts. + if frames < 2 { + self.phase = Phase::Settling { frames: frames + 1 }; + return; + } + self.phase = Phase::Running; + self.wake_at = now; + self.run_ops(ctx, raw, now); + } + Phase::Running => self.run_ops(ctx, raw, now), + } + } + + fn run_ops(&mut self, ctx: &egui::Context, raw: &mut RawInput, now: f64) { + if self.pending_shot.is_some() { + return; + } + while now >= self.wake_at { + let Some(op) = self.ops.pop_front() else { + self.index += 1; + self.phase = Phase::Next; + return; + }; + match op { + Op::Wait(secs) => { + self.wake_at = now + secs; + return; + } + Op::Event(e) => { + let is_press = matches!(e, Event::PointerButton { pressed: true, .. }); + raw.events.push(e); + if is_press { + return; + } + } + Op::Shot(name) => { + ctx.send_viewport_cmd(ViewportCommand::Screenshot(Default::default())); + self.pending_shot = Some(name); + return; + } + } + } + } + + /// Write the screenshot that arrived this frame, if any. + pub fn collect(&mut self, ctx: &egui::Context) { + let Some(name) = self.pending_shot.clone() else { + return; + }; + let image = ctx.input(|i| { + i.raw.events.iter().find_map(|e| match e { + Event::Screenshot { image, .. } => Some(image.clone()), + _ => None, + }) + }); + let Some(image) = image else { return }; + self.pending_shot = None; + let Some(scene) = self.scenes.get(self.index) else { + return; + }; + let dir = self.out.join(&scene.id); + let path = dir.join(format!("{name}.png")); + let want = scene.viewport.pixels(); + if image.size != want { + fail(format!( + "{}/{name}: frame {}x{}, expected {}x{}", + scene.id, image.size[0], image.size[1], want[0], want[1] + )); + } + let written = std::fs::create_dir_all(&dir) + .map_err(|e| e.to_string()) + .and_then(|()| write_png(&path, &image)); + match written { + Ok(()) => println!( + "{}/{name}.png {}x{}", + scene.id, image.size[0], image.size[1] + ), + Err(e) => fail(format!("{}: {e}", path.display())), + } + crate::bench::mark_with( + "shot", + &format!( + "\"scene\":\"{}\",\"name\":\"{name}\",\"w\":{},\"h\":{}", + scene.id, image.size[0], image.size[1] + ), + ); + } +} + +/// RGBA PNG, unpremultiplied like a browser screenshot. +fn write_png(path: &Path, image: &egui::ColorImage) -> Result<(), String> { + let [w, h] = image.size; + let file = std::fs::File::create(path).map_err(|e| e.to_string())?; + let mut encoder = png::Encoder::new(std::io::BufWriter::new(file), w as u32, h as u32); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().map_err(|e| e.to_string())?; + let data: Vec = image + .pixels + .iter() + .flat_map(|p| p.to_srgba_unmultiplied()) + .collect(); + writer.write_image_data(&data).map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spike_scene_parses() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("parity/scenes/spike-main.json"); + let scene = load(&path).unwrap(); + assert_eq!(scene.id, "spike-main"); + assert_eq!(scene.viewport.pixels(), [1290, 660]); + let shots = scene + .steps + .iter() + .filter(|s| matches!(s, Step::Shot(_))) + .count(); + assert_eq!(shots, 8); + assert_eq!(scene.steps[0], Step::Wait(0.6)); + } + + #[test] + fn keys_expand_to_events() { + let ev = key_events("Ctrl+K").unwrap(); + assert_eq!(ev.len(), 2); + let ev = key_events("a").unwrap(); + assert!(matches!(&ev[1], Event::Text(t) if t == "a")); + assert!(key_events("Hyper+K").is_err()); + } +} diff --git a/crates/accshift-egui/src/lib.rs b/crates/accshift-egui/src/lib.rs index b2e5d30..aace9cd 100644 --- a/crates/accshift-egui/src/lib.rs +++ b/crates/accshift-egui/src/lib.rs @@ -1,41 +1,52 @@ -//! Spike: the accshift main screen in egui. See `README.md` next to this crate. +//! The accshift UI in egui. See `README.md` next to this crate. -pub mod app; pub mod avatars; pub mod backdrop; pub mod bench; -pub mod capture; pub mod data; +pub mod harness; pub mod icons; +pub mod screens; #[cfg(feature = "software")] pub mod soft; pub mod theme; +pub mod ui; -pub use app::{AccshiftApp, Options}; +pub use screens::main_spike::{AccshiftApp, Options}; +pub use screens::Screen; #[cfg(all(feature = "software", feature = "glow"))] compile_error!("pick one renderer: `software` (default) or `glow` with --no-default-features"); -/// What both runners drive: the app plus the optional capture script. The -/// window is cleared to transparent so the OS acrylic shows through the 55 % -/// window fill, exactly like the Tauri window. +/// What both runners drive: the current screen plus the optional scene run. +/// The window is cleared to transparent so the OS acrylic shows through the +/// 55 % window fill, exactly like the Tauri window. pub struct Shell { - pub app: AccshiftApp, - pub capture: Option, + pub screen: Screen, + pub scenes: Option, } impl Shell { pub fn input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { - if let Some(capture) = &mut self.capture { - capture.feed(ctx, raw_input, self.app.avatars_ready()); + if let Some(run) = &mut self.scenes { + run.feed(ctx, raw_input, self.screen.ready()); } } pub fn frame(&mut self, ui: &mut egui::Ui) { - if let Some(capture) = &mut self.capture { - capture.collect(ui.ctx()); + if let Some(run) = &mut self.scenes { + run.collect(ui.ctx()); + if let Some(scene) = run.take_build() { + match Screen::for_scene(ui.ctx(), &scene) { + Ok(screen) => self.screen = screen, + Err(why) => { + self.screen = Screen::Blank; + run.skip(&why); + } + } + } } - self.app.ui(ui); + self.screen.ui(ui); } } diff --git a/crates/accshift-egui/src/main.rs b/crates/accshift-egui/src/main.rs index 4a9a7d1..8f70471 100644 --- a/crates/accshift-egui/src/main.rs +++ b/crates/accshift-egui/src/main.rs @@ -1,22 +1,50 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use accshift_egui::{bench, capture::Capture, AccshiftApp, Options, Shell}; +use accshift_egui::{bench, harness, AccshiftApp, Options, Screen, Shell}; use raw_window_handle::HasWindowHandle; fn main() { let bench_build = cfg!(feature = "startup-bench"); - // `--capture `: scripted offscreen shots, see src/capture.rs. - let capture_dir = { + // `--scene ... --out `: replay parity scenes in a hidden + // window, see src/harness/mod.rs. Nothing is parsed before main otherwise. + let (scene_files, out_dir) = { + let mut files = Vec::new(); + let mut out = None; let mut args = std::env::args().skip(1); - let mut dir = None; + let mut in_scenes = false; while let Some(arg) = args.next() { - if arg == "--capture" { - dir = args.next().map(std::path::PathBuf::from); + if arg == "--scene" { + in_scenes = true; + } else if arg == "--out" { + out = args.next().map(std::path::PathBuf::from); + in_scenes = false; + } else if in_scenes { + files.push(std::path::PathBuf::from(arg)); } } - dir + (files, out) }; - let hidden = bench_build || capture_dir.is_some(); + let scene_mode = !scene_files.is_empty(); + let scenes = if scene_mode { + let Some(out) = out_dir else { + eprintln!("usage: accshift-egui --scene ... --out "); + std::process::exit(2); + }; + let mut loaded = Vec::new(); + for file in &scene_files { + match harness::load(file) { + Ok(scene) => loaded.push(scene), + Err(err) => { + eprintln!("scene: {err}"); + std::process::exit(2); + } + } + } + Some((loaded, out)) + } else { + None + }; + let hidden = bench_build || scene_mode; // Experiment knob for the boot breakdown: an opaque window without the // acrylic, to price the transparent framebuffer. let opaque = std::env::var_os("ACCSHIFT_EGUI_OPAQUE").is_some(); @@ -34,9 +62,11 @@ fn main() { ); if hidden { // Safety net: a hidden process never outlives its run by much. - std::thread::spawn(|| { - std::thread::sleep(std::time::Duration::from_secs(60)); - std::process::exit(0); + let budget = 60 + 30 * scene_files.len() as u64; + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_secs(budget)); + eprintln!("scene: still running after {budget} s, giving up"); + std::process::exit(3); }); } @@ -73,13 +103,16 @@ fn main() { .with_title("Accshift (egui spike)") .with_app_id("accshift-egui") .with_inner_size([860.0, 440.0]) - .with_min_inner_size([520.0, 320.0]) .with_decorations(false) .with_transparent(!opaque) // No file drops in this app, and winit's drop target costs an // `OleInitialize` on the UI thread before the window exists. .with_drag_and_drop(false) .with_resizable(true); + if !scene_mode { + // A scene sets its own viewport, which may be smaller. + viewport = viewport.with_min_inner_size([520.0, 320.0]); + } if hidden { // Never activated, never in the taskbar, off every monitor, and // cloaked below: both runners show the window after its first frame @@ -91,31 +124,29 @@ fn main() { } // Shared by both runners once the window exists. - let build = move |ctx: &egui::Context, window: &dyn HasWindowHandle, native_ppp: f32| { + let build = move |ctx: &egui::Context, window: &dyn HasWindowHandle| { #[cfg(windows)] window_effects(window, hidden, !opaque); #[cfg(not(windows))] let _ = window; bench::mark("app_created"); - let capture = capture_dir.map(|dir| { - std::fs::create_dir_all(&dir).ok(); - // The Tauri recorder's scale: 860x440 points at 1.5. Another - // scale prices the raster on a bigger window. - let scale = std::env::var("ACCSHIFT_EGUI_CAPTURE_SCALE") - .ok() - .and_then(|s| s.parse::().ok()) - .unwrap_or(1.5); - ctx.set_zoom_factor(scale / native_ppp); - ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(860.0, 440.0))); - Capture::new(dir) + let scenes = scenes.map(|(scenes, out)| { + std::fs::create_dir_all(&out).ok(); + harness::SceneRun::new(scenes, out) }); - let options = Options { - desktop_backdrop: capture.is_some(), + let screen = if scenes.is_some() { + // Each scene builds its own screen once the window has its size. + accshift_egui::screens::main_spike::install_fonts(ctx); + Screen::Blank + } else { + Screen::Main(Box::new(AccshiftApp::new( + ctx, + Options { + desktop_backdrop: false, + }, + ))) }; - Shell { - app: AccshiftApp::new(ctx, options), - capture, - } + Shell { screen, scenes } }; #[cfg(feature = "software")] @@ -123,7 +154,7 @@ fn main() { viewport, Box::new( move |ctx: &egui::Context, window: &egui_winit::winit::window::Window| { - build(ctx, window, window.scale_factor() as f32) + build(ctx, window) }, ), ) @@ -145,10 +176,7 @@ fn main() { eframe::run_native( "accshift-egui", options, - Box::new(move |cc| { - let native = cc.egui_ctx.native_pixels_per_point().unwrap_or(1.0); - Ok(Box::new(build(&cc.egui_ctx, cc, native))) - }), + Box::new(move |cc| Ok(Box::new(build(&cc.egui_ctx, cc)))), ) .map_err(|err| err.to_string()) }; @@ -157,6 +185,10 @@ fn main() { eprintln!("{err}"); std::process::exit(1); } + if scene_mode && harness::failures() > 0 { + eprintln!("scene: {} failure(s)", harness::failures()); + std::process::exit(1); + } } #[cfg(windows)] diff --git a/crates/accshift-egui/src/app.rs b/crates/accshift-egui/src/screens/main_spike.rs similarity index 99% rename from crates/accshift-egui/src/app.rs rename to crates/accshift-egui/src/screens/main_spike.rs index d33a7c2..0850baf 100644 --- a/crates/accshift-egui/src/app.rs +++ b/crates/accshift-egui/src/screens/main_spike.rs @@ -1020,7 +1020,9 @@ fn semibold() -> FontFamily { /// Segoe UI from the system, like the webview's `system-ui` fallback (Inter /// is not installed here, so the Tauri build renders Segoe UI too). -fn install_fonts(ctx: &egui::Context) { +/// The scene runner calls it before the first frame: a screen built +/// mid-frame only gets its fonts one frame later. +pub fn install_fonts(ctx: &egui::Context) { let mut fonts = FontDefinitions::default(); let dir = std::env::var("WINDIR").unwrap_or_else(|_| r"C:\Windows".into()); let read = |name: &str| std::fs::read(format!(r"{dir}\Fonts\{name}")).ok(); diff --git a/crates/accshift-egui/src/screens/mod.rs b/crates/accshift-egui/src/screens/mod.rs new file mode 100644 index 0000000..d4d4ade --- /dev/null +++ b/crates/accshift-egui/src/screens/mod.rs @@ -0,0 +1,62 @@ +//! Screens: what fills the window. + +pub mod main_spike; +pub mod primitives; + +use crate::harness::Scene; + +pub enum Screen { + /// Nothing yet: a scene run before its first scene is set up. + Blank, + Main(Box), + Primitives(primitives::Primitives), +} + +impl Screen { + /// The screen a scene asks for, or why the egui side cannot draw it yet. + pub fn for_scene(ctx: &egui::Context, scene: &Scene) -> Result { + if let Some((_, html)) = &scene.html { + return Ok(Screen::Primitives(primitives::Primitives::parse(html)?)); + } + // The spike only knows its own demo data and Glass Dark; the mock + // data in parity/data/ is wired by a later phase. + let supported = scene.scenario == "demo" + && scene.theme.as_deref().is_none_or(|t| t == "glass-dark") + && scene.locale.as_deref().is_none_or(|l| l == "en") + && scene.os.as_deref().is_none_or(|o| o == "windows") + && scene.seed.is_none(); + if !supported { + return Err(format!( + "the spike screen only draws scenario demo, theme glass-dark, locale en, os windows, no seed (got scenario {}, theme {:?}, locale {:?}, os {:?}, seed {})", + scene.scenario, + scene.theme, + scene.locale, + scene.os, + if scene.seed.is_some() { "set" } else { "none" }, + )); + } + Ok(Screen::Main(Box::new(main_spike::AccshiftApp::new( + ctx, + main_spike::Options { + desktop_backdrop: true, + }, + )))) + } + + /// Ready to be photographed: every asset uploaded. + pub fn ready(&self) -> bool { + match self { + Screen::Blank => false, + Screen::Main(app) => app.avatars_ready(), + Screen::Primitives(_) => true, + } + } + + pub fn ui(&mut self, ui: &mut egui::Ui) { + match self { + Screen::Blank => {} + Screen::Main(app) => app.ui(ui), + Screen::Primitives(p) => p.ui(ui), + } + } +} diff --git a/crates/accshift-egui/src/screens/primitives.rs b/crates/accshift-egui/src/screens/primitives.rs new file mode 100644 index 0000000..e8577c9 --- /dev/null +++ b/crates/accshift-egui/src/screens/primitives.rs @@ -0,0 +1,71 @@ +//! Calibration screen for `ui::paint`: draws the boxes of an HTML fragment of +//! `parity/fragments/`, the same file `parity/ref.mjs` injects into the Tauri +//! page, so both sides paint identical CSS values. +//! +//! The fragment format is deliberately narrow: ` +
+ +
+ +

+ Help make accshift better? +

+ +

+ The deal +

+ +

+ Help shape accshift +

+ +

+ FL +

+ +

+ FL +

+ +

+ Streamer +

+ +

+ Streamer mode +

+ +

+ Weight 100 账号设置 +

+ +

+ Weight 200 账号设置 +

+ +

+ Weight 300 账号设置 +

+ +

+ Weight 350 账号设置 +

+ +

+ Weight 400 账号设置 +

+ +

+ Weight 450 账号设置 +

+ +

+ Weight 500 账号设置 +

+ +

+ Weight 550 账号设置 +

+ +

+ Weight 600 账号设置 +

+ +

+ Weight 650 账号设置 +

+ +

+ Weight 700 账号设置 +

+ +

+ Weight 750 账号设置 +

+ +

+ Weight 800 账号设置 +

+ +

+ Weight 850 账号设置 +

+ +

+ Weight 900 账号设置 +

+
diff --git a/crates/accshift-egui/parity/fragments/typography.html b/crates/accshift-egui/parity/fragments/typography.html new file mode 100644 index 0000000..d6db9f4 --- /dev/null +++ b/crates/accshift-egui/parity/fragments/typography.html @@ -0,0 +1,863 @@ + +
+ +
+ +

+ i +

+ +

+ Inherited from theme +

+ +

+ Main account, do not share +

+ +

+ Level 42 progress +

+ +

+ Beta +

+ +

+ Active +

+ +

+ Click again to switch +

+ +

+ Appearance +

+ +

+ VAC banned +

+ +

+ Prime Premier 14,203 +

+ +

+ Counter-Strike 2 +

+ +

+ Not detected 128 / 280 +

+ +

+ Violet +

+ +

+ Last login 2 hours ago +

+ +

+ Open Steam profile +

+ +

+ 3 days ago +

+ +

+ Ctrl+K Connected +

+ +

+ Accounts +

+ +

+ Custom colour +

+ +

+ Note +

+ +

+ Limited account +

+ +

+ Wrong PIN, 2 tries left +

+ +

+ C:\Program Files (x86)\Steam +

+ +

+ Hide account names when you go live +

+ +

+ Nothing is sent until you agree +

+ +

+ Undo +

+ +

+ Steam +

+ +

+ Surfaces +

+ +

+ Online +

+ +

+ 17 +

+ +

+ accshift 1.0.5 +

+ +

+ Switching +

+ +

+ Commands +

+ +

+ Crash reports and anonymous usage +

+ +

+ Search accounts +

+ +

+ Added 12 March 2026 +

+ +

+ Remove this account from accshift? +

+ +

+ Could not save your choice +

+ +

+ We never see your passwords +

+ +

+ Smurfs +

+ +

+ Version 1.0.5 +

+ +

+ Recent +

+ +

+ xXSniperWolfXx +

+ +

+ New persona +

+ +

+ Steam +

+ +

+ Startup +

+ +

+ Help us find bugs faster +

+ +

+ Switch to main account +

+ +

+ Reset +

+ +

+ Frosty Lynx +

+ +

+ Edit 4 accounts +

+ +

+ Weekend squad +

+ +

+ Type a command or search +

+ +

+ Remove +

+ +

+ Settings +

+ +

+ Enter your PIN +

+ +

+ Frosty Lynx +

+ +

+ 1234 +

+ +

+ :root { --accent: #8b5cf6; } +

+ +

+ Сменить аккаунт, настройки +

+ +

+ 切换账号 设置 上次登录 +

+ +

+ 账号 文件夹 Steam +

+
diff --git a/crates/accshift-egui/parity/ref.mjs b/crates/accshift-egui/parity/ref.mjs index 410cc91..6ff4966 100644 --- a/crates/accshift-egui/parity/ref.mjs +++ b/crates/accshift-egui/parity/ref.mjs @@ -318,9 +318,65 @@ async function runScene(page, scene, outDir, manifest) { }); } else console.error(`${scene.id}: unknown step ${JSON.stringify(step)}`); } + if (scene.probe) await probeText(page, scene, outDir); await send("Page.removeScriptToEvaluateOnNewDocument", { identifier: desk.identifier }); } +// `probe` (optional, with `html`): after the shots, every `body p` of the +// fragment is measured and asked which installed face draws it. Writes +// //probe.json: per line the computed font values, the line box, +// the text's own box (a Range over the contents: advances plus letter +// spacing), the baseline (bottom of a zero-size inline-block appended for +// the measure, then removed) in CSS px, and CSS.getPlatformFontsForNode. +const PROBE = ` +(() => [...document.querySelectorAll("body p")].map((p) => { + const box = p.getBoundingClientRect(); + const range = document.createRange(); + range.selectNodeContents(p); + const ink = range.getBoundingClientRect(); + const mark = document.createElement("span"); + mark.style.cssText = "display:inline-block;width:0;height:0;vertical-align:baseline"; + p.appendChild(mark); + const baseline = mark.getBoundingClientRect().bottom; + mark.remove(); + const cs = getComputedStyle(p); + return { + text: p.textContent, + family: cs.fontFamily, + size: cs.fontSize, + weight: cs.fontWeight, + lineHeight: cs.lineHeight, + letterSpacing: cs.letterSpacing, + transform: cs.textTransform, + box: [box.left, box.top, box.width, box.height], + text_box: [ink.left, ink.top, ink.width, ink.height], + baseline, + }; +}))() +`; + +async function probeText(page, scene, outDir) { + const { send, js } = page; + const lines = await js(PROBE); + await send("DOM.enable"); + await send("CSS.enable"); + const { root } = await send("DOM.getDocument", { depth: -1 }); + const { nodeIds } = await send("DOM.querySelectorAll", { + nodeId: root.nodeId, + selector: "body p", + }); + for (const [i, nodeId] of nodeIds.entries()) { + const { fonts } = await send("CSS.getPlatformFontsForNode", { nodeId }); + if (lines[i]) lines[i].platformFonts = fonts; + } + await send("CSS.disable"); + await send("DOM.disable"); + const dir = join(outDir, scene.id); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "probe.json"), JSON.stringify(lines, null, 2) + "\n"); + console.log(`${scene.id}/probe.json ${lines.length} lines`); +} + async function main() { const opts = parseArgs(process.argv.slice(2)); const out = resolve(opts.out); diff --git a/crates/accshift-egui/parity/scenes/typography-100.json b/crates/accshift-egui/parity/scenes/typography-100.json new file mode 100644 index 0000000..0677c3a --- /dev/null +++ b/crates/accshift-egui/parity/scenes/typography-100.json @@ -0,0 +1,22 @@ +{ + "id": "typography-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 680, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/typography.html", + "probe": true, + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/typography-125.json b/crates/accshift-egui/parity/scenes/typography-125.json new file mode 100644 index 0000000..f64dfc5 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/typography-125.json @@ -0,0 +1,22 @@ +{ + "id": "typography-125", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 680, + "scale": 1.25 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/typography.html", + "probe": true, + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/typography-150.json b/crates/accshift-egui/parity/scenes/typography-150.json new file mode 100644 index 0000000..4feeb79 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/typography-150.json @@ -0,0 +1,22 @@ +{ + "id": "typography-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 680, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/typography.html", + "probe": true, + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/typography-display-100.json b/crates/accshift-egui/parity/scenes/typography-display-100.json new file mode 100644 index 0000000..de256d5 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/typography-display-100.json @@ -0,0 +1,22 @@ +{ + "id": "typography-display-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/typography-display.html", + "probe": true, + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/typography-display-125.json b/crates/accshift-egui/parity/scenes/typography-display-125.json new file mode 100644 index 0000000..f221bfb --- /dev/null +++ b/crates/accshift-egui/parity/scenes/typography-display-125.json @@ -0,0 +1,22 @@ +{ + "id": "typography-display-125", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.25 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/typography-display.html", + "probe": true, + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/typography-display-150.json b/crates/accshift-egui/parity/scenes/typography-display-150.json new file mode 100644 index 0000000..039ddb8 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/typography-display-150.json @@ -0,0 +1,22 @@ +{ + "id": "typography-display-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/typography-display.html", + "probe": true, + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/src/screens/main_spike.rs b/crates/accshift-egui/src/screens/main_spike.rs index 0850baf..21f59e9 100644 --- a/crates/accshift-egui/src/screens/main_spike.rs +++ b/crates/accshift-egui/src/screens/main_spike.rs @@ -7,13 +7,11 @@ use std::collections::HashMap; use std::sync::mpsc::Receiver; -use std::sync::Arc; use egui::epaint::{RectShape, TextShape}; use egui::{ - pos2, vec2, Align2, Color32, ColorImage, CornerRadius, FontData, FontDefinitions, FontFamily, - FontId, Id, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, TextureHandle, TextureOptions, Ui, - Vec2, + pos2, vec2, Align2, Color32, ColorImage, CornerRadius, FontFamily, FontId, Id, Pos2, Rect, + Sense, Shape, Stroke, StrokeKind, TextureHandle, TextureOptions, Ui, Vec2, }; use crate::avatars::{self, Decoded}; @@ -1015,41 +1013,14 @@ impl AccshiftApp { // --------------------------------------------------------------- helpers fn semibold() -> FontFamily { - FontFamily::Name("semibold".into()) + FontFamily::Name(crate::ui::text::SEMIBOLD.into()) } -/// Segoe UI from the system, like the webview's `system-ui` fallback (Inter -/// is not installed here, so the Tauri build renders Segoe UI too). -/// The scene runner calls it before the first frame: a screen built -/// mid-frame only gets its fonts one frame later. +/// The faces WebView2 draws the UI with ([`crate::ui::text`]). The scene +/// runner calls it before the first frame: fonts set mid-frame only apply one +/// frame later. pub fn install_fonts(ctx: &egui::Context) { - let mut fonts = FontDefinitions::default(); - let dir = std::env::var("WINDIR").unwrap_or_else(|_| r"C:\Windows".into()); - let read = |name: &str| std::fs::read(format!(r"{dir}\Fonts\{name}")).ok(); - if let Some(bytes) = read("segoeui.ttf") { - fonts - .font_data - .insert("segoe".into(), Arc::new(FontData::from_owned(bytes))); - // On Windows egui ships no fonts of its own (see Cargo.toml): Segoe - // UI is then the whole proportional family and the monospace one. - for family in [FontFamily::Proportional, FontFamily::Monospace] { - let list = fonts.families.entry(family.clone()).or_default(); - if family == FontFamily::Proportional || list.is_empty() { - list.insert(0, "segoe".into()); - } - } - } - let mut semibold_family = Vec::new(); - if let Some(bytes) = read("seguisb.ttf") { - fonts.font_data.insert( - "segoe-semibold".into(), - Arc::new(FontData::from_owned(bytes)), - ); - semibold_family.push("segoe-semibold".to_string()); - } - semibold_family.extend(fonts.families[&FontFamily::Proportional].iter().cloned()); - fonts.families.insert(semibold(), semibold_family); - ctx.set_fonts(fonts); + crate::ui::text::install(ctx, false); } fn button(ui: &Ui, id: Id, rect: Rect, label: &str) -> egui::Response { diff --git a/crates/accshift-egui/src/screens/mod.rs b/crates/accshift-egui/src/screens/mod.rs index d4d4ade..506209c 100644 --- a/crates/accshift-egui/src/screens/mod.rs +++ b/crates/accshift-egui/src/screens/mod.rs @@ -48,7 +48,7 @@ impl Screen { match self { Screen::Blank => false, Screen::Main(app) => app.avatars_ready(), - Screen::Primitives(_) => true, + Screen::Primitives(p) => p.ready(), } } diff --git a/crates/accshift-egui/src/screens/primitives.rs b/crates/accshift-egui/src/screens/primitives.rs index e8577c9..2992be4 100644 --- a/crates/accshift-egui/src/screens/primitives.rs +++ b/crates/accshift-egui/src/screens/primitives.rs @@ -6,15 +6,21 @@ //! (they only hold what both sides share: absolute positioning, border-box //! sizing, the page background override), and every `
` is //! one box, positioned by `left`, `top`, `width` and `height` in px, painted -//! in document order with [`BoxStyle::from_css`]. +//! in document order with [`BoxStyle::from_css`]. Every `

text

` +//! is one line of text at `left` and `top`, laid out by [`crate::ui::text`] +//! with [`TextStyle::from_css`] (the typography fragments). use egui::{Rect, Ui}; use crate::ui::css; use crate::ui::paint::BoxStyle; +use crate::ui::text::{self, TextStyle}; pub struct Primitives { boxes: Vec<(Rect, BoxStyle)>, + texts: Vec<(Rect, String, TextStyle)>, + /// Every text line had its faces bound on the last frame. + ready: bool, } impl Primitives { @@ -39,7 +45,32 @@ impl Primitives { if boxes.is_empty() { return Err("no
box in the fragment".into()); } - Ok(Self { boxes }) + let mut texts = Vec::new(); + let mut rest = html; + while let Some(start) = rest.find("

").ok_or("unclosed

").ok_or("unclosed

")?; + // White space collapses (`nowrap` keeps that). + let content = rest[..close] + .split_whitespace() + .collect::>() + .join(" "); + rest = &rest[close..]; + let (pos, _) = + css::position(style).ok_or_else(|| format!("bad position in {style}"))?; + let text_style = + TextStyle::from_css(style).ok_or_else(|| format!("unsupported text in {style}"))?; + let rect = Rect::from_min_size(pos.to_pos2(), egui::vec2(f32::INFINITY, 0.0)); + texts.push((rect, content, text_style)); + } + Ok(Self { + boxes, + texts, + ready: false, + }) } pub fn ui(&mut self, ui: &mut Ui) { @@ -48,6 +79,15 @@ impl Primitives { for (rect, style) in &self.boxes { style.paint(painter, rect.translate(origin)); } + let mut ready = true; + for (rect, content, style) in &self.texts { + ready &= text::paint(painter, rect.translate(origin), content, style).is_some(); + } + self.ready = ready; + } + + pub fn ready(&self) -> bool { + self.ready } } @@ -68,4 +108,74 @@ mod tests { } assert!(count >= 4); } + + /// Every line of the typography fragments against WebView2: inline box + /// width and baseline, in device px. Run after `parity/ref.mjs` captured + /// the `typography-*` scenes (their `probe.json`): + /// `ACCSHIFT_TEXT_PROBE= cargo test --release -- --ignored --nocapture probe`. + #[test] + #[ignore = "needs the WebView2 probes of parity/ref.mjs and the system fonts"] + fn text_matches_webview2_probe() { + let dir = std::path::PathBuf::from( + std::env::var("ACCSHIFT_TEXT_PROBE").expect("ACCSHIFT_TEXT_PROBE="), + ); + let fragments = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("parity/fragments"); + let (mut worst_w, mut worst_b) = ((0.0f32, String::new()), (0.0f32, String::new())); + for fragment in ["typography", "typography-display"] { + let html = std::fs::read_to_string(fragments.join(format!("{fragment}.html"))).unwrap(); + let p = Primitives::parse(&html).unwrap(); + for pct in [100, 125, 150] { + let scale = pct as f32 / 100.0; + let probe: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(dir.join(format!("{fragment}-{pct}/probe.json"))) + .unwrap(), + ) + .unwrap(); + let lines = probe.as_array().unwrap(); + assert_eq!(lines.len(), p.texts.len(), "{fragment}: probe lines"); + let ctx = egui::Context::default(); + ctx.set_zoom_factor(scale); + text::install(&ctx, false); + let mut laid = Vec::new(); + for _ in 0..4 { + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { + let ctx = ui.ctx(); + laid = p + .texts + .iter() + .map(|(rect, content, style)| text::layout(ctx, content, style, *rect)) + .collect(); + }); + } + let (mut max_w, mut max_b) = (0.0f32, 0.0f32); + for ((line, laid), (rect, content, _)) in lines.iter().zip(&laid).zip(&p.texts) { + let laid = laid + .as_ref() + .unwrap_or_else(|| panic!("{content}: not laid out")); + let web_w = line["box"][2].as_f64().unwrap() as f32; + let web_b = line["baseline"].as_f64().unwrap() as f32; + let dw = (laid.width - web_w).abs() * scale; + // Device baseline: where the glyphs were snapped to. + let ours = ((rect.top() + laid.line_box.baseline) * scale + 0.5).floor(); + let web = (web_b * scale + 0.5).floor(); + let db = (ours - web).abs(); + if dw > worst_w.0 { + worst_w = (dw, format!("{fragment}-{pct} {content}")); + } + if db > worst_b.0 { + worst_b = (db, format!("{fragment}-{pct} {content}")); + } + max_w = max_w.max(dw); + max_b = max_b.max(db); + } + println!( + "{fragment}-{pct}: {} lines, width error max {max_w:.3} px, baseline error max {max_b:.0} px", + lines.len() + ); + } + } + println!("worst width: {:.3} px, {}", worst_w.0, worst_w.1); + println!("worst baseline: {:.0} px, {}", worst_b.0, worst_b.1); + assert!(worst_w.0 <= 1.0 && worst_b.0 <= 1.0); + } } diff --git a/crates/accshift-egui/src/soft.rs b/crates/accshift-egui/src/soft.rs index a4c5aa3..ead64d6 100644 --- a/crates/accshift-egui/src/soft.rs +++ b/crates/accshift-egui/src/soft.rs @@ -365,7 +365,8 @@ impl ApplicationHandler for Runner { state, info, shell, - render: EguiSoftwareRender::new(ColorFieldOrder::Bgra), + render: EguiSoftwareRender::new(ColorFieldOrder::Bgra) + .with_glyph_coverage(crate::ui::text::coverage_tables()), textures: Default::default(), actions: Vec::new(), full_present: true, diff --git a/crates/accshift-egui/src/ui/mod.rs b/crates/accshift-egui/src/ui/mod.rs index 9c499af..f62b422 100644 --- a/crates/accshift-egui/src/ui/mod.rs +++ b/crates/accshift-egui/src/ui/mod.rs @@ -5,3 +5,4 @@ pub mod anim; pub mod css; pub mod paint; +pub mod text; diff --git a/crates/accshift-egui/src/ui/text.rs b/crates/accshift-egui/src/ui/text.rs new file mode 100644 index 0000000..bc5919e --- /dev/null +++ b/crates/accshift-egui/src/ui/text.rs @@ -0,0 +1,896 @@ +//! Text the way WebView2 draws it. +//! +//! This module owns the fonts and the CSS text semantics of the port: +//! +//! - which face draws a CSS family stack at a weight ([`faces`]): the files +//! WebView2 picks, one egui [`FontFamily`] per face list, loaded on first +//! use; +//! - [`TextStyle`], a CSS text description (family stack, size, weight, +//! `line-height`, `letter-spacing`, `text-transform`, `white-space: nowrap`, +//! `text-overflow: ellipsis`, `text-align`, colour); +//! - the line box: Chromium computes it in CSS px with the face's ascent and +//! descent rounded separately and the half-leading floored, then scales it +//! by the device pixel ratio ([`LineBox`]); every glyph is moved onto that +//! baseline, whatever face drew it; +//! - the coverage curve of Skia's grayscale glyphs ([`gamma`]), applied by the +//! software backend. +//! +//! Measured against WebView2 with `parity/fragments/typography*.html` and the +//! `typography-*` scenes (the probe in `parity/ref.mjs` records the face, line +//! box and baseline of every line). + +pub mod faces; +pub mod gamma; + +use std::collections::{BTreeMap, HashMap}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, OnceLock}; + +use egui::epaint::text::{FontTweak, HintingTarget, SmoothHinting, TextOptions, VariationCoords}; +use egui::text::{LayoutJob, TextFormat, TextWrapping}; +use egui::{ + pos2, vec2, Align, Color32, Context, FontData, FontDefinitions, FontFamily, FontId, Galley, Id, + Painter, Pos2, Rect, +}; + +use crate::ui::css; +use faces::{Catalog, Choice, FaceMetrics, Source}; + +/// `--font-ui` of `src/app.css` with the built-in themes' `Inter` in front. +pub const UI_STACK: &str = "Inter, system-ui, Avenir, Helvetica, Arial, \"Microsoft YaHei UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Noto Sans CJK SC\", \"Noto Sans SC\", \"Noto Sans SC Subset\", sans-serif"; +/// The monospace stack of the settings and the theme editor. +pub const MONO_STACK: &str = "ui-monospace, \"Cascadia Mono\", Menlo, Consolas, monospace"; +/// `WaveText`'s pixel font. +pub const PIXEL_STACK: &str = "\"VT323\", \"Courier New\", \"Microsoft YaHei UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Noto Sans CJK SC\", \"Noto Sans SC Subset\", sans-serif"; + +/// The size the `Proportional` and [`SEMIBOLD`] aliases are drawn at, for +/// their gasp behaviour: the UI's 13 px. +const ALIAS_PPEM: f32 = 13.0; + +/// egui family of the UI stack at 600, for code that names it directly. +pub const SEMIBOLD: &str = "semibold"; + +/// CSS `line-height`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum LineHeight { + /// The face's ascent, descent and line gap, each rounded. + Normal, + /// A multiple of the font size. + Number(f32), + Px(f32), +} + +/// CSS `text-transform`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum TextTransform { + #[default] + None, + Uppercase, + Lowercase, + Capitalize, +} + +/// CSS `text-align`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum TextAlign { + #[default] + Left, + Center, + Right, +} + +/// The computed text style of one element. +#[derive(Clone, Debug, PartialEq)] +pub struct TextStyle { + /// CSS `font-family`, as written. + pub family: Arc, + /// `font-size` in CSS px. + pub size: f32, + pub weight: u16, + pub line_height: LineHeight, + /// `letter-spacing` in CSS px, added after every character. + pub letter_spacing: f32, + pub transform: TextTransform, + /// `white-space: nowrap`. + pub nowrap: bool, + /// `text-overflow: ellipsis` (with `overflow: hidden`): only with `nowrap`. + pub ellipsis: bool, + pub align: TextAlign, + pub color: Color32, +} + +impl Default for TextStyle { + /// What `:root` gives every element: the UI stack, 400, `line-height` + /// 1.5 (`--line-height`), no letter spacing, at the browser's 16 px. + fn default() -> Self { + Self { + family: Arc::from(UI_STACK), + size: 16.0, + weight: 400, + line_height: LineHeight::Number(1.5), + letter_spacing: 0.0, + transform: TextTransform::None, + nowrap: false, + ellipsis: false, + align: TextAlign::Left, + color: Color32::WHITE, + } + } +} + +impl TextStyle { + /// `size` px at `weight` in the UI stack, everything else from `:root`. + pub fn new(size: f32, weight: u16) -> Self { + Self { + size, + weight, + ..Self::default() + } + } + + pub fn family(mut self, stack: &str) -> Self { + self.family = Arc::from(stack); + self + } + + pub fn line_height(mut self, line_height: LineHeight) -> Self { + self.line_height = line_height; + self + } + + /// `letter-spacing` in em. + pub fn letter_spacing_em(mut self, em: f32) -> Self { + self.letter_spacing = em * self.size; + self + } + + pub fn letter_spacing_px(mut self, px: f32) -> Self { + self.letter_spacing = px; + self + } + + pub fn transform(mut self, transform: TextTransform) -> Self { + self.transform = transform; + self + } + + /// `white-space: nowrap`, with `text-overflow: ellipsis` when `ellipsis`. + pub fn nowrap(mut self, ellipsis: bool) -> Self { + self.nowrap = true; + self.ellipsis = ellipsis; + self + } + + pub fn align(mut self, align: TextAlign) -> Self { + self.align = align; + self + } + + pub fn color(mut self, color: Color32) -> Self { + self.color = color; + self + } + + /// The style an element with these declarations computes, over the + /// `:root` defaults. Unknown properties are ignored, invalid values give + /// `None`. + pub fn from_css(decls: &str) -> Option { + let mut style = Self::default(); + let mut spacing = None; + for decl in css::split_top(decls, ';') { + let Some((prop, value)) = decl.split_once(':') else { + continue; + }; + let value = value.trim().trim_end_matches("!important").trim(); + match prop.trim() { + "font-family" => style.family = Arc::from(value), + "font-size" => style.size = css::px(value)?, + "font-weight" => { + style.weight = match value { + "normal" => 400, + "bold" => 700, + v => v.parse().ok()?, + } + } + "line-height" => { + style.line_height = if value == "normal" { + LineHeight::Normal + } else if let Ok(n) = value.parse::() { + LineHeight::Number(n) + } else if let Some(p) = value.strip_suffix('%') { + LineHeight::Number(p.trim().parse::().ok()? / 100.0) + } else { + LineHeight::Px(css::px(value)?) + } + } + "letter-spacing" => spacing = Some(value.to_string()), + "text-transform" => { + style.transform = match value { + "uppercase" => TextTransform::Uppercase, + "lowercase" => TextTransform::Lowercase, + "capitalize" => TextTransform::Capitalize, + _ => TextTransform::None, + } + } + "white-space" => style.nowrap = value == "nowrap" || value == "pre", + "text-overflow" => style.ellipsis = value == "ellipsis", + "text-align" => { + style.align = match value { + "center" => TextAlign::Center, + "right" | "end" => TextAlign::Right, + _ => TextAlign::Left, + } + } + "color" => style.color = css::color(value)?.to_color32(), + _ => {} + } + } + // `em` resolves against the element's own font size. + if let Some(v) = spacing { + style.letter_spacing = match v.as_str() { + "normal" | "0" => 0.0, + v => match v.strip_suffix("em") { + Some(em) => em.trim().parse::().ok()? * style.size, + None => css::px(v)?, + }, + }; + } + Some(style) + } + + /// The line box of this style, once its face is known. + pub fn line_box(&self, ctx: &Context) -> Option { + let ppem = self.size * ctx.pixels_per_point(); + let resolved = resolve(ctx, &self.family, self.weight, ppem)?; + Some(LineBox::new(&resolved.metrics, self.size, self.line_height)) + } + + fn transformed(&self, text: &str) -> String { + match self.transform { + TextTransform::None => text.to_string(), + TextTransform::Uppercase => text.to_uppercase(), + TextTransform::Lowercase => text.to_lowercase(), + TextTransform::Capitalize => { + let mut out = String::with_capacity(text.len()); + let mut start = true; + for c in text.chars() { + if start && c.is_alphanumeric() { + out.extend(c.to_uppercase()); + start = false; + } else { + if c.is_whitespace() { + start = true; + } + out.push(c); + } + } + out + } + } + } +} + +/// One line box, in CSS px. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LineBox { + pub height: f32, + /// From the top of the line box. + pub baseline: f32, +} + +impl LineBox { + /// Chromium's line box: ascent and descent rounded separately at the CSS + /// font size, the line height kept to LayoutUnit precision (1/64 px), the + /// half-leading floored to whole px. Matches the 252 baselines of the + /// typography fragments at scales 1, 1.25 and 1.5. + pub fn new(m: &FaceMetrics, size: f32, line_height: LineHeight) -> Self { + let scale = size / m.units_per_em; + let ascent = (m.ascent * scale).round(); + let descent = (m.descent * scale).round(); + let height = match line_height { + LineHeight::Normal => ascent + descent + (m.line_gap * scale).round(), + LineHeight::Number(n) => n * size, + LineHeight::Px(px) => px, + }; + let height = (height * 64.0).floor() / 64.0; + let half_leading = ((height - (ascent + descent)) / 2.0).floor(); + Self { + height, + baseline: ascent + half_leading, + } + } +} + +/// Laid out text, ready to paint. +#[derive(Clone, Debug)] +pub struct TextLayout { + pub galley: Arc, + /// Where the galley goes, on the device pixel grid. + pub pos: Pos2, + /// Widest line in CSS px: advances plus `letter-spacing` after every + /// character, like the inline box. + pub width: f32, + /// All line boxes. + pub height: f32, + pub line_box: LineBox, + pub lines: usize, +} + +impl TextLayout { + pub fn paint(&self, painter: &Painter) { + painter.add(egui::epaint::TextShape::new( + self.pos, + self.galley.clone(), + Color32::PLACEHOLDER, + )); + } +} + +/// Lays `text` out in `rect`: its top is the top of the first line box, its +/// left and width the containing block (for wrapping, alignment and the +/// ellipsis). `None` while the style's faces are not loaded yet (the first +/// frame that uses a new face): a repaint is requested. +pub fn layout(ctx: &Context, text: &str, style: &TextStyle, rect: Rect) -> Option { + let resolved = resolve( + ctx, + &style.family, + style.weight, + style.size * ctx.pixels_per_point(), + )?; + let bound = ctx.fonts(|f| f.definitions().families.contains_key(&resolved.family)); + if !bound { + ctx.request_repaint(); + return None; + } + let text = style.transformed(text); + if text.chars().any(is_han) { + need_cjk(ctx); + } + let ppp = ctx.pixels_per_point(); + let line_box = LineBox::new(&resolved.metrics, style.size, style.line_height); + let avail = if rect.width().is_finite() && rect.width() > 0.0 { + rect.width() + } else { + f32::INFINITY + }; + let job = |lead: f32| { + let mut job = LayoutJob::default(); + job.append( + &text, + lead, + TextFormat { + font_id: FontId::new(style.size, resolved.family.clone()), + extra_letter_spacing: style.letter_spacing, + line_height: Some(line_box.height), + color: style.color, + valign: Align::TOP, + ..Default::default() + }, + ); + job.wrap = match (style.nowrap, style.ellipsis) { + (true, true) => TextWrapping::truncate_at_width(avail + lead), + (true, false) => TextWrapping::no_max_width(), + (false, _) => TextWrapping::wrap_at_width(avail + lead), + }; + job + }; + let offset = |w: f32| match style.align { + TextAlign::Left => 0.0, + TextAlign::Center if avail.is_finite() => ((avail - w) / 2.0 * 64.0).floor() / 64.0, + TextAlign::Right if avail.is_finite() => avail - w, + _ => 0.0, + }; + + let mut galley = ctx.fonts_mut(|f| f.layout_job(job(0.0))); + let widths: Vec = galley + .rows + .iter() + .map(|r| row_width(&r.row, style.letter_spacing)) + .collect(); + // One line: the fractional part of its device x goes into the layout + // (leading space), so glyphs land on the same quarter pixels as Skia's. + let mut lead = 0.0; + let origin_x = if galley.rows.len() == 1 { + let x = (rect.left() + offset(widths[0])) * ppp; + let xi = (x + 1e-3).floor(); + let frac = x - xi; + if frac > 1e-3 { + lead = frac; + galley = ctx.fonts_mut(|f| f.layout_job(job(frac / ppp))); + } + xi / ppp + } else { + (rect.left() * ppp).round() / ppp + }; + let origin_y = (rect.top() * ppp).floor() / ppp; + let lines = galley.rows.len(); + + let g = Arc::make_mut(&mut galley); + let mut bounds = Rect::NOTHING; + for (i, placed) in g.rows.iter_mut().enumerate() { + if lines > 1 { + let x = rect.left() + offset(widths[i]); + placed.pos.x = (x * ppp).round() / ppp - origin_x; + } + let baseline = (rect.top() + line_box.baseline + i as f32 * line_box.height) * ppp; + let target = snap(baseline) / ppp - origin_y - placed.pos.y; + let row = Arc::make_mut(&mut placed.row); + // The pen in device px: egui puts every CJK glyph on a whole pixel + // (no sub-pixel bins for them), Skia on a quarter pixel like the rest. + let mut pen = if i == 0 { lead } else { 0.0 }; + for (k, glyph) in row.glyphs.iter_mut().enumerate() { + if k > 0 { + pen += style.letter_spacing * ppp; + } + let exact = pen; + pen += glyph.advance_width * ppp; + let dx = if is_han(glyph.chr) { + ((exact * 4.0).round() / 4.0 - glyph.pos.x * ppp) / ppp + } else { + 0.0 + }; + let dy = target - glyph.pos.y; + glyph.pos.y = target; + if (dx == 0.0 && dy == 0.0) || glyph.uv_rect.is_nothing() { + continue; + } + let first = glyph.first_vertex as usize; + if let Some(quad) = row.visuals.mesh.vertices.get_mut(first..first + 4) { + for v in quad { + v.pos += vec2(dx, dy); + } + } + } + row.visuals.mesh_bounds = row.visuals.mesh.calc_bounds(); + bounds = bounds.union(row.visuals.mesh_bounds.translate(placed.pos.to_vec2())); + } + g.mesh_bounds = bounds; + let width = widths.iter().copied().fold(0.0, f32::max); + g.rect = Rect::from_min_size(Pos2::ZERO, vec2(width, lines as f32 * line_box.height)); + + Some(TextLayout { + galley, + pos: pos2(origin_x, origin_y), + width, + height: lines as f32 * line_box.height, + line_box, + lines, + }) +} + +/// [`layout`] then paint. +pub fn paint(painter: &Painter, rect: Rect, text: &str, style: &TextStyle) -> Option { + let laid = layout(painter.ctx(), text, style, rect)?; + laid.paint(painter); + Some(laid) +} + +/// Width of `text` in CSS px, as an inline box of this style. +pub fn measure(ctx: &Context, text: &str, style: &TextStyle) -> Option { + let mut style = style.clone(); + style.nowrap = true; + style.ellipsis = false; + style.align = TextAlign::Left; + layout( + ctx, + text, + &style, + Rect::from_min_size(Pos2::ZERO, vec2(f32::INFINITY, 0.0)), + ) + .map(|l| l.width) +} + +/// Whether the faces of `style` are bound in the current frame. +pub fn ready(ctx: &Context, style: &TextStyle) -> bool { + resolve( + ctx, + &style.family, + style.weight, + style.size * ctx.pixels_per_point(), + ) + .is_some_and(|r| ctx.fonts(|f| f.definitions().families.contains_key(&r.family))) +} + +/// Advances of a row plus the letter spacing after each character. +fn row_width(row: &egui::epaint::text::Row, spacing: f32) -> f32 { + row.glyphs.iter().map(|g| g.advance_width + spacing).sum() +} + +/// The ranges of the Han `@font-face` rules of `src/app.css`. +fn is_han(c: char) -> bool { + matches!(c as u32, + 0x2E80..=0x2EFF | 0x3000..=0x303F | 0x31C0..=0x31EF | 0x3400..=0x4DBF + | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0xFE30..=0xFE4F | 0xFF00..=0xFFEF) +} + +// ---------------------------------------------------------------- fonts + +/// The faces loaded so far and the egui families made of them. +struct Book { + /// Han fallback faces join the families (set once CJK text shows up). + cjk: bool, + /// egui fonts by name: a face and its gasp behaviour. + faces: HashMap>, + /// Metrics by face. + metrics: HashMap, + /// egui family name to its face keys, in fallback order. + families: BTreeMap>, + /// By stack, weight and ppem. + resolved: HashMap<(Arc, u16, u16), Option>, + /// Faces of egui's `Proportional` family (its own widgets). + proportional: Vec, + /// Faces of the UI stack at 600 ([`SEMIBOLD`]). + semibold: Vec, + dirty: bool, +} + +#[derive(Clone)] +struct Resolved { + family: FontFamily, + metrics: FaceMetrics, +} + +type SharedBook = Arc>; + +fn book(ctx: &Context) -> SharedBook { + ctx.data_mut(|d| { + d.get_temp_mut_or_insert_with(Id::new("accshift.ui.text.book"), || { + Arc::new(Mutex::new(Book { + cjk: false, + faces: HashMap::new(), + metrics: HashMap::new(), + families: BTreeMap::new(), + resolved: HashMap::new(), + proportional: Vec::new(), + semibold: Vec::new(), + dirty: false, + })) + }) + .clone() + }) +} + +/// Face file bytes, read once per process and kept for its lifetime (egui +/// holds them as long as the fonts live). +fn bytes(source: &Source) -> Option<&'static [u8]> { + type Files = Mutex>>; + static FILES: OnceLock = OnceLock::new(); + match source { + Source::Bundled(_, bytes) => Some(bytes), + Source::File(path) => { + let mut files = FILES.get_or_init(Default::default).lock().ok()?; + *files.entry(path.clone()).or_insert_with(|| { + std::fs::read(path) + .ok() + .map(|b| &*Box::leak(b.into_boxed_slice())) + }) + } + } +} + +impl Book { + /// Loads a face as drawn at `ppem` device px: its egui font name and + /// metrics. A face gets one egui font per `gasp` behaviour (vertical + /// smoothing or not), so a family draws each size like DirectWrite. + fn load(&mut self, choice: &Choice, ppem: f32) -> Option<(String, FaceMetrics)> { + let base = choice.key(); + let data = bytes(&choice.entry.source)?; + let metrics = match self.metrics.get(&base) { + Some(m) => *m, + None => { + let m = FaceMetrics::read(data, choice.entry.index)?; + self.metrics.insert(base.clone(), m); + m + } + }; + let smooth = metrics.symmetric_smoothing(ppem); + let key = format!("{base}~{}", if smooth { "s" } else { "a" }); + if self.faces.contains_key(&key) { + return Some((key, metrics)); + } + let mut tweak = FontTweak { + hinting: Some(true), + subpixel_binning: Some(true), + hinting_target: HintingTarget::Smooth(SmoothHinting { + light: true, + symmetric_rendering: smooth, + preserve_linear_metrics: true, + }), + ..FontTweak::default() + }; + if let Some(w) = choice.wght { + tweak.coords = VariationCoords::new([(*b"wght", w)]); + } + let mut font = FontData::from_static(data); + font.index = choice.entry.index; + font.tweak = tweak; + self.faces.insert(key.clone(), Arc::new(font)); + self.dirty = true; + Some((key, metrics)) + } + + fn resolve(&mut self, stack: &Arc, weight: u16, ppem: f32) -> Option { + let ppem = ppem.round().clamp(1.0, 4096.0); + let cache_key = (stack.clone(), weight, ppem as u16); + if let Some(r) = self.resolved.get(&cache_key) { + return r.clone(); + } + let catalog = Catalog::system(); + let mut keys: Vec = Vec::new(); + let mut metrics = None; + for name in css::split_top(stack, ',') { + let Some(name) = faces::normalize(name) else { + continue; + }; + if !catalog.has(&name) { + continue; + } + let cjk = faces::CJK_FAMILIES.contains(&name.as_str()); + // The first family present is the primary font; after it only + // the Han fallback faces matter (every UI face covers Latin and + // Cyrillic). + if metrics.is_some() && !(cjk && self.cjk) { + continue; + } + let Some(choice) = catalog.pick(&name, weight, &mut |s| bytes(s)) else { + continue; + }; + let Some((key, m)) = self.load(&choice, ppem) else { + continue; + }; + metrics.get_or_insert(m); + if !keys.contains(&key) { + keys.push(key); + } + } + // Off Windows, egui's own fonts close every family. + #[cfg(not(windows))] + { + let defaults = FontDefinitions::default(); + for name in defaults + .families + .get(&FontFamily::Proportional) + .into_iter() + .flatten() + { + if metrics.is_none() { + metrics = defaults + .font_data + .get(name) + .and_then(|d| FaceMetrics::read(&d.font, d.index)); + } + keys.push(format!("egui:{name}")); + } + } + let resolved = metrics.map(|metrics| { + let name = keys.join("|"); + if !self.families.contains_key(&name) { + self.families.insert(name.clone(), keys); + self.dirty = true; + } + Resolved { + family: FontFamily::Name(name.into()), + metrics, + } + }); + self.resolved.insert(cache_key, resolved.clone()); + resolved + } + + /// Points `Proportional` and [`SEMIBOLD`] at the UI stack's faces. + fn alias(&mut self) { + let ui: Arc = Arc::from(UI_STACK); + for (weight, slot) in [(400, 0), (600, 1)] { + let keys = match self.resolve(&ui, weight, ALIAS_PPEM) { + Some(Resolved { + family: FontFamily::Name(name), + .. + }) => self.families.get(&*name).cloned().unwrap_or_default(), + _ => Vec::new(), + }; + if slot == 0 { + self.proportional = keys; + } else { + self.semibold = keys; + } + } + } + + fn definitions(&self) -> FontDefinitions { + let defaults = FontDefinitions::default(); + let mut defs = FontDefinitions::empty(); + for (key, data) in &self.faces { + defs.font_data.insert(key.clone(), data.clone()); + } + for (name, data) in &defaults.font_data { + defs.font_data.insert(format!("egui:{name}"), data.clone()); + } + let egui_name = |k: &String| k.clone(); + for (name, keys) in &self.families { + defs.families.insert( + FontFamily::Name(name.as_str().into()), + keys.iter().map(egui_name).collect(), + ); + } + let mut proportional = self.proportional.clone(); + if proportional.is_empty() { + proportional = defaults + .families + .get(&FontFamily::Proportional) + .into_iter() + .flatten() + .map(|n| format!("egui:{n}")) + .collect(); + } + let semibold = if self.semibold.is_empty() { + proportional.clone() + } else { + self.semibold.clone() + }; + defs.families + .insert(FontFamily::Name(SEMIBOLD.into()), semibold); + defs.families + .insert(FontFamily::Monospace, proportional.clone()); + defs.families.insert(FontFamily::Proportional, proportional); + defs + } +} + +/// Resolves a family stack at a weight, loading its faces; new faces are +/// handed to egui for the next frame. +fn resolve(ctx: &Context, stack: &Arc, weight: u16, ppem: f32) -> Option { + let book = book(ctx); + let mut b = book.lock().ok()?; + let r = b.resolve(stack, weight, ppem); + if b.dirty { + b.dirty = false; + ctx.set_fonts(b.definitions()); + } + r +} + +/// Han text showed up: the CJK fallback faces join every family. +fn need_cjk(ctx: &Context) { + let book = book(ctx); + let Ok(mut b) = book.lock() else { + return; + }; + if b.cjk { + return; + } + b.cjk = true; + b.resolved.clear(); + b.alias(); + b.dirty = false; + ctx.set_fonts(b.definitions()); + ctx.request_repaint(); +} + +/// Installs the faces of the UI stack at 400, 600 and 700 (what the first +/// frame draws) and the text options. Call before the first frame: fonts set +/// during a frame only apply from the next one. `cjk` loads the Han fallback +/// faces up front (Chinese locale); otherwise they load on the first Han +/// character. +pub fn install(ctx: &Context, cjk: bool) { + let started = std::time::Instant::now(); + let book = book(ctx); + if let Ok(mut b) = book.lock() { + b.cjk = cjk; + b.resolved.clear(); + let ui: Arc = Arc::from(UI_STACK); + // Every gasp behaviour of the UI sizes, so no size met later adds a + // family (new fonts rebuild the whole glyph atlas). + for weight in [400, 600, 700] { + for ppem in 6..=40 { + b.resolve(&ui, weight, ppem as f32); + } + } + b.alias(); + b.dirty = false; + ctx.set_fonts(b.definitions()); + } + let options = text_options(); + ctx.all_styles_mut(|s| s.visuals.text_options = options); + crate::bench::mark_with( + "fonts", + &format!("\"ms\":{:.3}", started.elapsed().as_secs_f64() * 1000.0), + ); +} + +/// The egui family of the UI stack at `weight`, for code that lays text out +/// with egui directly (the spike screen). [`install`] binds 400, 600 and 700. +pub fn ui_family(ctx: &Context, weight: u16) -> FontFamily { + resolve(ctx, &Arc::from(UI_STACK), weight, ALIAS_PPEM) + .map_or(FontFamily::Proportional, |r| r.family) +} + +/// Text options as measured against WebView2 (typography scenes, PSNR): +/// +/// - hinting on: skrifa runs the faces' TrueType instructions like +/// DirectWrite does (off: -3.5 dB, the monochrome target: -4.5 dB), light, +/// with the vertical smoothing of each face's `gasp` table at the drawn +/// size (+0.6 dB over one fixed choice); +/// - sub-pixel binning on: Skia positions glyphs on quarter pixels too (off: +/// -2.3 dB); +/// - with the software renderer the atlas keeps raw coverage and the backend +/// applies [`coverage_tables`]; glow gets the curve of white text. +pub fn text_options() -> TextOptions { + TextOptions { + font_hinting: true, + subpixel_binning: true, + color_transfer_function: if cfg!(feature = "software") { + egui::epaint::FontColorTransferFunction::Off + } else { + egui::epaint::FontColorTransferFunction::Gamma(1.0 / gamma::CHROMIUM.gamma) + }, + ..TextOptions::default() + } +} + +/// The per-luminance-class coverage tables for the software backend +/// (`EguiSoftwareRender::with_glyph_coverage`). +pub fn coverage_tables() -> [[u8; 256]; gamma::CLASSES] { + gamma::CHROMIUM.tables() +} + +/// Skia rounds glyph origins with `floor(v + 0.5)`: a baseline at x.5 device +/// px goes down the page. +fn snap(v: f32) -> f32 { + (v + 0.5).floor() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn segoe() -> FaceMetrics { + FaceMetrics { + units_per_em: 2048.0, + ascent: 2210.0, + descent: 514.0, + line_gap: 0.0, + gasp: Default::default(), + gasp_len: 0, + } + } + + #[test] + fn line_boxes_match_the_probe() { + // From typography-100/probe.json: (size, line-height, baseline). + for (size, lh, baseline) in [ + (8.0, LineHeight::Number(1.0), 7.0), + (9.0, LineHeight::Number(1.5), 10.0), + (9.0, LineHeight::Number(1.2), 9.0), + (9.0, LineHeight::Number(1.0), 8.0), + (10.0, LineHeight::Px(13.0), 10.0), + (11.0, LineHeight::Number(1.45), 12.0), + (12.0, LineHeight::Number(1.5), 14.0), + (12.0, LineHeight::Number(1.55), 14.0), + (12.5, LineHeight::Number(1.6), 15.0), + (14.0, LineHeight::Number(1.0), 12.0), + (16.0, LineHeight::Number(1.5), 18.0), + ] { + let lb = LineBox::new(&segoe(), size, lh); + assert_eq!(lb.baseline, baseline, "{size}px {lh:?}"); + } + assert_eq!( + LineBox::new(&segoe(), 12.0, LineHeight::Normal).height, + 16.0 + ); + } + + #[test] + fn css_styles() { + let s = TextStyle::from_css( + "left: 3px; font-size: 9px; font-weight: 700; line-height: 1.2; letter-spacing: 0.08em; text-transform: uppercase; color: #7c7c88", + ) + .unwrap(); + assert_eq!(s.size, 9.0); + assert_eq!(s.weight, 700); + assert_eq!(s.line_height, LineHeight::Number(1.2)); + assert!((s.letter_spacing - 0.72).abs() < 1e-6); + assert_eq!(s.transform, TextTransform::Uppercase); + assert_eq!(s.color, Color32::from_rgb(0x7c, 0x7c, 0x88)); + assert_eq!(s.transformed("Startup"), "STARTUP"); + } +} diff --git a/crates/accshift-egui/src/ui/text/faces.rs b/crates/accshift-egui/src/ui/text/faces.rs new file mode 100644 index 0000000..c9f4ff2 --- /dev/null +++ b/crates/accshift-egui/src/ui/text/faces.rs @@ -0,0 +1,627 @@ +//! Which face file draws a CSS family at a weight, the way WebView2 picks it. +//! +//! Measured with `CSS.getPlatformFontsForNode` on the typography fragments +//! (`parity/fragments/typography*.html`), Windows 11, theme Glass Dark: +//! +//! - the stack is `--font-ui`: `Inter, system-ui, ...`. Inter is not installed +//! on a stock Windows, `system-ui` is Segoe UI; +//! - the face of a weight is the installed weight nearest to it, a tie going to +//! the heavier one (DirectWrite's matching, not the CSS rule): Segoe UI 500 +//! and 550 draw with Semibold, 650 and 750 with Bold, 800 and 850 with Black, +//! 100 and 200 with Light; Microsoft YaHei UI 550 and 600 draw with Bold; +//! - no synthetic bold (`font-synthesis: none`): VT323 at 700 is the regular +//! face; +//! - Han characters fall through to Microsoft YaHei UI, per character. +//! +//! On Windows the catalog is the system font registry (`HKLM` and `HKCU` +//! `...\Windows NT\CurrentVersion\Fonts`), so a family the theme names (or +//! Inter, when the user installed it) resolves like in the webview. The +//! bundled faces of `public/fonts` (VT323, and the Noto Sans SC subset off +//! Windows) are compiled in. Other systems try a few well-known files, then +//! fall back to egui's own fonts. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::OnceLock; + +use skrifa::raw::TableProvider as _; +use skrifa::MetadataProvider as _; + +/// A face file, or a bundled one. +#[derive(Clone, Debug, PartialEq)] +pub enum Source { + File(PathBuf), + Bundled(&'static str, &'static [u8]), +} + +/// One installed face of a family. +#[derive(Clone, Debug, PartialEq)] +pub struct Entry { + pub source: Source, + /// Face index in a collection (`.ttc`). + pub index: u32, + /// CSS weight of the face (from its name). + pub weight: u16, +} + +/// The face picked for a family at a weight; `wght` is the variation +/// coordinate when the file is a variable font. +#[derive(Clone, Debug, PartialEq)] +pub struct Choice { + pub entry: Entry, + pub wght: Option, +} + +impl Choice { + /// Unique name of the face, used as the egui font name. + pub fn key(&self) -> String { + let file = match &self.entry.source { + Source::File(p) => p.file_name().map_or_else( + || p.display().to_string(), + |n| n.to_string_lossy().into_owned(), + ), + Source::Bundled(name, _) => (*name).to_string(), + }; + match self.wght { + Some(w) => format!("{file}#{}@{w}", self.entry.index), + None => format!("{file}#{}", self.entry.index), + } + } +} + +/// Vertical metrics as DirectWrite reports them, in font units. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FaceMetrics { + pub units_per_em: f32, + pub ascent: f32, + pub descent: f32, + pub line_gap: f32, + /// `gasp` ranges: highest ppem of the range, and whether DirectWrite + /// smooths vertically there (`GASP_SYMMETRIC_SMOOTHING`). + pub(super) gasp: [(u16, bool); 8], + pub(super) gasp_len: u8, +} + +impl FaceMetrics { + /// Whether DirectWrite antialiases this face vertically at `ppem` device + /// px: Segoe UI only does at 8 ppem and below and from 20 ppem up, so the + /// UI sizes in between get crisp horizontal stems. No `gasp` table: yes. + pub fn symmetric_smoothing(&self, ppem: f32) -> bool { + let ppem = ppem.round().max(0.0) as u32; + self.gasp[..usize::from(self.gasp_len)] + .iter() + .find(|(max, _)| ppem <= u32::from(*max)) + .is_none_or(|(_, smooth)| *smooth) + } + + fn with_gasp(mut self, font: &skrifa::FontRef<'_>) -> Self { + use skrifa::raw::tables::gasp::GaspRangeBehavior; + if let Ok(gasp) = font.gasp() { + for range in gasp.gasp_ranges().iter().take(self.gasp.len()) { + self.gasp[usize::from(self.gasp_len)] = ( + range.range_max_ppem(), + range + .range_gasp_behavior() + .contains(GaspRangeBehavior::GASP_SYMMETRIC_SMOOTHING), + ); + self.gasp_len += 1; + } + } + self + } + + /// DirectWrite's metrics of a face: the `OS/2` win metrics, or the typo + /// metrics when `USE_TYPO_METRICS` is set; the line gap is what `hhea` + /// adds over the win metrics. + pub fn read(bytes: &[u8], index: u32) -> Option { + let font = skrifa::FontRef::from_index(bytes, index).ok()?; + let upm = f32::from(font.head().ok()?.units_per_em()); + let hhea = font.hhea().ok(); + let (h_asc, h_desc, h_gap) = hhea.as_ref().map_or((0.0, 0.0, 0.0), |h| { + ( + f32::from(h.ascender().to_i16()), + -f32::from(h.descender().to_i16()), + f32::from(h.line_gap().to_i16()), + ) + }); + let Ok(os2) = font.os2() else { + return Some( + Self { + units_per_em: upm, + ascent: h_asc, + descent: h_desc, + line_gap: h_gap, + gasp: Default::default(), + gasp_len: 0, + } + .with_gasp(&font), + ); + }; + let use_typo = os2 + .fs_selection() + .contains(skrifa::raw::tables::os2::SelectionFlags::USE_TYPO_METRICS); + Some( + if use_typo { + Self { + units_per_em: upm, + ascent: f32::from(os2.s_typo_ascender()), + descent: -f32::from(os2.s_typo_descender()), + line_gap: f32::from(os2.s_typo_line_gap()), + gasp: Default::default(), + gasp_len: 0, + } + } else { + let ascent = f32::from(os2.us_win_ascent()); + let descent = f32::from(os2.us_win_descent()); + Self { + units_per_em: upm, + ascent, + descent, + line_gap: (h_asc + h_desc + h_gap - ascent - descent).max(0.0), + gasp: Default::default(), + gasp_len: 0, + } + } + .with_gasp(&font), + ) + } +} + +/// Nearest weight, a tie going to the heavier one (DirectWrite). +pub fn nearest_weight(available: impl IntoIterator, want: u16) -> Option { + available.into_iter().min_by_key(|&w| { + let d = (i32::from(w) - i32::from(want)).unsigned_abs(); + // Heavier wins a tie: rank it first. + (d, u8::from(w < want)) + }) +} + +/// Families that only matter as per-character fallback for Han. They join a +/// family's face list once CJK text has been seen (their files are large). +pub const CJK_FAMILIES: &[&str] = &[ + "microsoft yahei ui", + "microsoft yahei", + "pingfang sc", + "hiragino sans gb", + "noto sans cjk sc", + "noto sans sc", + "noto sans sc subset", +]; + +/// A CSS family name as the catalog knows it: unquoted, lower case, generic +/// families replaced by what the platform maps them to. +pub fn normalize(name: &str) -> Option { + let name = name + .trim() + .trim_matches(|c| c == '"' || c == '\'') + .trim() + .to_lowercase(); + let generic = match name.as_str() { + "system-ui" | "ui-sans-serif" if cfg!(windows) => "segoe ui", + "sans-serif" if cfg!(windows) => "arial", + "serif" | "ui-serif" if cfg!(windows) => "times new roman", + "monospace" if cfg!(windows) => "consolas", + // Chromium knows the ui-* generics on macOS only. + "ui-monospace" | "ui-rounded" if cfg!(windows) => return None, + "-apple-system" | "blinkmacsystemfont" if cfg!(windows) => return None, + _ => return (!name.is_empty()).then_some(name), + }; + Some(generic.to_string()) +} + +/// Every installed family, by normalized name. +#[derive(Default)] +pub struct Catalog { + families: HashMap>, +} + +impl Catalog { + /// The system catalog, built once per process. + pub fn system() -> &'static Catalog { + static CATALOG: OnceLock = OnceLock::new(); + CATALOG.get_or_init(|| { + let mut c = Catalog::default(); + c.add_bundled(); + c.add_system(); + c + }) + } + + fn add(&mut self, family: &str, entry: Entry) { + let list = self.families.entry(family.to_lowercase()).or_default(); + if !list.contains(&entry) { + list.push(entry); + } + } + + fn add_bundled(&mut self) { + self.add( + "vt323", + Entry { + source: Source::Bundled("VT323-Regular.ttf", VT323), + index: 0, + weight: 400, + }, + ); + #[cfg(not(windows))] + for (name, bytes, weight) in [ + ("NotoSansSC-Subset-Regular.ttf", NOTO_SC_REGULAR, 400), + ("NotoSansSC-Subset-Bold.ttf", NOTO_SC_BOLD, 700), + ] { + self.add( + "noto sans sc subset", + Entry { + source: Source::Bundled(name, bytes), + index: 0, + weight, + }, + ); + } + } + + #[cfg(windows)] + fn add_system(&mut self) { + let windir = + std::env::var_os("WINDIR").map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from); + let fonts_dir = windir.join("Fonts"); + let mut found = false; + for (name, file) in registry::font_values() { + let path = PathBuf::from(&file); + let path = if path.is_absolute() { + path + } else { + fonts_dir.join(path) + }; + for (index, (family, weight)) in parse_registry_name(&name).into_iter().enumerate() { + if let Some((family, weight)) = family.zip(weight) { + found = true; + self.add( + &family, + Entry { + source: Source::File(path.clone()), + index: index as u32, + weight, + }, + ); + } + } + } + if !found { + // No registry: the faces the default stack needs. + for (file, weight) in [ + ("segoeuil.ttf", 300), + ("segoeuisl.ttf", 350), + ("segoeui.ttf", 400), + ("seguisb.ttf", 600), + ("segoeuib.ttf", 700), + ("seguibl.ttf", 900), + ] { + self.add( + "segoe ui", + Entry { + source: Source::File(fonts_dir.join(file)), + index: 0, + weight, + }, + ); + } + } + } + + #[cfg(not(windows))] + fn add_system(&mut self) { + // No fontconfig or CoreText query: a few well-known files. + #[cfg(target_os = "macos")] + let known: &[(&str, &str, u32, u16)] = &[ + ("system-ui", "/System/Library/Fonts/SFNS.ttf", 0, 400), + ("helvetica", "/System/Library/Fonts/Helvetica.ttc", 0, 400), + ("helvetica", "/System/Library/Fonts/Helvetica.ttc", 1, 700), + ("pingfang sc", "/System/Library/Fonts/PingFang.ttc", 0, 400), + ("menlo", "/System/Library/Fonts/Menlo.ttc", 0, 400), + ]; + #[cfg(not(target_os = "macos"))] + let known: &[(&str, &str, u32, u16)] = &[ + ( + "system-ui", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + 0, + 400, + ), + ( + "system-ui", + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + 0, + 700, + ), + ("system-ui", "/usr/share/fonts/TTF/DejaVuSans.ttf", 0, 400), + ( + "system-ui", + "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", + 0, + 700, + ), + ( + "noto sans cjk sc", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + 2, + 400, + ), + ( + "noto sans cjk sc", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc", + 2, + 700, + ), + ]; + for &(family, path, index, weight) in known { + let path = PathBuf::from(path); + if path.exists() { + self.add( + family, + Entry { + source: Source::File(path), + index, + weight, + }, + ); + } + } + } + + pub fn has(&self, family: &str) -> bool { + self.families.contains_key(family) + } + + /// The face that draws `family` (normalized) at `weight`. + pub fn pick( + &self, + family: &str, + weight: u16, + load: &mut dyn FnMut(&Source) -> Option<&'static [u8]>, + ) -> Option { + let entries = self.families.get(family)?; + // A family of one file may be a variable font: its named instances + // are its weights, as DirectWrite lists them. + if let [entry] = entries.as_slice() { + if let Some(bytes) = load(&entry.source) { + let weights = instance_weights(bytes, entry.index); + if let Some(w) = nearest_weight(weights.iter().map(|w| *w as u16), weight) { + return Some(Choice { + entry: entry.clone(), + wght: Some(f32::from(w)), + }); + } + } + } + let w = nearest_weight(entries.iter().map(|e| e.weight), weight)?; + let entry = entries.iter().find(|e| e.weight == w)?.clone(); + Some(Choice { entry, wght: None }) + } +} + +/// `wght` values of the named instances of a variable font, empty for a +/// static one. +fn instance_weights(bytes: &[u8], index: u32) -> Vec { + let Ok(font) = skrifa::FontRef::from_index(bytes, index) else { + return Vec::new(); + }; + let axes = font.axes(); + let Some(wght) = axes + .iter() + .position(|a| a.tag() == skrifa::Tag::new(b"wght")) + else { + return Vec::new(); + }; + let mut out: Vec = font + .named_instances() + .iter() + .filter_map(|i| i.user_coords().nth(wght)) + .collect(); + out.sort_by(f32::total_cmp); + out.dedup(); + out +} + +/// `"Segoe UI Semibold (TrueType)"` gives `("segoe ui", 600)`; a collection, +/// `"Microsoft YaHei Bold & Microsoft YaHei UI Bold (TrueType)"`, one pair per +/// face index. Italic faces give `None`. +pub fn parse_registry_name(name: &str) -> Vec<(Option, Option)> { + let name = name + .trim() + .trim_end_matches("(TrueType)") + .trim_end_matches("(OpenType)") + .trim(); + name.split(" & ") + .map(|part| { + let mut words: Vec<&str> = part.split_whitespace().collect(); + let mut weight = 400u16; + let mut italic = false; + while let Some(last) = words.last() { + let w = match last.to_lowercase().as_str() { + "thin" | "hairline" => Some(100), + "extralight" | "ultralight" => Some(200), + "light" => Some(300), + "semilight" => Some(350), + "regular" | "normal" | "book" => Some(400), + "medium" => Some(500), + "semibold" | "demibold" => Some(600), + "bold" => Some(700), + "extrabold" | "ultrabold" => Some(800), + "black" | "heavy" => Some(900), + "italic" | "oblique" => { + italic = true; + None + } + _ => break, + }; + if let Some(w) = w { + weight = w; + } + words.pop(); + } + if italic || words.is_empty() { + (None, None) + } else { + (Some(words.join(" ").to_lowercase()), Some(weight)) + } + }) + .collect() +} + +/// VT323 (SIL OFL), converted from `public/fonts/VT323-Regular.woff2`. +pub const VT323: &[u8] = include_bytes!("../../../assets/fonts/VT323-Regular.ttf"); +/// Noto Sans SC cut to the Chinese dictionary (SIL OFL), from `public/fonts`. +#[cfg(not(windows))] +pub const NOTO_SC_REGULAR: &[u8] = + include_bytes!("../../../assets/fonts/NotoSansSC-Subset-Regular.ttf"); +#[cfg(not(windows))] +pub const NOTO_SC_BOLD: &[u8] = include_bytes!("../../../assets/fonts/NotoSansSC-Subset-Bold.ttf"); + +#[cfg(windows)] +mod registry { + use windows_sys::Win32::Foundation::ERROR_SUCCESS; + use windows_sys::Win32::System::Registry::{ + RegCloseKey, RegEnumValueW, RegOpenKeyExW, HKEY, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, + KEY_READ, REG_EXPAND_SZ, REG_SZ, + }; + + const KEY: &str = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts"; + + /// `(value name, file)` of every registered font, machine then user. + pub fn font_values() -> Vec<(String, String)> { + let mut out = Vec::new(); + for root in [HKEY_LOCAL_MACHINE, HKEY_CURRENT_USER] { + read(root, &mut out); + } + out + } + + fn read(root: HKEY, out: &mut Vec<(String, String)>) { + let key: Vec = KEY.encode_utf16().chain(Some(0)).collect(); + let mut hkey: HKEY = std::ptr::null_mut(); + // SAFETY: valid NUL-terminated key path, out pointer to a local. + if unsafe { RegOpenKeyExW(root, key.as_ptr(), 0, KEY_READ, &mut hkey) } != ERROR_SUCCESS { + return; + } + let mut name = vec![0u16; 512]; + let mut data = vec![0u16; 1024]; + for index in 0.. { + let mut name_len = name.len() as u32; + let mut data_len = (data.len() * 2) as u32; + let mut kind = 0u32; + // SAFETY: buffers and their lengths (characters for the name, + // bytes for the data) match. + let status = unsafe { + RegEnumValueW( + hkey, + index, + name.as_mut_ptr(), + &mut name_len, + std::ptr::null(), + &mut kind, + data.as_mut_ptr().cast(), + &mut data_len, + ) + }; + if status != ERROR_SUCCESS { + // ERROR_NO_MORE_ITEMS, or a value too long for the buffers: + // the font folder never holds one. + if status == 234 { + continue; + } + break; + } + if kind != REG_SZ && kind != REG_EXPAND_SZ { + continue; + } + let value = &data[..(data_len as usize / 2)]; + let value = value.split(|&c| c == 0).next().unwrap_or_default(); + out.push(( + String::from_utf16_lossy(&name[..name_len as usize]), + String::from_utf16_lossy(value), + )); + } + // SAFETY: opened above. + unsafe { RegCloseKey(hkey) }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn weight_matching_is_directwrite() { + let segoe = [300, 350, 400, 600, 700, 900]; + let pick = |w| nearest_weight(segoe, w).unwrap(); + // The table measured in WebView2 (module docs). + for (want, got) in [ + (100, 300), + (200, 300), + (300, 300), + (350, 350), + (400, 400), + (450, 400), + (500, 600), + (550, 600), + (600, 600), + (650, 700), + (700, 700), + (750, 700), + (800, 900), + (850, 900), + (900, 900), + ] { + assert_eq!(pick(want), got, "Segoe UI {want}"); + } + let yahei = [300, 400, 700]; + for (want, got) in [(350, 400), (500, 400), (550, 700), (600, 700), (900, 700)] { + assert_eq!(nearest_weight(yahei, want).unwrap(), got, "YaHei UI {want}"); + } + } + + #[test] + fn registry_names() { + assert_eq!( + parse_registry_name("Segoe UI Semibold (TrueType)"), + vec![(Some("segoe ui".into()), Some(600))] + ); + assert_eq!( + parse_registry_name("Microsoft YaHei Bold & Microsoft YaHei UI Bold (TrueType)"), + vec![ + (Some("microsoft yahei".into()), Some(700)), + (Some("microsoft yahei ui".into()), Some(700)) + ] + ); + assert_eq!( + parse_registry_name("Segoe UI Bold Italic (TrueType)"), + vec![(None, None)] + ); + assert_eq!( + parse_registry_name("Cascadia Mono (TrueType)"), + vec![(Some("cascadia mono".into()), Some(400))] + ); + } + + #[test] + fn generics() { + assert_eq!( + normalize(" \"Noto Sans SC Subset\" ").as_deref(), + Some("noto sans sc subset") + ); + if cfg!(windows) { + assert_eq!(normalize("system-ui").as_deref(), Some("segoe ui")); + assert_eq!(normalize("ui-monospace"), None); + } + } + + #[test] + fn bundled_metrics() { + let m = FaceMetrics::read(VT323, 0).unwrap(); + // USE_TYPO_METRICS is set: typo 800 / -200, as Chromium lays it out. + assert_eq!( + (m.units_per_em, m.ascent, m.descent), + (1000.0, 800.0, 200.0) + ); + } +} diff --git a/crates/accshift-egui/src/ui/text/gamma.rs b/crates/accshift-egui/src/ui/text/gamma.rs new file mode 100644 index 0000000..83aa316 --- /dev/null +++ b/crates/accshift-egui/src/ui/text/gamma.rs @@ -0,0 +1,137 @@ +//! The coverage curve Chromium applies to grayscale glyphs. +//! +//! Skia does not blend raw glyph coverage. Before the blit it runs it through +//! a table built for the luminance class of the text colour +//! (`SkTMaskGamma_build_correcting_lut` in `src/core/SkMaskGamma.cpp`): it +//! guesses the background as the opposite luminance, applies a contrast boost +//! (`srca + (1 - srca) * contrast * srca`, tapering off as the text gets +//! lighter), blends in a space with the given gamma, and returns the coverage +//! that gives that result under a plain sRGB-space blend. Light text on a dark +//! page comes out heavier than its raw coverage, which is the look egui's +//! default `2c - c²` transfer only approximates. +//! +//! The software backend applies one table per luminance class to the glyph +//! texels (`egui_software_backend::glyph_coverage`); the glow renderer keeps +//! egui's own transfer function. + +/// Luminance classes, as in Skia's `SkTMaskGamma<3, 3, 3>`. +pub const CLASSES: usize = 8; + +/// Contrast and gamma of the pre-blend. [`CHROMIUM`] holds the values +/// measured against WebView2. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MaskGamma { + pub contrast: f32, + pub gamma: f32, +} + +/// What WebView2 on Windows draws with. Chromium hands Skia the gamma and +/// enhanced contrast of the DirectWrite rendering parameters, 2.2 and 1.0 on +/// a stock Windows (no ClearType tuning). Fitted on the typography shots +/// (`parity/fragments/typography*.html`): matching each line's coverage +/// histogram to egui's raw coverage gives c^(1/2.2) for white text, and the +/// grey classes need the full contrast; Skia's own defaults (0.2, 1.2) lose +/// 1.5 dB. +pub const CHROMIUM: MaskGamma = MaskGamma { + contrast: 1.0, + gamma: 2.2, +}; + +/// Skia's `sk_t_scale255<3>`: a 3-bit class back to a 0..255 luminance. +fn class_luminance(class: usize) -> u8 { + let i = class as u32; + ((i << 5) | (i << 2) | (i >> 1)) as u8 +} + +fn apply_contrast(srca: f32, contrast: f32) -> f32 { + srca + ((1.0 - srca) * contrast * srca) +} + +impl MaskGamma { + fn linear(self, v: f32) -> f32 { + if self.gamma == 1.0 { + v + } else { + v.powf(self.gamma) + } + } + + fn encoded(self, v: f32) -> f32 { + if self.gamma == 1.0 { + v + } else { + v.powf(1.0 / self.gamma) + } + } + + /// The table for text of luminance `src_i` (0..255). + pub fn table(self, src_i: u8) -> [u8; 256] { + let mut table = [0u8; 256]; + let src = src_i as f32 / 255.0; + let lin_src = self.linear(src); + let dst = 1.0 - src; + let lin_dst = self.linear(dst); + let contrast = self.contrast * lin_dst; + if (src - dst).abs() < 1.0 / 256.0 { + for (i, out) in table.iter_mut().enumerate() { + let srca = apply_contrast(i as f32 / 255.0, contrast); + *out = (255.0 * srca).round() as u8; + } + } else { + for (i, out) in table.iter_mut().enumerate() { + let srca = apply_contrast(i as f32 / 255.0, contrast); + let dsta = 1.0 - srca; + let lin_out = lin_src * srca + dsta * lin_dst; + let blended = self.encoded(lin_out); + let result = (blended - dst) / (src - dst); + *out = (255.0 * result).round().clamp(0.0, 255.0) as u8; + } + } + table + } + + /// One table per luminance class. + pub fn tables(self) -> [[u8; 256]; CLASSES] { + std::array::from_fn(|class| self.table(class_luminance(class))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ends_are_fixed() { + for g in [ + CHROMIUM, + MaskGamma { + contrast: 1.0, + gamma: 2.2, + }, + ] { + for t in g.tables() { + assert_eq!(t[0], 0); + assert_eq!(t[255], 255); + assert!(t.windows(2).all(|w| w[0] <= w[1]), "monotonic"); + } + } + } + + #[test] + fn white_text_is_a_pure_power() { + // src 1, dst 0: no contrast left, coverage ^ (1 / gamma). + let g = MaskGamma { + contrast: 0.5, + gamma: 2.0, + }; + let t = g.table(255); + assert_eq!(t[64], (255.0 * (64.0f32 / 255.0).sqrt()).round() as u8); + } + + #[test] + fn class_luminance_matches_skia() { + assert_eq!(class_luminance(0), 0); + assert_eq!(class_luminance(7), 255); + assert_eq!(class_luminance(3), 109); + } +} diff --git a/crates/accshift-egui/vendor/egui_software_backend/src/egui_texture.rs b/crates/accshift-egui/vendor/egui_software_backend/src/egui_texture.rs index 4bcc5f8..96f34f8 100644 --- a/crates/accshift-egui/vendor/egui_software_backend/src/egui_texture.rs +++ b/crates/accshift-egui/vendor/egui_software_backend/src/egui_texture.rs @@ -49,6 +49,21 @@ impl EguiTexture { } } + /// A texture from texels already in the output channel order. + pub fn from_data(data: Vec<[u8; 4]>, size: [usize; 2], options: TextureOptions) -> EguiTexture { + let uv_zero_val = data[0]; + EguiTexture { + data, + width_extent: size[0] as i32 - 1, + height_extent: size[1] as i32 - 1, + width: size[0], + height: size[1], + fsize: vec2(size[0] as f32, size[1] as f32), + options, + uv_zero_val, + } + } + #[allow(dead_code)] pub fn sample_nearest(&self, uv: Vec2) -> [u8; 4] { let ss_x = ((uv.x * self.fsize.x) as i32).max(0).min(self.width_extent); diff --git a/crates/accshift-egui/vendor/egui_software_backend/src/glyph_coverage.rs b/crates/accshift-egui/vendor/egui_software_backend/src/glyph_coverage.rs new file mode 100644 index 0000000..1a5c02b --- /dev/null +++ b/crates/accshift-egui/vendor/egui_software_backend/src/glyph_coverage.rs @@ -0,0 +1,173 @@ +//! Coverage curves for the glyphs of the font atlas (added for accshift). +//! +//! Chromium does not draw glyph coverage as is: Skia runs it through a +//! lookup table picked by the luminance of the text colour (its "mask gamma +//! pre-blend"), so light text on a dark page gets a different curve than dark +//! text. egui keeps one atlas for every colour. This module keeps one copy of +//! the glyph texels per luminance class actually drawn, each passed through +//! its table, stacked under the untouched atlas in a single texture, and +//! points the UVs of every glyph vertex at the copy of its class. Shapes keep +//! sampling the untouched rows (the white texel at UV 0,0 is never moved). +//! +//! The tables themselves come from the caller (`with_glyph_coverage`), so the +//! backend knows nothing about gamma: it only classifies a vertex colour the +//! way Skia does for an A8 mask and applies the table of that class. + +use alloc::vec::Vec; +use egui::{Color32, Mesh, TextureOptions}; + +/// Luminance classes: Skia keeps 3 bits of the text colour's luminance for +/// grayscale masks (`SkTMaskGamma<3, 3, 3>`). +pub const CLASSES: usize = 8; + +/// One table per luminance class, indexed by raw coverage (0 to 255). +pub type CoverageTables = [[u8; 256]; CLASSES]; + +/// Skia's class of a text colour: `SkComputeLuminance` on the colour without +/// its alpha (premultiplied vertex colours are divided back), top three bits. +#[inline] +pub fn class_of(color: Color32) -> usize { + let a = color.a() as u32; + if a == 0 { + return 0; + } + let un = |c: u8| ((c as u32 * 255 + a / 2) / a).min(255); + let lum = (un(color.r()) * 54 + un(color.g()) * 183 + un(color.b()) * 19) >> 8; + (lum >> 5) as usize +} + +/// A vertex samples a glyph when its UV is not the white texel at 0,0. +#[inline] +pub fn is_glyph_uv(uv: egui::Pos2) -> bool { + uv.x != 0.0 || uv.y != 0.0 +} + +/// The untouched font atlas and its per-class copies. +pub(crate) struct GlyphAtlas { + pub tables: CoverageTables, + /// The atlas as egui sent it, already in the output channel order. + pub raw: Vec<[u8; 4]>, + pub size: [usize; 2], + pub options: TextureOptions, + /// Class drawn by each copy, in stacking order (copy `i` sits at rows + /// `(i + 1) * height`). + pub slots: Vec, + /// Copy index per class, `u8::MAX` when the class has none. + pub slot_of_class: [u8; CLASSES], +} + +impl GlyphAtlas { + pub fn new(tables: CoverageTables) -> Self { + Self { + tables, + raw: Vec::new(), + size: [0, 0], + options: TextureOptions::default(), + slots: Vec::new(), + slot_of_class: [u8::MAX; CLASSES], + } + } + + /// Glyph texels are premultiplied white: every channel equals the + /// coverage, so the table maps all four. + #[inline] + fn apply(table: &[u8; 256], px: [u8; 4]) -> [u8; 4] { + let v = table[px[3] as usize]; + [v, v, v, v] + } + + /// The whole stacked texture: the raw atlas, then one copy per slot. + pub fn stacked(&self) -> Vec<[u8; 4]> { + let mut data = Vec::with_capacity(self.raw.len() * (1 + self.slots.len())); + data.extend_from_slice(&self.raw); + for &class in &self.slots { + let table = &self.tables[class as usize]; + data.extend(self.raw.iter().map(|&px| Self::apply(table, px))); + } + data + } + + /// Writes a region of the raw atlas and of every copy into `stacked`. + pub fn write_region( + &mut self, + stacked: &mut [[u8; 4]], + pos: [usize; 2], + size: [usize; 2], + src: impl Fn(usize) -> [u8; 4], + ) { + let [w, h] = self.size; + for y in 0..size[1] { + for x in 0..size[0] { + let px = src(x + y * size[0]); + let at = (x + pos[0]) + (y + pos[1]) * w; + self.raw[at] = px; + stacked[at] = px; + for (slot, &class) in self.slots.iter().enumerate() { + stacked[at + (slot + 1) * w * h] = + Self::apply(&self.tables[class as usize], px); + } + } + } + } + + /// Classes drawn by glyph vertices of `meshes` that have no copy yet. + pub fn missing_classes<'a>(&self, meshes: impl Iterator) -> [bool; CLASSES] { + let mut missing = [false; CLASSES]; + for mesh in meshes { + if mesh.texture_id != egui::TextureId::default() { + continue; + } + for v in &mesh.vertices { + if is_glyph_uv(v.uv) { + let class = class_of(v.color); + if self.slot_of_class[class] == u8::MAX { + missing[class] = true; + } + } + } + } + missing + } + + pub fn add_slot(&mut self, class: usize) { + self.slot_of_class[class] = self.slots.len() as u8; + self.slots.push(class as u8); + } + + /// UV of a vertex in the stacked texture. `color` is the vertex colour as + /// egui made it (before any channel swizzle). + #[inline] + pub fn remap_uv(&self, uv: egui::Pos2, color: Color32) -> egui::Pos2 { + let rows = (1 + self.slots.len()) as f32; + if !is_glyph_uv(uv) { + return uv; + } + let slot = self.slot_of_class[class_of(color)]; + // A class without a copy (never expected: copies are made before the + // meshes are prepared) samples the raw atlas. + let base = if slot == u8::MAX { + 0.0 + } else { + slot as f32 + 1.0 + }; + egui::pos2(uv.x, (uv.y + base) / rows) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classes_follow_skia_luminance() { + assert_eq!(class_of(Color32::WHITE), 7); + assert_eq!(class_of(Color32::BLACK), 0); + // #aeaeba: luminance 175, class 5. + assert_eq!(class_of(Color32::from_rgb(0xae, 0xae, 0xba)), 5); + // Alpha does not change the class. + assert_eq!( + class_of(Color32::from_rgba_unmultiplied(0xae, 0xae, 0xba, 100)), + 5 + ); + } +} diff --git a/crates/accshift-egui/vendor/egui_software_backend/src/lib.rs b/crates/accshift-egui/vendor/egui_software_backend/src/lib.rs index 6cb0981..a91888c 100644 --- a/crates/accshift-egui/vendor/egui_software_backend/src/lib.rs +++ b/crates/accshift-egui/vendor/egui_software_backend/src/lib.rs @@ -27,12 +27,14 @@ use crate::stats::RasterStats; use crate::{ color::{AvailableImpl, SelectedImpl, swizzle_rgba_bgra}, egui_texture::EguiTexture, + glyph_coverage::GlyphAtlas, hash::Hash32, render::{draw_egui_mesh, egui_orient2df}, }; pub(crate) mod color; pub(crate) mod egui_texture; +pub mod glyph_coverage; pub(crate) mod hash; pub(crate) mod math; pub(crate) mod raster; @@ -85,6 +87,9 @@ pub struct EguiSoftwareRender { allow_raster_opt: bool, cacheing_enabled: bool, simd_impl: AvailableImpl, + /// Per-colour coverage copies of the font atlas, when the caller gave + /// tables (`with_glyph_coverage`). + glyph_atlas: Option, #[cfg(feature = "raster_stats")] pub stats: RasterStats, } @@ -108,11 +113,46 @@ impl EguiSoftwareRender { allow_raster_opt: true, cacheing_enabled: true, simd_impl: Default::default(), + glyph_atlas: None, #[cfg(feature = "raster_stats")] stats: Default::default(), } } + /// Glyph coverage goes through `tables[class]`, the class being Skia's + /// luminance class of the text colour (see [`glyph_coverage`]). Without + /// it, the font atlas is sampled as egui wrote it. + pub fn with_glyph_coverage(mut self, tables: glyph_coverage::CoverageTables) -> Self { + self.glyph_atlas = Some(GlyphAtlas::new(tables)); + self + } + + /// Gives every luminance class drawn by a glyph this frame its copy of + /// the atlas, rebuilding the stacked texture when one is added. + fn ensure_glyph_classes(&mut self, paint_jobs: &[egui::ClippedPrimitive]) { + let Some(atlas) = &mut self.glyph_atlas else { + return; + }; + if atlas.raw.is_empty() { + return; + } + let meshes = paint_jobs.iter().filter_map(|job| match &job.primitive { + egui::epaint::Primitive::Mesh(mesh) => Some(mesh), + egui::epaint::Primitive::Callback(_) => None, + }); + let missing = atlas.missing_classes(meshes); + if !missing.contains(&true) { + return; + } + for (class, _) in missing.iter().enumerate().filter(|(_, m)| **m) { + atlas.add_slot(class); + } + let [w, h] = atlas.size; + let rows = h * (1 + atlas.slots.len()); + let texture = EguiTexture::from_data(atlas.stacked(), [w, rows], atlas.options); + self.textures.insert(egui::TextureId::default(), texture); + } + /// If true: attempts to optimize by converting suitable triangle pairs into rectangles for faster rendering. /// Things *should* look the same with this set to `true` while rendering faster. pub fn with_convert_tris_to_rects(mut self, set: bool) -> Self { @@ -209,6 +249,7 @@ impl EguiSoftwareRender { self.tiles_dim = [width.div_ceil(TILE_SIZE), height.div_ceil(TILE_SIZE)]; self.set_textures(textures_delta); + self.ensure_glyph_classes(paint_jobs); self.render_prims_to_cache(paint_jobs, pixels_per_point); @@ -417,6 +458,7 @@ impl EguiSoftwareRender { self.stats.clear(); self.set_textures(textures_delta); + self.ensure_glyph_classes(paint_jobs); self.target_size = vec2( direct_draw_buffer.width as f32, @@ -506,9 +548,16 @@ impl EguiSoftwareRender { mesh_max: &mut Vec2, ) -> Mesh { let mut px_mesh = mesh.clone(); + let glyph_atlas = self + .glyph_atlas + .as_ref() + .filter(|_| mesh.texture_id == egui::TextureId::default()); for v in px_mesh.vertices.iter_mut() { v.pos *= pixels_per_point; + if let Some(atlas) = glyph_atlas { + v.uv = atlas.remap_uv(v.uv, v.color); + } match self.output_field_order { ColorFieldOrder::Rgba => (), // egui uses rgba @@ -889,6 +938,32 @@ impl EguiSoftwareRender { } }; let size = delta.image.size(); + if *id == egui::TextureId::default() + && let Some(atlas) = &mut self.glyph_atlas + { + let order = self.output_field_order; + let texel = |i: usize| match order { + ColorFieldOrder::Rgba => pixels[i].to_array(), + ColorFieldOrder::Bgra => swizzle_rgba_bgra(pixels[i].to_array()), + }; + match delta.pos { + Some(pos) if atlas.size[0] > 0 => { + if let Some(texture) = self.textures.get_mut(id) { + atlas.write_region(&mut texture.data, pos, size, texel); + } + } + _ => { + atlas.raw = (0..size[0] * size[1]).map(texel).collect(); + atlas.size = size; + atlas.options = delta.options; + let rows = size[1] * (1 + atlas.slots.len()); + let texture = + EguiTexture::from_data(atlas.stacked(), [size[0], rows], delta.options); + self.textures.insert(*id, texture); + } + } + continue; + } if let Some(pos) = delta.pos { if let Some(texture) = self.textures.get_mut(id) { for y in 0..size[1] { From 7aaaccda410669e85819d028569c50708bab59f3 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:27:26 +0200 Subject: [PATCH 021/106] feat(egui): app shell and title bar --- crates/accshift-egui/Cargo.lock | 252 ++++++- crates/accshift-egui/Cargo.toml | 5 + crates/accshift-egui/parity/diff.mjs | 94 ++- .../parity/scenes/titlebar-hover-100.json | 35 + .../parity/scenes/titlebar-hover-150.json | 35 + .../scenes/titlebar-idle-linux-100.json | 9 + .../scenes/titlebar-idle-linux-150.json | 9 + .../scenes/titlebar-idle-macos-100.json | 9 + .../scenes/titlebar-idle-macos-150.json | 9 + .../scenes/titlebar-idle-windows-100.json | 9 + .../scenes/titlebar-idle-windows-150.json | 9 + .../parity/scenes/titlebar-tabs-100.json | 27 + .../parity/scenes/titlebar-tabs-150.json | 27 + crates/accshift-egui/src/app/clock.rs | 65 ++ crates/accshift-egui/src/app/mod.rs | 655 ++++++++++++++++++ crates/accshift-egui/src/app/platforms.rs | 222 ++++++ crates/accshift-egui/src/app/shell.rs | 313 +++++++++ crates/accshift-egui/src/app/tests.rs | 159 +++++ crates/accshift-egui/src/app/vars.rs | 153 ++++ crates/accshift-egui/src/harness/mod.rs | 9 + crates/accshift-egui/src/lib.rs | 7 +- crates/accshift-egui/src/main.rs | 18 +- .../accshift-egui/src/screens/main_spike.rs | 38 +- crates/accshift-egui/src/screens/mod.rs | 52 +- .../accshift-egui/src/ui/components/icon.rs | 128 ++++ crates/accshift-egui/src/ui/components/mod.rs | 16 + .../src/ui/components/platform_icons.rs | 42 ++ .../src/ui/components/title_bar.rs | 637 +++++++++++++++++ crates/accshift-egui/src/ui/mod.rs | 1 + 29 files changed, 2984 insertions(+), 60 deletions(-) create mode 100644 crates/accshift-egui/parity/scenes/titlebar-hover-100.json create mode 100644 crates/accshift-egui/parity/scenes/titlebar-hover-150.json create mode 100644 crates/accshift-egui/parity/scenes/titlebar-idle-linux-100.json create mode 100644 crates/accshift-egui/parity/scenes/titlebar-idle-linux-150.json create mode 100644 crates/accshift-egui/parity/scenes/titlebar-idle-macos-100.json create mode 100644 crates/accshift-egui/parity/scenes/titlebar-idle-macos-150.json create mode 100644 crates/accshift-egui/parity/scenes/titlebar-idle-windows-100.json create mode 100644 crates/accshift-egui/parity/scenes/titlebar-idle-windows-150.json create mode 100644 crates/accshift-egui/parity/scenes/titlebar-tabs-100.json create mode 100644 crates/accshift-egui/parity/scenes/titlebar-tabs-150.json create mode 100644 crates/accshift-egui/src/app/clock.rs create mode 100644 crates/accshift-egui/src/app/mod.rs create mode 100644 crates/accshift-egui/src/app/platforms.rs create mode 100644 crates/accshift-egui/src/app/shell.rs create mode 100644 crates/accshift-egui/src/app/tests.rs create mode 100644 crates/accshift-egui/src/app/vars.rs create mode 100644 crates/accshift-egui/src/ui/components/icon.rs create mode 100644 crates/accshift-egui/src/ui/components/mod.rs create mode 100644 crates/accshift-egui/src/ui/components/platform_icons.rs create mode 100644 crates/accshift-egui/src/ui/components/title_bar.rs diff --git a/crates/accshift-egui/Cargo.lock b/crates/accshift-egui/Cargo.lock index 2418f23..fa5ed6d 100644 --- a/crates/accshift-egui/Cargo.lock +++ b/crates/accshift-egui/Cargo.lock @@ -115,6 +115,26 @@ dependencies = [ "winit", ] +[[package]] +name = "accshift-app" +version = "0.0.0" +dependencies = [ + "accshift-core", + "base64 0.23.1", + "dirs", + "image", + "jiff", + "reqwest", + "rfd", + "rusqlite", + "rusty-leveldb", + "serde", + "serde_json", + "sha2 0.11.0", + "tokio", + "windows-sys 0.61.2", +] + [[package]] name = "accshift-core" version = "1.0.5" @@ -141,7 +161,9 @@ dependencies = [ name = "accshift-egui" version = "0.0.0" dependencies = [ + "accshift-app", "accshift-core", + "accshift-theme", "eframe", "egui", "egui-winit", @@ -157,6 +179,14 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "accshift-theme" +version = "0.0.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "addr2line" version = "0.25.1" @@ -460,6 +490,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bitflags" version = "1.3.2" @@ -809,6 +845,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32c" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" +dependencies = [ + "rustc_version", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -918,6 +963,37 @@ dependencies = [ "zeroize", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "digest" version = "0.10.7" @@ -941,6 +1017,27 @@ dependencies = [ "ctutils", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -954,6 +1051,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.13.1", + "block2 0.6.2", + "libc", "objc2 0.6.4", ] @@ -1813,7 +1912,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1945,6 +2044,8 @@ dependencies = [ "num-traits", "png 0.18.1", "tiff", + "zune-core", + "zune-jpeg", ] [[package]] @@ -1973,6 +2074,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "integer-encoding" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" + [[package]] name = "io-uring" version = "0.7.14" @@ -2024,6 +2131,60 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.22.4" @@ -2500,6 +2661,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.1", + "block2 0.6.2", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", @@ -2785,6 +2947,12 @@ dependencies = [ "libc", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "orbclient" version = "0.3.55" @@ -3001,6 +3169,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + [[package]] name = "polycool" version = "0.4.0" @@ -3010,6 +3184,21 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -3270,13 +3459,23 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.20", +] + [[package]] name = "reqwest" version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -3324,6 +3523,33 @@ dependencies = [ "usvg", ] +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "percent-encoding", + "pollster", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "web-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rgb" version = "0.8.53" @@ -3466,6 +3692,19 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rusty-leveldb" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43ddda8f34100fca4bf1b0bbf743d12d27b5d84c35c74a52f392aac26f54e82d" +dependencies = [ + "bytes", + "crc32c", + "integer-encoding", + "rand 0.8.7", + "snap", +] + [[package]] name = "ryu" version = "1.0.23" @@ -3596,6 +3835,7 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -3815,6 +4055,12 @@ dependencies = [ "serde", ] +[[package]] +name = "snap" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886" + [[package]] name = "socket2" version = "0.6.5" @@ -4287,7 +4533,7 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", "imagesize", diff --git a/crates/accshift-egui/Cargo.toml b/crates/accshift-egui/Cargo.toml index 4355b0f..79d7cc4 100644 --- a/crates/accshift-egui/Cargo.toml +++ b/crates/accshift-egui/Cargo.toml @@ -25,6 +25,10 @@ default = ["software"] [dependencies] accshift-core = { path = "../accshift-core" } +# The UI-free app logic (backend trait, mock, real backend, stores, i18n) and +# the theme engine the Svelte frontend applies. +accshift-app = { path = "../accshift-app" } +accshift-theme = { path = "../accshift-theme" } # Same backend choice as spotifast for the `glow` path: glow keeps the binary # and the tree small, wgpu buys nothing for a 2D grid. eframe = { version = "0.36", optional = true, default-features = false, features = [ @@ -73,6 +77,7 @@ windows-sys = { version = "0.61", features = [ "Win32_Graphics_Gdi", "Win32_System_Registry", "Win32_UI_Accessibility", + "Win32_UI_WindowsAndMessaging", ] } # The root workspace profile, except the size: a smaller exe reaches `main` diff --git a/crates/accshift-egui/parity/diff.mjs b/crates/accshift-egui/parity/diff.mjs index 6d56598..0ba2348 100644 --- a/crates/accshift-egui/parity/diff.mjs +++ b/crates/accshift-egui/parity/diff.mjs @@ -3,6 +3,7 @@ // // node diff.mjs --out

// node diff.mjs --batch --out +// ... --region x,y,w,h [--scale ] // // Both images are decoded to raw RGBA by ffmpeg (PNG or PAM in), then each is // composited over black so a transparent pixel compares as what an opaque @@ -18,6 +19,12 @@ // top, other below). When the sizes differ, the overlapping top-left area is // compared and the shot is flagged. // +// `--region x,y,w,h` limits every number and both PNGs to that rectangle, in +// CSS px of the viewport. It is scaled by each shot's scale: the scene's +// `viewport.scale` (scenes/.json next to this file) in batch mode, +// `--scale` (default 1) for a single pair. The worst tile stays in image +// pixels of the whole shot. +// // Batch mode pairs shots through /manifest.json (written by ref.mjs): // //.png|pam first, then /.png|pam for // shots whose scene step names an alias (the spike's `--capture` PAM names). @@ -37,6 +44,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; import { basename, dirname, extname, join, relative, resolve } from "node:path"; const FFMPEG = process.env.FFMPEG ?? "ffmpeg"; @@ -130,8 +138,40 @@ function ramp(v) { return [r * 255, g * 255, b * 255]; } -export function compare(refFile, otherFile, heatFile, sideFile) { - const [a, b] = decode(refFile, otherFile); +/** `img` cut to `rect` (image pixels), clipped to the image. */ +function crop(img, rect) { + const x0 = Math.max(0, Math.min(img.width, rect.x)); + const y0 = Math.max(0, Math.min(img.height, rect.y)); + const width = Math.max(0, Math.min(img.width, rect.x + rect.w) - x0); + const height = Math.max(0, Math.min(img.height, rect.y + rect.h) - y0); + const data = Buffer.alloc(width * height * 4); + for (let y = 0; y < height; y++) { + const from = ((y0 + y) * img.width + x0) * 4; + img.data.copy(data, y * width * 4, from, from + width * 4); + } + return { width, height, data }; +} + +/** `--region` in CSS px to image pixels at `scale`. */ +export function scaleRegion(region, scale) { + if (!region) return null; + const [x, y, w, h] = region; + const x0 = Math.round(x * scale); + const y0 = Math.round(y * scale); + return { + x: x0, + y: y0, + w: Math.round((x + w) * scale) - x0, + h: Math.round((y + h) * scale) - y0, + }; +} + +export function compare(refFile, otherFile, heatFile, sideFile, region = null) { + let [a, b] = decode(refFile, otherFile); + if (region) { + a = crop(a, region); + b = crop(b, region); + } a.rgb = overBlack(a); b.rgb = overBlack(b); const width = Math.min(a.width, b.width); @@ -212,9 +252,10 @@ export function compare(refFile, otherFile, heatFile, sideFile) { psnr: mse === 0 ? 99 : round(10 * Math.log10((255 * 255) / mse)), over8: round((over8 / n) * 100), over24: round((over24 / n) * 100), + ...(region ? { region } : {}), worst: { - x: (worst % tilesX) * 16, - y: Math.floor(worst / tilesX) * 16, + x: (worst % tilesX) * 16 + (region?.x ?? 0), + y: Math.floor(worst / tilesX) * 16 + (region?.y ?? 0), mae: round(tileSum[worst] / tileCount[worst]), }, }; @@ -245,9 +286,28 @@ function refShots(refDir) { return shots; } -function batch(refDir, otherDir, out) { +/** `viewport.scale` of a scene file next to this script, or null. */ +function sceneScale(scene) { + const file = join(dirname(fileURLToPath(import.meta.url)), "scenes", `${scene}.json`); + if (!existsSync(file)) return null; + return JSON.parse(readFileSync(file, "utf8")).viewport?.scale ?? 1.5; +} + +function batch(refDir, otherDir, out, region) { const rows = []; for (const s of refShots(refDir)) { + let rect = null; + if (region) { + const scale = sceneScale(s.scene); + if (scale === null) { + console.log( + `${s.scene}/${s.shot}: no scenes/${s.scene}.json for the region scale, skipped`, + ); + rows.push({ scene: s.scene, shot: s.shot, missing: true }); + continue; + } + rect = scaleRegion(region, scale); + } const other = findOther(otherDir, [`${s.scene}/${s.shot}`, ...(s.alias ? [s.alias] : [])]); const row = { scene: s.scene, shot: s.shot, ref: join(refDir, s.file), other }; if (!other) { @@ -261,6 +321,7 @@ function batch(refDir, otherDir, out) { other, join(out, s.scene, `${s.shot}-heat.png`), join(out, s.scene, `${s.shot}-side.png`), + rect, ), ); console.log( @@ -280,14 +341,14 @@ function batch(refDir, otherDir, out) { }; writeFileSync( join(out, "report.json"), - JSON.stringify({ refDir, otherDir, summary, rows }, null, 2) + "\n", + JSON.stringify({ refDir, otherDir, region, summary, rows }, null, 2) + "\n", ); const rel = (p) => relative(out, p).split("\\").join("/"); const md = [ `# Parity diff`, "", - `Reference \`${refDir}\` against \`${otherDir}\`: ${done.length} of ${rows.length} shots compared, mean PSNR ${summary.meanPsnr} dB, mean MAE ${summary.meanMae}.`, + `Reference \`${refDir}\` against \`${otherDir}\`: ${done.length} of ${rows.length} shots compared, mean PSNR ${summary.meanPsnr} dB, mean MAE ${summary.meanMae}.${region ? ` Region ${region.join(",")} (CSS px).` : ""}`, "", "| Shot | Other | PSNR (dB) | MAE | > 8 (%) | > 24 (%) | Worst 16x16 tile | Images |", "| --- | --- | ---: | ---: | ---: | ---: | --- | --- |", @@ -309,15 +370,20 @@ function main() { const argv = process.argv.slice(2); let out = null; let isBatch = false; + let region = null; + let scale = 1; const pos = []; for (let i = 0; i < argv.length; i++) { if (argv[i] === "--out") out = argv[++i]; else if (argv[i] === "--batch") isBatch = true; + else if (argv[i] === "--region") region = String(argv[++i]).split(",").map(Number); + else if (argv[i] === "--scale") scale = Number(argv[++i]); else pos.push(argv[i]); } - if (!out || pos.length !== 2) { + const badRegion = region && (region.length !== 4 || region.some((v) => !Number.isFinite(v))); + if (!out || pos.length !== 2 || badRegion || !(scale > 0)) { console.error( - "usage: diff.mjs --out | diff.mjs --batch --out ", + "usage: diff.mjs --out [--region x,y,w,h --scale s] | diff.mjs --batch --out [--region x,y,w,h]", ); process.exit(2); } @@ -325,12 +391,18 @@ function main() { mkdirSync(out, { recursive: true }); const [ref, other] = pos.map((p) => resolve(p)); if (isBatch) { - const summary = batch(ref, other, out); + const summary = batch(ref, other, out, region); if (summary.compared === 0) process.exitCode = 1; return; } const stem = basename(ref, extname(ref)); - const result = compare(ref, other, join(out, `${stem}-heat.png`), join(out, `${stem}-side.png`)); + const result = compare( + ref, + other, + join(out, `${stem}-heat.png`), + join(out, `${stem}-side.png`), + scaleRegion(region, scale), + ); console.log(JSON.stringify(result)); } diff --git a/crates/accshift-egui/parity/scenes/titlebar-hover-100.json b/crates/accshift-egui/parity/scenes/titlebar-hover-100.json new file mode 100644 index 0000000..67274d0 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-hover-100.json @@ -0,0 +1,35 @@ +{ + "id": "titlebar-hover-100", + "scenario": "demo", + "os": "windows", + "viewport": { "width": 860, "height": 440, "scale": 1 }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { "wait": 600 }, + { "move": [23, 18] }, + { "wait": 600 }, + { "shot": "refresh" }, + { "move": [55, 18] }, + { "wait": 600 }, + { "shot": "add" }, + { "move": [87, 18] }, + { "wait": 600 }, + { "shot": "settings" }, + { "move": [119, 18] }, + { "wait": 600 }, + { "shot": "bulk-edit" }, + { "move": [447, 18] }, + { "wait": 600 }, + { "shot": "tab-riot" }, + { "move": [745, 18] }, + { "wait": 600 }, + { "shot": "minimize" }, + { "move": [791, 18] }, + { "wait": 600 }, + { "shot": "maximize" }, + { "move": [837, 18] }, + { "wait": 600 }, + { "shot": "close" } + ] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-hover-150.json b/crates/accshift-egui/parity/scenes/titlebar-hover-150.json new file mode 100644 index 0000000..7c5dac1 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-hover-150.json @@ -0,0 +1,35 @@ +{ + "id": "titlebar-hover-150", + "scenario": "demo", + "os": "windows", + "viewport": { "width": 860, "height": 440, "scale": 1.5 }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { "wait": 600 }, + { "move": [23, 18] }, + { "wait": 600 }, + { "shot": "refresh" }, + { "move": [55, 18] }, + { "wait": 600 }, + { "shot": "add" }, + { "move": [87, 18] }, + { "wait": 600 }, + { "shot": "settings" }, + { "move": [119, 18] }, + { "wait": 600 }, + { "shot": "bulk-edit" }, + { "move": [447, 18] }, + { "wait": 600 }, + { "shot": "tab-riot" }, + { "move": [745, 18] }, + { "wait": 600 }, + { "shot": "minimize" }, + { "move": [791, 18] }, + { "wait": 600 }, + { "shot": "maximize" }, + { "move": [837, 18] }, + { "wait": 600 }, + { "shot": "close" } + ] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-idle-linux-100.json b/crates/accshift-egui/parity/scenes/titlebar-idle-linux-100.json new file mode 100644 index 0000000..8b10c34 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-idle-linux-100.json @@ -0,0 +1,9 @@ +{ + "id": "titlebar-idle-linux-100", + "scenario": "demo", + "os": "linux", + "viewport": { "width": 860, "height": 440, "scale": 1 }, + "theme": "glass-dark", + "locale": "en", + "steps": [{ "wait": 600 }, { "move": [250, 300] }, { "wait": 300 }, { "shot": "idle" }] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-idle-linux-150.json b/crates/accshift-egui/parity/scenes/titlebar-idle-linux-150.json new file mode 100644 index 0000000..f2a7c34 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-idle-linux-150.json @@ -0,0 +1,9 @@ +{ + "id": "titlebar-idle-linux-150", + "scenario": "demo", + "os": "linux", + "viewport": { "width": 860, "height": 440, "scale": 1.5 }, + "theme": "glass-dark", + "locale": "en", + "steps": [{ "wait": 600 }, { "move": [250, 300] }, { "wait": 300 }, { "shot": "idle" }] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-idle-macos-100.json b/crates/accshift-egui/parity/scenes/titlebar-idle-macos-100.json new file mode 100644 index 0000000..5c781cc --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-idle-macos-100.json @@ -0,0 +1,9 @@ +{ + "id": "titlebar-idle-macos-100", + "scenario": "demo", + "os": "macos", + "viewport": { "width": 860, "height": 440, "scale": 1 }, + "theme": "glass-dark", + "locale": "en", + "steps": [{ "wait": 600 }, { "move": [250, 300] }, { "wait": 300 }, { "shot": "idle" }] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-idle-macos-150.json b/crates/accshift-egui/parity/scenes/titlebar-idle-macos-150.json new file mode 100644 index 0000000..90c14c8 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-idle-macos-150.json @@ -0,0 +1,9 @@ +{ + "id": "titlebar-idle-macos-150", + "scenario": "demo", + "os": "macos", + "viewport": { "width": 860, "height": 440, "scale": 1.5 }, + "theme": "glass-dark", + "locale": "en", + "steps": [{ "wait": 600 }, { "move": [250, 300] }, { "wait": 300 }, { "shot": "idle" }] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-idle-windows-100.json b/crates/accshift-egui/parity/scenes/titlebar-idle-windows-100.json new file mode 100644 index 0000000..9a99b86 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-idle-windows-100.json @@ -0,0 +1,9 @@ +{ + "id": "titlebar-idle-windows-100", + "scenario": "demo", + "os": "windows", + "viewport": { "width": 860, "height": 440, "scale": 1 }, + "theme": "glass-dark", + "locale": "en", + "steps": [{ "wait": 600 }, { "move": [250, 300] }, { "wait": 300 }, { "shot": "idle" }] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-idle-windows-150.json b/crates/accshift-egui/parity/scenes/titlebar-idle-windows-150.json new file mode 100644 index 0000000..b24fd9d --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-idle-windows-150.json @@ -0,0 +1,9 @@ +{ + "id": "titlebar-idle-windows-150", + "scenario": "demo", + "os": "windows", + "viewport": { "width": 860, "height": 440, "scale": 1.5 }, + "theme": "glass-dark", + "locale": "en", + "steps": [{ "wait": 600 }, { "move": [250, 300] }, { "wait": 300 }, { "shot": "idle" }] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-tabs-100.json b/crates/accshift-egui/parity/scenes/titlebar-tabs-100.json new file mode 100644 index 0000000..fe8ede2 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-tabs-100.json @@ -0,0 +1,27 @@ +{ + "id": "titlebar-tabs-100", + "scenario": "demo", + "os": "windows", + "viewport": { "width": 860, "height": 440, "scale": 1 }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { "wait": 600 }, + { "click": [379, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "personas" }, + { "click": [413, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "steam" }, + { "click": [447, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "riot" }, + { "click": [481, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "roblox" } + ] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-tabs-150.json b/crates/accshift-egui/parity/scenes/titlebar-tabs-150.json new file mode 100644 index 0000000..c51357d --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-tabs-150.json @@ -0,0 +1,27 @@ +{ + "id": "titlebar-tabs-150", + "scenario": "demo", + "os": "windows", + "viewport": { "width": 860, "height": 440, "scale": 1.5 }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { "wait": 600 }, + { "click": [379, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "personas" }, + { "click": [413, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "steam" }, + { "click": [447, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "riot" }, + { "click": [481, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "roblox" } + ] +} diff --git a/crates/accshift-egui/src/app/clock.rs b/crates/accshift-egui/src/app/clock.rs new file mode 100644 index 0000000..f2696f7 --- /dev/null +++ b/crates/accshift-egui/src/app/clock.rs @@ -0,0 +1,65 @@ +//! The mock clock of the reference app. +//! +//! The Tauri mock pins `Date.now()` and an argument-less `new Date()` to the +//! scenario's `NOW` when the document starts, then lets them advance with +//! `performance.now()` (`PIN_CLOCK` in `parity/harness.mjs`). The egui side +//! does the same, so relative times ("2 days ago") read the same in both. + +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +#[derive(Clone, Copy, Debug)] +pub struct Clock { + /// `NOW * 1000` of the mock scenario, or `None` for the wall clock. + pinned_ms: Option, + start: Instant, +} + +impl Clock { + /// The real clock. + pub fn wall() -> Clock { + Clock { + pinned_ms: None, + start: Instant::now(), + } + } + + /// `Date.now()` starts at `now_s * 1000` and advances from here. + pub fn pinned(now_s: u64) -> Clock { + Clock { + pinned_ms: Some(now_s as f64 * 1000.0), + start: Instant::now(), + } + } + + pub fn is_pinned(&self) -> bool { + self.pinned_ms.is_some() + } + + /// `Date.now()`: milliseconds since the Unix epoch. + pub fn now_ms(&self) -> f64 { + match self.pinned_ms { + Some(base) => base + self.start.elapsed().as_secs_f64() * 1000.0, + None => SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs_f64() * 1000.0) + .unwrap_or(0.0), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pinned_clock_starts_at_now_and_advances() { + let clock = Clock::pinned(1_791_000_000); + let first = clock.now_ms(); + assert!((1_791_000_000_000.0..1_791_000_000_000.0 + 1000.0).contains(&first)); + std::thread::sleep(std::time::Duration::from_millis(20)); + let later = clock.now_ms(); + assert!(later - first >= 19.0, "advances like performance.now()"); + assert!(Clock::wall().now_ms() > 1_700_000_000_000.0); + assert!(clock.is_pinned() && !Clock::wall().is_pinned()); + } +} diff --git a/crates/accshift-egui/src/app/mod.rs b/crates/accshift-egui/src/app/mod.rs new file mode 100644 index 0000000..4994e44 --- /dev/null +++ b/crates/accshift-egui/src/app/mod.rs @@ -0,0 +1,655 @@ +//! The egui application: `AppModel` owns everything `App.svelte` and its +//! controllers hold, and draws one frame in the DOM order of `App.svelte`. +//! +//! - backend: [`MockBackend`] on `parity/data/.json` in a scene +//! run, [`RealBackend`] on the user's app folders otherwise, both behind +//! `Arc`; +//! - [`Jobs`]: every backend call runs off the UI thread, and a result wakes +//! the UI through `Context::request_repaint`; +//! - [`ClientStores`] loaded from the boot payload's storage snapshot; +//! - the theme: [`ThemeRegistry`] with the custom themes, the +//! [`ThemeApplication`] of the current settings and OS, its +//! [`ResolvedTheme`] and the typed [`Vars`], recomputed when the theme id, +//! the background opacity, the motion setting or the OS change; +//! - the locale ([`Locale`]) for `accshift_app::i18n`; +//! - navigation: the active platform tab, the enabled platforms, settings +//! and personas panels; +//! - [`Action`]s returned by components, applied after the frame is drawn. +//! +//! A frame: take finished jobs, then draw (`shell::draw`), then apply the +//! actions. Before the boot payload lands nothing is drawn, like +//! `.app-frame { opacity: 0 }` before `boot-ready`. + +pub mod clock; +pub mod platforms; +mod shell; +pub mod vars; + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Instant; + +use accshift_app::backend::Backend; +use accshift_app::error::BackendResult; +use accshift_app::i18n::Locale; +use accshift_app::jobs::{Jobs, Pending, Poll}; +use accshift_app::mock::MockBackend; +use accshift_app::real::RealBackend; +use accshift_app::settings::AnimationsMode; +use accshift_app::storage::{self, StoreId}; +use accshift_app::stores::{ClientStores, StoreContext}; +use accshift_app::types::BootPayload; +use accshift_theme::{ + theme_application, window_backdrop, AppTheme, CustomThemePayload, ResolvedTheme, + ThemeApplication, ThemeRegistry, WindowBackdrop, +}; +use egui::{Context, Rect, TextureHandle, Ui, ViewportCommand}; +use serde_json::Value; + +use crate::screens::main_spike; +use crate::ui::anim::{self, Motion}; +use clock::Clock; +use platforms::{PlatformDef, RuntimeOs}; +use vars::Vars; + +/// What a component asks for. Applied by [`AppModel::apply`] after drawing. +#[derive(Clone, Debug, PartialEq)] +pub enum Action { + /// Title bar refresh: reload the active platform's accounts. + Refresh, + /// Title bar plus: start the add-account flow. + AddAccount, + /// Title bar gear: toggle the settings panel. + OpenSettings, + /// Personas tab. + OpenPersonas, + /// Bulk edit toggle. + BulkEdit, + /// Update call to action. + ApplyUpdate, + /// A platform tab. + TabChange(String), + Minimize, + ToggleMaximize, + Close, + /// Mousedown on the bar outside a button. + StartDrag, + /// Where the maximize button is, for Windows 11 Snap Layouts + /// (`set_maximize_button_rect`), `None` when there is none (macOS). + SetMaximizeButtonRect(Option), +} + +/// How a scene sets up the mock, the same fields `parity/ref.mjs` writes into +/// `accshift.mock.overrides`. +#[derive(Clone, Debug, Default)] +pub struct MockSetup { + pub scenario: String, + pub os: Option, + pub theme: Option, + pub locale: Option, + pub seed: Option, + /// `parity/data`. + pub data_dir: PathBuf, + /// The repo root, to resolve the mock avatar paths. + pub repo_root: Option, +} + +impl MockSetup { + /// `theme` and `locale` patch `client.settings`; a `seed` key is a store + /// id or the suffix of its `CLIENT_STORE_` constant, an object merges over + /// the scenario's store and anything else replaces it. + pub fn patch_stores(&self, stores: &mut std::collections::BTreeMap) { + let settings_id = storage::CLIENT_STORE_SETTINGS; + let mut settings = stores + .get(settings_id) + .cloned() + .filter(Value::is_object) + .unwrap_or_else(|| Value::Object(Default::default())); + if let Some(theme) = &self.theme { + settings["themeId"] = Value::String(theme.clone()); + } + if let Some(locale) = &self.locale { + settings["language"] = Value::String(locale.clone()); + } + stores.insert(settings_id.to_string(), settings); + let Some(Value::Object(seed)) = &self.seed else { + return; + }; + for (key, value) in seed { + let id = store_id_for_seed(key).unwrap_or(key.as_str()).to_string(); + let merged = match (value, stores.get(&id)) { + (Value::Object(over), Some(Value::Object(base))) => { + let mut out = base.clone(); + for (k, v) in over { + out.insert(k.clone(), v.clone()); + } + Value::Object(out) + } + _ => value.clone(), + }; + stores.insert(id, merged); + } + } +} + +/// `CLIENT_STORE_` of `src/lib/storage/storeIds.ts` for a seed key. +/// The suffixes follow the ids (`client.view-mode` is `VIEW_MODE`), except +/// the caches, which nothing seeds. +fn store_id_for_seed(key: &str) -> Option { + storage::CLIENT_STORE_IDS.into_iter().find(|id| { + id.strip_prefix("client.") + .is_some_and(|rest| rest.replace('-', "_").eq_ignore_ascii_case(key)) + }) +} + +/// The inputs a theme application depends on. +#[derive(Clone, Debug, PartialEq)] +struct ThemeKey { + theme_id: String, + opacity: i64, + os: RuntimeOs, + animations: AnimationsMode, + custom_themes: usize, +} + +/// The theme in effect. +pub struct ThemeState { + key: ThemeKey, + pub theme: AppTheme, + pub application: ThemeApplication, + pub resolved: ResolvedTheme, + pub vars: Vars, + /// What the window shell must put behind a transparent window. + pub backdrop: WindowBackdrop, + pub motion: Motion, +} + +/// Navigation sub-state (`createPlatformShellState`, `useAppNavigation`, +/// `usePersonaSwitch`). +#[derive(Clone, Debug, Default)] +pub struct Navigation { + pub active_tab: String, + pub show_settings: bool, + pub show_personas: bool, + pub bulk_edit: bool, +} + +impl Navigation { + /// `panelKey` of `App.svelte`. + pub fn panel_key(&self) -> String { + if self.show_settings { + "__settings__".into() + } else if self.show_personas { + "__personas__".into() + } else { + self.active_tab.clone() + } + } +} + +/// Everything that exists once the boot payload is in. +pub struct Booted { + pub runtime_os: RuntimeOs, + pub stores: ClientStores, + pub platforms: Vec, + pub registry: ThemeRegistry, + pub theme: ThemeState, + pub locale: Locale, + pub nav: Navigation, + /// `ctx.input().time` when `boot-ready` was set. + pub boot_at: f64, + /// `panelKey` and when it last changed (`{#key panelKey}` remount). + pub panel: (String, f64), + pub init_error: Option, +} + +type BootResult = BackendResult; + +/// The application model. See the module documentation. +pub struct AppModel { + backend: Arc, + mock: Option>, + setup: Option, + jobs: Jobs, + clock: Clock, + boot_job: Option>, + boot_error: Option, + booted: Option, + saves: Vec<(StoreId, Pending>>)>, + /// The panel slot's placeholder until the workspace port replaces it. + spike: main_spike::AccshiftApp, + /// The recorder's stand-in desktop under glass themes, scene runs only. + desktop: Option, + maximize_button: Option, + focus_visible: bool, + last_time: f64, + os_reduced_motion: bool, +} + +impl AppModel { + /// A scene run: the mock on `parity/data/.json`, the clock + /// pinned to the scenario's `NOW`, the stand-in desktop behind. + pub fn mock(ctx: &Context, setup: MockSetup) -> Result { + // `accshift.mock.overrides.runtimeOs` patches the scenario's spec + // before the handlers exist, frozen `demo` included, unlike + // `__mock.os` (`set_runtime_os`), which the frozen scenario refuses. + let file = setup.data_dir.join(format!("{}.json", setup.scenario)); + let text = + std::fs::read_to_string(&file).map_err(|e| format!("read {}: {e}", file.display()))?; + let mut scenario: Value = + serde_json::from_str(&text).map_err(|e| format!("{}: {e}", file.display()))?; + if let Some(os) = &setup.os { + scenario["spec"]["runtimeOs"] = Value::String(os.clone()); + } + let mock = MockBackend::from_json(&scenario.to_string(), setup.repo_root.as_deref()) + .map_err(|e| format!("{}: {e}", file.display()))?; + let mock = Arc::new(mock); + let clock = Clock::pinned(mock.now()); + let backend: Arc = mock.clone(); + let mut model = AppModel::with_backend(ctx, backend, Some(mock), clock); + model.setup = Some(setup); + model.desktop = Some(ctx.load_texture( + "desktop", + crate::backdrop::render(860, 440, 2.0), + egui::TextureOptions::LINEAR, + )); + Ok(model) + } + + /// A normal run on the user's real app folders. + pub fn real(ctx: &Context) -> Result { + let backend: Arc = Arc::new(RealBackend::new()?); + let mut model = AppModel::with_backend(ctx, backend, None, Clock::wall()); + model.os_reduced_motion = os_prefers_reduced_motion(); + Ok(model) + } + + /// Any backend; the boot payload is requested at once. + pub fn with_backend( + ctx: &Context, + backend: Arc, + mock: Option>, + clock: Clock, + ) -> AppModel { + let wake = ctx.clone(); + let jobs = Jobs::new(Arc::clone(&backend), 2, move || wake.request_repaint()); + let boot_job = Some(jobs.run(|backend| backend.get_boot_payload())); + let spike = main_spike::AccshiftApp::new( + ctx, + main_spike::Options { + desktop_backdrop: false, + embedded: true, + }, + ); + AppModel { + backend, + mock, + setup: None, + jobs, + clock, + boot_job, + boot_error: None, + booted: None, + saves: Vec::new(), + spike, + desktop: None, + maximize_button: None, + focus_visible: false, + last_time: 0.0, + os_reduced_motion: false, + } + } + + pub fn backend(&self) -> &Arc { + &self.backend + } + + pub fn booted(&self) -> Option<&Booted> { + self.booted.as_ref() + } + + pub fn boot_error(&self) -> Option<&str> { + self.boot_error.as_deref() + } + + /// `Date.now()` of the app (pinned in scene runs). + pub fn now_ms(&self) -> f64 { + self.clock.now_ms() + } + + /// Where the maximize button was last drawn (Snap Layouts hook). + pub fn maximize_button_rect(&self) -> Option { + self.maximize_button + } + + /// Blocks until the boot payload is in and applied. For tests and tools; + /// a frame never waits. + pub fn wait_boot(&mut self, ctx: &Context) -> Result<(), String> { + if let Some(job) = self.boot_job.take() { + let result = job.wait().unwrap_or_else(|| { + Err(accshift_app::error::BackendError::new("boot job panicked")) + }); + self.finish_boot(ctx, result, 0.0); + } + match (&self.booted, &self.boot_error) { + (Some(_), _) => Ok(()), + (None, Some(e)) => Err(e.clone()), + (None, None) => Err("boot never ran".into()), + } + } + + /// Ready to be photographed: booted, the boot cascade over, the panel's + /// assets uploaded. + pub fn ready(&self) -> bool { + self.booted + .as_ref() + .is_some_and(|b| self.last_time - b.boot_at >= shell::BOOT_CASCADE) + && self.spike.avatars_ready() + } + + fn finish_boot(&mut self, ctx: &Context, result: BootResult, now: f64) { + let mut payload = match result { + Ok(payload) => payload, + Err(error) => { + self.boot_error = Some(error.message); + return; + } + }; + if let Some(setup) = &self.setup { + setup.patch_stores(&mut payload.storage_snapshot.stores); + } + let runtime_os = RuntimeOs::parse(&payload.runtime_os); + let (storage, report) = storage::initialize( + self.backend.as_ref(), + Some(payload.storage_snapshot), + // The WebView2 localStorage of a v0.x install: a later job. + None, + self.mock.is_some(), + ); + let loaded: Vec = payload + .user_platforms + .loaded + .iter() + .filter_map(|d| serde_json::to_value(d).ok()) + .collect(); + let platforms = platforms::with_user(&loaded); + let custom: Vec = payload + .custom_themes + .iter() + .filter_map(|t| serde_json::to_value(t).ok()) + .filter_map(|v| serde_json::from_value(v).ok()) + .collect(); + let mut registry = ThemeRegistry::new(); + registry.apply_custom_theme_payloads(&custom); + let mut store_ctx = StoreContext::shipped(); + store_ctx.platform_ids = platforms.iter().map(|p| p.id.clone()).collect(); + store_ctx.theme_ids = registry.all_themes().into_iter().map(|t| t.id).collect(); + store_ctx.preferred_languages = preferred_languages(); + let stores = ClientStores::new(storage, store_ctx); + if let Some(mock) = &self.mock { + // The TS mock reads the live settings for its PIN gate. + mock.set_pin_settings(&stores.settings().to_value()); + } + let settings = stores.settings(); + let nav = Navigation { + active_tab: platforms::initial_tab( + &platforms, + &settings.enabled_platforms, + &settings.default_platform_id, + runtime_os, + ), + ..Navigation::default() + }; + let locale = stores.language(); + let theme = compute_theme(®istry, &stores, runtime_os, self.os_reduced_motion); + anim::set_motion(ctx, theme.motion); + let panel = (nav.panel_key(), now); + self.booted = Some(Booted { + runtime_os, + stores, + platforms, + registry, + theme, + locale, + nav, + boot_at: now, + panel, + init_error: report.load_error, + }); + // `finish_boot` (marks, the startup event); the real shell shows the + // window here. Fire and forget. + drop(self.jobs.run(|backend| backend.finish_boot("egui", None))); + } + + /// Settings changed under us (a store write or an external refresh): + /// recompute what derives from them. + fn sync_settings(&mut self, ctx: &Context) { + let Some(b) = self.booted.as_mut() else { + return; + }; + b.stores.sync(); + let key = theme_key(&b.stores, &b.registry, b.runtime_os); + if key != b.theme.key { + b.theme = compute_theme(&b.registry, &b.stores, b.runtime_os, self.os_reduced_motion); + } + b.locale = b.stores.language(); + // Harness scenes clear egui memory: keep the motion flag in it. + anim::set_motion(ctx, b.theme.motion); + } + + /// Saves the stores asked for, through the job pool. + fn pump_saves(&mut self) { + let Some(b) = self.booted.as_mut() else { + return; + }; + for save in b.stores.take_due_saves(Instant::now()) { + let store_id = save.store_id; + let pending = self + .jobs + .run(move |backend| backend.save_client_storage_store(store_id, &save.value)); + self.saves.push((store_id, pending)); + } + self.saves.retain_mut(|(id, pending)| match pending.poll() { + Poll::Waiting => true, + Poll::Ready(result) => { + b.stores.finish_save(id, &result); + false + } + Poll::Gone => false, + }); + } + + /// One frame. + pub fn frame(&mut self, ui: &mut Ui) { + let ctx = ui.ctx().clone(); + let now = ctx.input(|i| i.time); + self.last_time = now; + if let Some(job) = &mut self.boot_job { + match job.poll() { + Poll::Waiting => {} + Poll::Ready(result) => { + self.boot_job = None; + self.finish_boot(&ctx, result, now); + } + Poll::Gone => { + self.boot_job = None; + self.boot_error = Some("boot job panicked".into()); + } + } + } + self.sync_settings(&ctx); + self.pump_saves(); + if ctx.input(|i| i.key_pressed(egui::Key::Tab)) { + self.focus_visible = true; + } else if ctx.input(|i| i.pointer.any_pressed()) { + self.focus_visible = false; + } + let actions = shell::draw(self, ui, now); + self.apply(&ctx, actions, now); + } + + /// Applies what the frame's components asked for. + pub fn apply(&mut self, ctx: &Context, actions: Vec, now: f64) { + for action in actions { + self.apply_one(ctx, action); + } + if let Some(b) = self.booted.as_mut() { + let key = b.nav.panel_key(); + if key != b.panel.0 { + b.panel = (key, now); + } + } + } + + fn apply_one(&mut self, ctx: &Context, action: Action) { + let maximized = ctx.input(|i| i.viewport().maximized.unwrap_or(false)); + match action { + Action::Minimize => ctx.send_viewport_cmd(ViewportCommand::Minimized(true)), + Action::ToggleMaximize => ctx.send_viewport_cmd(ViewportCommand::Maximized(!maximized)), + Action::Close => ctx.send_viewport_cmd(ViewportCommand::Close), + Action::StartDrag => ctx.send_viewport_cmd(ViewportCommand::StartDrag), + Action::SetMaximizeButtonRect(rect) => { + // Hook for the window-shell job: answer HTMAXBUTTON over + // this rect so Windows 11 opens the Snap Layouts flyout. + self.maximize_button = rect; + } + // The workspace and add-flow ports own these. + Action::Refresh | Action::AddAccount | Action::ApplyUpdate => {} + Action::OpenSettings => { + if let Some(b) = self.booted.as_mut() { + b.nav.show_personas = false; + b.nav.show_settings = !b.nav.show_settings; + b.nav.bulk_edit = false; + } + } + Action::OpenPersonas => { + if let Some(b) = self.booted.as_mut() { + if b.stores.settings().personas_enabled { + b.nav.show_settings = false; + b.nav.bulk_edit = false; + b.nav.show_personas = true; + self.spike.show_tab("personas"); + } + } + } + Action::BulkEdit => { + if let Some(b) = self.booted.as_mut() { + b.nav.bulk_edit = !b.nav.bulk_edit; + } + } + Action::TabChange(tab) => { + let Some(b) = self.booted.as_mut() else { + return; + }; + b.nav.show_personas = false; + if !platforms::usable(&b.platforms, &tab, b.runtime_os) { + return; + } + if tab == b.nav.active_tab { + b.nav.show_settings = false; + self.spike.show_tab(&tab); + return; + } + b.nav.bulk_edit = false; + b.nav.show_settings = false; + b.nav.active_tab = tab.clone(); + self.spike.show_tab(&tab); + } + } + } +} + +fn animations_mode(mode: AnimationsMode) -> accshift_theme::AnimationsMode { + match mode { + AnimationsMode::System => accshift_theme::AnimationsMode::System, + AnimationsMode::On => accshift_theme::AnimationsMode::On, + AnimationsMode::Off => accshift_theme::AnimationsMode::Off, + } +} + +fn theme_key(stores: &ClientStores, registry: &ThemeRegistry, os: RuntimeOs) -> ThemeKey { + let settings = stores.settings(); + ThemeKey { + theme_id: settings.theme_id.clone(), + opacity: settings.background_opacity, + os, + animations: settings.animations, + custom_themes: registry.all_themes().len(), + } +} + +/// `useThemeApplication`: the theme of the settings, applied with the +/// backdrop the OS can give, and the motion preference. +fn compute_theme( + registry: &ThemeRegistry, + stores: &ClientStores, + os: RuntimeOs, + os_reduced_motion: bool, +) -> ThemeState { + let key = theme_key(stores, registry, os); + let theme = registry.theme(Some(&key.theme_id)); + let liquid_backdrop_active = theme.liquid && os == RuntimeOs::Windows; + // Linux compositors have no blur-behind; Liquid Glass waits for a + // wallpaper snapshot, which the window-shell job captures. + let wallpaper_ready = false; + let backdrop_available = os != RuntimeOs::Linux && (!liquid_backdrop_active || wallpaper_ready); + let application = theme_application(&theme, key.opacity as f64, backdrop_available); + let resolved = ResolvedTheme::new(&application); + let vars = Vars::new(&resolved); + let backdrop = window_backdrop(theme.glass, &theme.id, theme.liquid); + let motion = match accshift_theme::motion_attribute( + animations_mode(key.animations), + os_reduced_motion, + ) { + accshift_theme::MotionAttribute::Full => Motion::Full, + accshift_theme::MotionAttribute::Reduced => Motion::Reduced, + }; + ThemeState { + key, + theme, + application, + resolved, + vars, + backdrop, + motion, + } +} + +/// `navigator.languages`, most preferred first: the OS UI language. +fn preferred_languages() -> Vec { + ["LC_ALL", "LC_MESSAGES", "LANG"] + .iter() + .find_map(|var| std::env::var(var).ok().filter(|v| !v.is_empty())) + .map(|v| { + // `fr_FR.UTF-8` reads as `fr-FR`. + vec![v.split('.').next().unwrap_or("").replace('_', "-")] + }) + .unwrap_or_default() +} + +/// Chromium's `prefers-reduced-motion` on Windows: client-area animations off. +#[cfg(windows)] +fn os_prefers_reduced_motion() -> bool { + use windows_sys::Win32::UI::WindowsAndMessaging::{ + SystemParametersInfoW, SPI_GETCLIENTAREAANIMATION, + }; + let mut on: i32 = 1; + // SAFETY: SPI_GETCLIENTAREAANIMATION writes one BOOL into `on`. + let ok = unsafe { + SystemParametersInfoW( + SPI_GETCLIENTAREAANIMATION, + 0, + &mut on as *mut i32 as *mut _, + 0, + ) + }; + ok != 0 && on == 0 +} + +#[cfg(not(windows))] +fn os_prefers_reduced_motion() -> bool { + false +} + +#[cfg(test)] +mod tests; diff --git a/crates/accshift-egui/src/app/platforms.rs b/crates/accshift-egui/src/app/platforms.rs new file mode 100644 index 0000000..0558525 --- /dev/null +++ b/crates/accshift-egui/src/app/platforms.rs @@ -0,0 +1,222 @@ +//! What a platform looks like on screen: `PLATFORM_DEFS` of +//! `src/lib/platforms/registry.ts`, the chrome table merged with the shipped +//! descriptors (`crates/accshift-core/src/platforms/descriptor/descriptors/`) +//! and the user's own descriptors from the boot payload. + +use serde_json::Value; + +use crate::theme::Rgba; + +/// `RuntimeOs` of `src/lib/shared/platform.ts`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RuntimeOs { + Windows, + Linux, + Macos, + #[default] + Unknown, +} + +impl RuntimeOs { + /// `get_runtime_os` answers `windows`, `linux` or `macos`. + pub fn parse(os: &str) -> RuntimeOs { + match os { + "windows" => RuntimeOs::Windows, + "linux" => RuntimeOs::Linux, + "macos" => RuntimeOs::Macos, + _ => RuntimeOs::Unknown, + } + } + + pub fn as_str(self) -> &'static str { + match self { + RuntimeOs::Windows => "windows", + RuntimeOs::Linux => "linux", + RuntimeOs::Macos => "macos", + RuntimeOs::Unknown => "unknown", + } + } +} + +/// One entry of `PLATFORM_DEFS`, the parts the shell reads. +#[derive(Clone, Debug, PartialEq)] +pub struct PlatformDef { + pub id: String, + pub name: String, + pub accent: Rgba, + pub supported_os: Vec, + /// `capabilities.bulkEdit` is set. + pub bulk_edit: bool, + pub user_provided: bool, +} + +impl PlatformDef { + pub fn supports(&self, os: RuntimeOs) -> bool { + self.supported_os.contains(&os) + } +} + +use RuntimeOs::{Linux, Macos, Windows}; + +/// `PLATFORM_CHROME` in declaration order (the tab order), with the name and +/// systems the shipped descriptor gives for the five platforms that have one. +const SHIPPED: [(&str, &str, u32, &[RuntimeOs], bool); 9] = [ + ("steam", "Steam", 0x2563eb, &[Windows, Linux, Macos], true), + ("riot", "Riot Games", 0xef4444, &[Windows], false), + ( + "battle-net", + "Battle.net", + 0x38bdf8, + &[Windows, Macos], + false, + ), + ("ubisoft", "Ubisoft Connect", 0x0070ff, &[Windows], false), + ("roblox", "Roblox", 0xe1242a, &[Windows], false), + ("epic", "Epic Games Launcher", 0x0078f2, &[Windows], false), + ("gog", "GOG Galaxy", 0xa02de3, &[Windows], false), + ("jagex", "Jagex Launcher", 0xeab308, &[Windows], false), + ("discord", "Discord", 0x5865f2, &[Windows], false), +]; + +/// The shipped platforms, in tab order. +pub fn shipped() -> Vec { + SHIPPED + .iter() + .map(|(id, name, accent, os, bulk_edit)| PlatformDef { + id: (*id).to_string(), + name: (*name).to_string(), + accent: Rgba::hex(*accent, 1.0), + supported_os: os.to_vec(), + bulk_edit: *bulk_edit, + user_provided: false, + }) + .collect() +} + +/// `registerUserPlatforms`: the shipped list plus every user descriptor +/// (`UserPlatformReport.loaded`) whose id is not shipped. +pub fn with_user(descriptors: &[Value]) -> Vec { + let mut defs = shipped(); + for descriptor in descriptors { + let Some(id) = descriptor.get("id").and_then(Value::as_str) else { + continue; + }; + if defs.iter().any(|def| def.id == id) { + continue; + } + let name = descriptor + .get("name") + .and_then(Value::as_str) + .unwrap_or(id) + .to_string(); + let supported_os = descriptor + .get("os") + .and_then(Value::as_object) + .map(|os| os.keys().map(|key| RuntimeOs::parse(key)).collect()) + .unwrap_or_default(); + defs.push(PlatformDef { + id: id.to_string(), + name, + accent: accent_for(id), + supported_os, + bulk_edit: false, + user_provided: true, + }); + } + defs +} + +/// `accentFor`: `hsl( 62% 52%)` with the hue hashed from the id's UTF-16 +/// code units. +pub fn accent_for(id: &str) -> Rgba { + let mut hue: u32 = 0; + for unit in id.encode_utf16() { + hue = (hue * 31 + u32::from(unit)) % 360; + } + hsl(hue as f32, 0.62, 0.52) +} + +/// CSS `hsl()` to sRGB (CSS Color 4 `hslToRgb`). +fn hsl(hue: f32, s: f32, l: f32) -> Rgba { + let f = |n: f32| { + let k = (n + hue / 30.0) % 12.0; + let a = s * l.min(1.0 - l); + l - a * (k - 3.0).min(9.0 - k).clamp(-1.0, 1.0) + }; + Rgba::new(f(0.0) * 255.0, f(8.0) * 255.0, f(4.0) * 255.0, 1.0) +} + +/// `isPlatformUsable`. +pub fn usable(defs: &[PlatformDef], id: &str, os: RuntimeOs) -> bool { + defs.iter().any(|def| def.id == id && def.supports(os)) +} + +/// `getInitialActiveTab`. +pub fn initial_tab( + defs: &[PlatformDef], + enabled: &[String], + default_platform: &str, + os: RuntimeOs, +) -> String { + if enabled.iter().any(|id| id == default_platform) && usable(defs, default_platform, os) { + return default_platform.to_string(); + } + if let Some(id) = enabled.iter().find(|id| usable(defs, id, os)) { + return id.clone(); + } + if let Some(def) = defs.iter().find(|def| def.supports(os)) { + return def.id.clone(); + } + enabled + .first() + .cloned() + .or_else(|| defs.first().map(|def| def.id.clone())) + .unwrap_or_else(|| "steam".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn initial_tab_follows_the_ts_order() { + let defs = shipped(); + let enabled = vec!["riot".to_string(), "steam".to_string()]; + assert_eq!(initial_tab(&defs, &enabled, "steam", Windows), "steam"); + // Riot is Windows only: on Linux the default wins only if usable. + assert_eq!(initial_tab(&defs, &enabled, "riot", Linux), "steam"); + assert_eq!(initial_tab(&defs, &[], "steam", Linux), "steam"); + assert_eq!( + initial_tab(&defs, &["riot".into()], "riot", RuntimeOs::Unknown), + "riot" + ); + } + + #[test] + fn user_platforms_get_a_stable_hsl_accent() { + let defs = with_user(&[ + serde_json::json!({ "id": "foo", "name": "Foo", "os": { "linux": {} } }), + serde_json::json!({ "id": "steam", "name": "Not Steam" }), + ]); + assert_eq!(defs.len(), 10); + let foo = defs.last().unwrap(); + assert_eq!(foo.name, "Foo"); + assert_eq!(foo.supported_os, vec![Linux]); + // "foo": 102, then (102*31+111) % 360 = 33, then (33*31+111) % 360 = 54. + let expected = hsl(54.0, 0.62, 0.52); + assert_eq!(foo.accent, expected); + assert_eq!(defs[0].name, "Steam"); + } + + #[test] + fn hsl_matches_css() { + // hsl(0 100% 50%) is red, hsl(120 100% 25%) is #008000. + let red = hsl(0.0, 1.0, 0.5); + assert_eq!((red.r, red.g, red.b), (255.0, 0.0, 0.0)); + let green = hsl(120.0, 1.0, 0.25); + assert_eq!( + (green.r.round(), green.g.round(), green.b.round()), + (0.0, 128.0, 0.0) + ); + } +} diff --git a/crates/accshift-egui/src/app/shell.rs b/crates/accshift-egui/src/app/shell.rs new file mode 100644 index 0000000..ff96fef --- /dev/null +++ b/crates/accshift-egui/src/app/shell.rs @@ -0,0 +1,313 @@ +//! The DOM of `App.svelte`, drawn in its order: +//! +//! ```text +//! .app-frame appEntrance (fade, scale, rise, un-blur) +//! TitleBar macOS only, above the stage, page-entrance +//! .app-stage page-entrance, 50 ms late +//! .app-shell var(--bg) and var(--bg-image) +//! TitleBar Windows and Linux, page-entrance +//! {#key panelKey} the panel, page-entrance on every switch +//! overlays (none ported yet) +//! dialogs (none ported yet) +//! ``` +//! +//! Before the boot payload lands the frame is `opacity: 0`: nothing but the +//! stand-in desktop of a scene run is drawn. + +use egui::epaint::{self, ClippedShape}; +use egui::layers::ShapeIdx; +use egui::{vec2, Color32, Rect, Ui, UiBuilder}; + +use super::platforms::{PlatformDef, RuntimeOs}; +use super::{Action, AppModel}; +use crate::ui::anim::{Animation, Fill, Motion, Timing}; +use crate::ui::components::title_bar::{self, TitleBarProps}; +use crate::ui::paint::BoxStyle; + +/// `appEntrance 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards`. +const APP_ENTRANCE: Animation = + Animation::new(0.24, Timing::cubic_bezier(0.22, 1.0, 0.36, 1.0)).with_fill(Fill::Forwards); +/// `.titlebar { animation: page-entrance 180ms ease-out backwards }`. +const TITLEBAR_ENTRANCE: Animation = + Animation::new(0.18, Timing::EASE_OUT).with_fill(Fill::Backwards); +/// `.app-stage { animation: page-entrance 220ms ease-out 50ms backwards }`. +const STAGE_ENTRANCE: Animation = Animation::new(0.22, Timing::EASE_OUT) + .with_delay(0.05) + .with_fill(Fill::Backwards); +/// The last step of the boot cascade ends here, seconds after `boot-ready`. +pub const BOOT_CASCADE: f64 = 0.27; + +/// Copies of the frame drawn to approximate `filter: blur()`. +const BLUR_TAPS: usize = 12; + +/// `@keyframes appEntrance` at `local` seconds after boot. +#[derive(Clone, Copy, Debug, PartialEq)] +struct FrameStyle { + opacity: f32, + scale: f32, + rise: f32, + blur: f32, +} + +fn frame_style(local: f64, motion: Motion) -> FrameStyle { + let p = match (motion, APP_ENTRANCE.progress(local)) { + (Motion::Reduced, _) | (_, None) => 1.0, + (Motion::Full, Some(p)) => APP_ENTRANCE.timing.eval(p) as f32, + }; + FrameStyle { + opacity: p, + scale: 0.99 + 0.01 * p, + rise: 6.0 * (1.0 - p), + blur: 8.0 * (1.0 - p), + } +} + +/// `@keyframes page-entrance` (opacity 0 to 1, translateY 4px to 0): +/// `(opacity, rise)` at `local` seconds into `animation`. +fn page_entrance(animation: &Animation, local: f64, motion: Motion) -> (f32, f32) { + match (motion, animation.progress(local)) { + (Motion::Reduced, _) | (_, None) => (1.0, 0.0), + (Motion::Full, Some(p)) => { + let e = animation.timing.eval(p) as f32; + (e, 4.0 * (1.0 - e)) + } + } +} + +/// A child of `ui` over `rect`, shifted down by `rise` and faded to +/// `opacity`, like an element under `transform: translateY()` and `opacity`. +fn layer(ui: &mut Ui, rect: Rect, opacity: f32, rise: f32, add: impl FnOnce(&mut Ui) -> R) -> R { + let transform = egui::emath::TSTransform::from_translation(vec2(0.0, rise)); + ui.with_visual_transform(transform, |ui| { + let mut child = ui.new_child(UiBuilder::new().max_rect(rect)); + child.set_clip_rect(rect.intersect(ui.clip_rect())); + child.multiply_opacity(opacity); + add(&mut child) + }) + .inner +} + +pub(super) fn draw(app: &mut AppModel, ui: &mut Ui, now: f64) -> Vec { + let window = ui.max_rect(); + if let Some(desktop) = &app.desktop { + ui.painter().image( + desktop.id(), + window, + Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + Color32::WHITE, + ); + } + let Some(booted) = app.booted.as_ref() else { + return Vec::new(); + }; + let ctx = ui.ctx().clone(); + let motion = booted.theme.motion; + let local = now - booted.boot_at; + if motion == Motion::Full && local < BOOT_CASCADE { + ctx.request_repaint(); + } + let frame = frame_style(local, motion); + let os = booted.runtime_os; + let vars = booted.theme.vars.clone(); + let maximized = ctx.input(|i| i.viewport().maximized.unwrap_or(false)); + + let enabled: Vec = booted + .platforms + .iter() + .filter(|p| booted.stores.settings().enabled_platforms.contains(&p.id)) + .cloned() + .collect(); + let unavailable: Vec = enabled + .iter() + .filter(|p| !p.supports(os)) + .map(|p| p.id.clone()) + .collect(); + let nav = booted.nav.clone(); + let active_def = booted.platforms.iter().find(|p| p.id == nav.active_tab); + let active_usable = active_def.is_some_and(|p| p.supports(os)); + let props = TitleBarProps { + vars: &vars, + locale: booted.locale, + runtime_os: os, + hide_actions: false, + personas_visible: booted.stores.settings().personas_enabled, + personas_active: nav.show_personas, + active_tab: &nav.active_tab, + enabled_platforms: &enabled, + unavailable: &unavailable, + can_refresh: active_usable && !nav.show_personas, + can_add_account: active_usable && !nav.show_personas, + show_bulk_edit: active_def.is_some_and(|p| p.bulk_edit) + && !nav.show_settings + && !nav.show_personas + && active_usable, + bulk_edit_active: nav.bulk_edit, + show_settings: nav.show_settings, + update_cta: None, + maximized, + snap_hover: false, + focus_visible: app.focus_visible, + }; + let panel_since = booted.panel.1; + let show_spike = !nav.show_settings; + + let titlebar_style = page_entrance(&TITLEBAR_ENTRANCE, local, motion); + let stage_style = page_entrance(&STAGE_ENTRANCE, local, motion); + let panel_entrance = Animation::new(vars.motion_page_entrance, Timing::EASE_OUT); + let panel_style = page_entrance(&panel_entrance, now - panel_since, motion); + if motion == Motion::Full && now - panel_since < panel_entrance.end_time() { + ctx.request_repaint(); + } + + let bar_h = title_bar::height(os); + let (mac_bar, stage) = if os == RuntimeOs::Macos { + let (bar, rest) = window.split_top_bottom_at_y(window.top() + bar_h); + (Some(bar), rest) + } else { + (None, window) + }; + + let mut actions = Vec::new(); + let layer_id = ui.layer_id(); + let start = next_idx(&ctx, layer_id); + // `.app-frame`: scale about the centre, rise, fade. + let center = window.center().to_vec2(); + let frame_transform = egui::emath::TSTransform::new( + center * (1.0 - frame.scale) + vec2(0.0, frame.rise), + frame.scale, + ); + let spike = &mut app.spike; + ui.with_visual_transform(frame_transform, |ui| { + let mut frame_ui = ui.new_child(UiBuilder::new().max_rect(window)); + frame_ui.multiply_opacity(frame.opacity); + if let Some(bar) = mac_bar { + let (o, rise) = titlebar_style; + actions.extend(layer(&mut frame_ui, bar, o, rise, |ui| { + title_bar::title_bar(ui, bar, &props) + })); + } + let (stage_o, stage_rise) = stage_style; + layer(&mut frame_ui, stage, stage_o, stage_rise, |ui| { + // `background-color: var(--bg)`. A plain rect: `BoxStyle::paint` + // on a square full-window box darkens a translucent fill as if + // it were laid twice (reported to the foundation owner). + ui.painter().rect_filled(stage, 0.0, vars.bg.to_color32()); + if !vars.bg_image.is_empty() { + BoxStyle { + images: vars.bg_image.clone(), + ..BoxStyle::default() + } + .paint(ui.painter(), stage); + } + if mac_bar.is_none() { + let (bar, _) = stage.split_top_bottom_at_y(stage.top() + bar_h); + let (o, rise) = titlebar_style; + actions.extend(layer(ui, bar, o, rise, |ui| { + title_bar::title_bar(ui, bar, &props) + })); + } + // The panel slot. The spike lays its page out below its own + // title bar height, so it gets the whole stage. Settings are not + // ported: their slot stays empty. + let (o, rise) = panel_style; + if show_spike { + layer(ui, stage, o, rise, |ui| spike.ui(ui)); + } + }); + }); + let end = next_idx(&ctx, layer_id); + if motion == Motion::Full && frame.blur > 0.25 { + blur_range(&ctx, layer_id, start, end, frame.blur); + } + actions +} + +fn next_idx(ctx: &egui::Context, layer: egui::LayerId) -> ShapeIdx { + ctx.graphics(|g| g.get(layer).map_or(ShapeIdx(0), |l| l.next_idx())) +} + +/// `filter: blur(px)` on the shapes painted in `start..end`, +/// approximated by translated copies: copy `k` drawn at opacity `1/(k+1)` +/// over the previous ones keeps every copy at the same weight. +fn blur_range( + ctx: &egui::Context, + layer: egui::LayerId, + start: ShapeIdx, + end: ShapeIdx, + radius: f32, +) { + let shapes: Vec = ctx.graphics(|g| { + g.get(layer) + .map(|list| { + list.all_entries() + .skip(start.0) + .take(end.0.saturating_sub(start.0)) + .cloned() + .collect() + }) + .unwrap_or_default() + }); + if shapes.is_empty() { + return; + } + ctx.graphics_mut(|g| { + let list = g.entry(layer); + for k in 1..=BLUR_TAPS { + // Two rings: 0.6 and 1.4 sigma, six taps each, offset by 30 deg. + let ring = if k % 2 == 0 { 1.4 } else { 0.6 }; + let angle = (k as f32) * std::f32::consts::TAU / 6.0 + + if k % 2 == 0 { + std::f32::consts::FRAC_PI_6 + } else { + 0.0 + }; + let offset = vec2(angle.cos(), angle.sin()) * radius * ring; + let alpha = 1.0 / (k as f32 + 1.0); + for ClippedShape { clip_rect, shape } in &shapes { + let mut shape = shape.clone(); + shape.translate(offset); + epaint::shape_transform::adjust_colors(&mut shape, move |c| { + *c = c.gamma_multiply(alpha); + }); + list.add(*clip_rect, shape); + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn boot_cascade_matches_the_css() { + let start = frame_style(0.0, Motion::Full); + assert_eq!(start.opacity, 0.0); + assert_eq!(start.blur, 8.0); + assert!((start.scale - 0.99).abs() < 1e-6); + let end = frame_style(0.24, Motion::Full); + assert_eq!( + end, + FrameStyle { + opacity: 1.0, + scale: 1.0, + rise: 0.0, + blur: 0.0 + } + ); + assert_eq!(frame_style(0.0, Motion::Reduced).opacity, 1.0); + // The stage waits 50 ms at its first keyframe (fill backwards). + assert_eq!( + page_entrance(&STAGE_ENTRANCE, 0.02, Motion::Full), + (0.0, 4.0) + ); + assert_eq!( + page_entrance(&STAGE_ENTRANCE, BOOT_CASCADE, Motion::Full), + (1.0, 0.0) + ); + assert_eq!( + page_entrance(&TITLEBAR_ENTRANCE, 0.0, Motion::Reduced), + (1.0, 0.0) + ); + } +} diff --git a/crates/accshift-egui/src/app/tests.rs b/crates/accshift-egui/src/app/tests.rs new file mode 100644 index 0000000..be6d727 --- /dev/null +++ b/crates/accshift-egui/src/app/tests.rs @@ -0,0 +1,159 @@ +//! `AppModel` on the real backend, over a scratch copy of the user's app +//! folders: the real snapshot is read, never written. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::SystemTime; + +use accshift_app::real::{EguiAppContext, RealBackend}; +use accshift_core::AppContext; + +use super::*; + +struct Scratch { + root: PathBuf, +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +impl AppContext for Scratch { + fn app_config_dir(&self) -> Result { + Ok(self.root.join("config")) + } + fn app_data_dir(&self) -> Result { + Ok(self.root.join("config")) + } + fn app_local_data_dir(&self) -> Result { + Ok(self.root.join("local")) + } + fn app_cache_dir(&self) -> Result { + Ok(self.root.join("local")) + } +} + +/// Folders a copy skips: the WebView profile and the logs are not app state. +const SKIP: [&str; 2] = ["EBWebView", "logs"]; + +/// Size and modification time of every app-state file under `dir`. +fn fingerprint(dir: &Path, out: &mut BTreeMap)>) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + if SKIP.iter().any(|skip| entry.file_name() == *skip) { + continue; + } + let path = entry.path(); + let Ok(meta) = entry.metadata() else { continue }; + if meta.is_dir() { + fingerprint(&path, out); + } else { + out.insert(path, (meta.len(), meta.modified().ok())); + } + } +} + +/// Copies `from` into `to`, reading only. +fn copy_tree(from: &Path, to: &Path) { + let Ok(entries) = std::fs::read_dir(from) else { + return; + }; + std::fs::create_dir_all(to).unwrap(); + for entry in entries.flatten() { + let name = entry.file_name(); + if SKIP.iter().any(|skip| name == *skip) { + continue; + } + let path = entry.path(); + let Ok(meta) = entry.metadata() else { continue }; + if meta.is_dir() { + copy_tree(&path, &to.join(&name)); + } else { + let _ = std::fs::write(to.join(&name), std::fs::read(&path).unwrap_or_default()); + } + } +} + +#[test] +fn real_backend_boots_on_a_copy_of_the_real_snapshot() { + let real = EguiAppContext::new().unwrap(); + let real_dirs = [ + real.app_config_dir().unwrap(), + real.app_local_data_dir().unwrap(), + ]; + let mut before = BTreeMap::new(); + for dir in &real_dirs { + fingerprint(dir, &mut before); + } + + let root = std::env::temp_dir().join(format!("accshift-egui-app-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let scratch = Arc::new(Scratch { root }); + copy_tree(&real_dirs[0], &scratch.root.join("config")); + copy_tree(&real_dirs[1], &scratch.root.join("local")); + + let ctx = egui::Context::default(); + let backend: Arc = Arc::new(RealBackend::with_context(scratch.clone())); + let mut app = AppModel::with_backend(&ctx, backend, None, Clock::wall()); + app.wait_boot(&ctx).expect("boot"); + let booted = app.booted().expect("booted"); + assert_ne!(booted.runtime_os, RuntimeOs::Unknown); + // The theme of the stored settings resolved to real values. + assert_eq!(booted.theme.vars.fg.a, 1.0); + assert!(booted + .stores + .settings() + .enabled_platforms + .iter() + .all(|id| booted.platforms.iter().any(|p| &p.id == id))); + assert!( + platforms::usable(&booted.platforms, &booted.nav.active_tab, booted.runtime_os) + || booted.nav.active_tab.is_empty() + || booted.stores.settings().enabled_platforms.is_empty(), + "active tab {} is usable", + booted.nav.active_tab + ); + drop(app); + + let mut after = BTreeMap::new(); + for dir in &real_dirs { + fingerprint(dir, &mut after); + } + assert_eq!(before, after, "the real app folders were not touched"); +} + +#[test] +fn seed_keys_patch_the_scenario_stores() { + let setup = MockSetup { + theme: Some("dark".into()), + locale: Some("fr".into()), + seed: Some(serde_json::json!({ + "SETTINGS": { "personasEnabled": false }, + "VIEW_MODE": "list", + "client.folders": [], + })), + ..MockSetup::default() + }; + let mut stores = BTreeMap::new(); + stores.insert( + "client.settings".to_string(), + serde_json::json!({ "themeId": "glass-dark", "enabledPlatforms": ["steam"] }), + ); + setup.patch_stores(&mut stores); + assert_eq!( + stores["client.settings"], + serde_json::json!({ + "themeId": "dark", + "language": "fr", + "enabledPlatforms": ["steam"], + "personasEnabled": false, + }) + ); + assert_eq!(stores["client.view-mode"], "list"); + assert_eq!(stores["client.folders"], serde_json::json!([])); +} diff --git a/crates/accshift-egui/src/app/vars.rs b/crates/accshift-egui/src/app/vars.rs new file mode 100644 index 0000000..a6c2460 --- /dev/null +++ b/crates/accshift-egui/src/app/vars.rs @@ -0,0 +1,153 @@ +//! Typed style values, filled from the resolved theme once per theme change. +//! +//! One field per CSS custom property a ported component reads, named after +//! the property (`--bg-card` is `bg_card`). Components never look a variable +//! up by name at draw time. A component port adds the fields it needs here, +//! with the property in its doc line, and fills them in [`Vars::new`]. + +use accshift_theme::ResolvedTheme; + +use crate::theme::Rgba; +use crate::ui::css; +use crate::ui::paint::Gradient; + +#[derive(Clone, Debug, PartialEq)] +pub struct Vars { + /// `--bg`: the window fill `.app-shell` paints (translucent on glass). + pub bg: Rgba, + /// `--bg-solid`: `--bg` at full opacity (macOS title bar). + pub bg_solid: Rgba, + /// `--bg-image`: the theme's gradients over `--bg`, first on top. Empty + /// for `none`. + pub bg_image: Vec, + /// `--bg-card`. + pub bg_card: Rgba, + /// `--bg-muted`. + pub bg_muted: Rgba, + /// `--bg-elevated`. + pub bg_elevated: Rgba, + /// `--bg-overlay`. + pub bg_overlay: Rgba, + /// `--fg`. + pub fg: Rgba, + /// `--fg-muted`. + pub fg_muted: Rgba, + /// `--fg-subtle`. + pub fg_subtle: Rgba, + /// `--border`. + pub border: Rgba, + /// `--border-width`, CSS px. + pub border_width: f32, + /// `--border-style` is `none`: borders drawn with it vanish. + pub border_style_none: bool, + /// `--danger`. + pub danger: Rgba, + /// `--accent`. + pub accent: Rgba, + /// `--accent-text`. + pub accent_text: Rgba, + /// `--motion-page-entrance`, seconds. + pub motion_page_entrance: f64, +} + +/// `accshift_theme::Rgba` (channels 0 to 1) in the painter's colour type +/// (channels 0 to 255, alpha 0 to 1). +pub fn rgba(c: accshift_theme::Rgba) -> Rgba { + Rgba::new(c.r * 255.0, c.g * 255.0, c.b * 255.0, c.a) +} + +impl Vars { + pub fn new(theme: &ResolvedTheme) -> Vars { + let color = |name: &str| theme.color(name).map(rgba).unwrap_or(Rgba::TRANSPARENT); + let bg_image = theme + .substitute("--bg-image") + .map(|value| { + let value = value.trim(); + if value.is_empty() || value == "none" { + return Vec::new(); + } + css::split_top(value, ',') + .into_iter() + .filter_map(css::gradient) + .collect() + }) + .unwrap_or_default(); + Vars { + bg: color("--bg"), + bg_solid: color("--bg-solid"), + bg_image, + bg_card: color("--bg-card"), + bg_muted: color("--bg-muted"), + bg_elevated: color("--bg-elevated"), + bg_overlay: color("--bg-overlay"), + fg: color("--fg"), + fg_muted: color("--fg-muted"), + fg_subtle: color("--fg-subtle"), + border: color("--border"), + border_width: theme.length_px("--border-width").unwrap_or(1.0), + border_style_none: theme + .substitute("--border-style") + .is_some_and(|style| style.trim() == "none"), + danger: color("--danger"), + accent: color("--accent"), + accent_text: color("--accent-text"), + motion_page_entrance: f64::from( + theme.duration_ms("--motion-page-entrance").unwrap_or(200.0), + ) / 1000.0, + } + } + + /// `var(--border-width) var(--border-style) var(--border)` as a width, + /// zero when the style is `none`. + pub fn border_px(&self) -> f32 { + if self.border_style_none { + 0.0 + } else { + self.border_width + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use accshift_theme::{theme_application, ThemeRegistry}; + + fn vars(id: &str, opacity: f64, backdrop: bool) -> Vars { + let registry = ThemeRegistry::new(); + let theme = registry.theme(Some(id)); + Vars::new(&ResolvedTheme::new(&theme_application( + &theme, opacity, backdrop, + ))) + } + + #[test] + fn glass_dark_fills_the_shell_values() { + let v = vars("glass-dark", 85.0, true); + // Same numbers the theme crate's doc example checks. + let c = v.bg_card; + let alpha = (c.a * 255.0).round(); + assert_eq!( + [c.r.round(), c.g.round(), c.b.round(), alpha], + [19.0, 19.0, 24.0, 166.0] + ); + assert!( + v.bg.a > 0.0 && v.bg.a < 1.0, + "glass window fill is translucent" + ); + assert_eq!(v.bg_solid.a, 1.0); + assert!(v.bg_image.is_empty()); + assert_eq!(v.border_px(), 1.0); + assert!((v.motion_page_entrance - 0.2).abs() < 1e-9); + } + + #[test] + fn dark_is_opaque() { + let v = vars("dark", 100.0, true); + assert_eq!(v.bg.a, 1.0); + assert_eq!( + v.fg.to_color32().to_srgba_unmultiplied(), + [250, 250, 250, 255] + ); + } +} diff --git a/crates/accshift-egui/src/harness/mod.rs b/crates/accshift-egui/src/harness/mod.rs index 2e43c25..174817b 100644 --- a/crates/accshift-egui/src/harness/mod.rs +++ b/crates/accshift-egui/src/harness/mod.rs @@ -65,6 +65,8 @@ pub enum Step { #[derive(Clone, Debug)] pub struct Scene { + /// The scene file. + pub path: PathBuf, pub id: String, pub scenario: String, pub os: Option, @@ -78,6 +80,12 @@ pub struct Scene { } impl Scene { + /// `parity/data`, next to the `parity/scenes` folder the scene sits in. + pub fn data_dir(&self) -> PathBuf { + let scenes = self.path.parent().unwrap_or(Path::new(".")); + scenes.parent().unwrap_or(Path::new(".")).join("data") + } + /// The fragment's file stem: which test screen draws it. pub fn fragment_name(&self) -> Option { let (path, _) = self.html.as_ref()?; @@ -166,6 +174,7 @@ pub fn load(path: &Path) -> Result { .map_err(|e| format!("{}: {e}", path.display()))? .unwrap_or_default(); Ok(Scene { + path: path.to_path_buf(), id, scenario: s("scenario").unwrap_or_else(|| "demo".into()), os: s("os"), diff --git a/crates/accshift-egui/src/lib.rs b/crates/accshift-egui/src/lib.rs index aace9cd..3590570 100644 --- a/crates/accshift-egui/src/lib.rs +++ b/crates/accshift-egui/src/lib.rs @@ -1,5 +1,6 @@ //! The accshift UI in egui. See `README.md` next to this crate. +pub mod app; pub mod avatars; pub mod backdrop; pub mod bench; @@ -12,15 +13,15 @@ pub mod soft; pub mod theme; pub mod ui; -pub use screens::main_spike::{AccshiftApp, Options}; +pub use app::{Action, AppModel}; pub use screens::Screen; #[cfg(all(feature = "software", feature = "glow"))] compile_error!("pick one renderer: `software` (default) or `glow` with --no-default-features"); /// What both runners drive: the current screen plus the optional scene run. -/// The window is cleared to transparent so the OS acrylic shows through the -/// 55 % window fill, exactly like the Tauri window. +/// The window is cleared to transparent so the OS backdrop shows through the +/// theme's window fill, like the Tauri window. pub struct Shell { pub screen: Screen, pub scenes: Option, diff --git a/crates/accshift-egui/src/main.rs b/crates/accshift-egui/src/main.rs index 8f70471..cd40432 100644 --- a/crates/accshift-egui/src/main.rs +++ b/crates/accshift-egui/src/main.rs @@ -1,6 +1,6 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use accshift_egui::{bench, harness, AccshiftApp, Options, Screen, Shell}; +use accshift_egui::{bench, harness, AppModel, Screen, Shell}; use raw_window_handle::HasWindowHandle; fn main() { @@ -100,7 +100,7 @@ fn main() { }); let mut viewport = egui::ViewportBuilder::default() - .with_title("Accshift (egui spike)") + .with_title("Accshift") .with_app_id("accshift-egui") .with_inner_size([860.0, 440.0]) .with_decorations(false) @@ -139,12 +139,14 @@ fn main() { accshift_egui::screens::main_spike::install_fonts(ctx); Screen::Blank } else { - Screen::Main(Box::new(AccshiftApp::new( - ctx, - Options { - desktop_backdrop: false, - }, - ))) + // The real app folders, through accshift-core. + match AppModel::real(ctx) { + Ok(app) => Screen::App(Box::new(app)), + Err(err) => { + eprintln!("accshift: {err}"); + std::process::exit(1); + } + } }; Shell { screen, scenes } }; diff --git a/crates/accshift-egui/src/screens/main_spike.rs b/crates/accshift-egui/src/screens/main_spike.rs index 21f59e9..145ad77 100644 --- a/crates/accshift-egui/src/screens/main_spike.rs +++ b/crates/accshift-egui/src/screens/main_spike.rs @@ -49,6 +49,9 @@ pub struct Options { /// Paint the recorder's stand-in desktop under the window, for offscreen /// captures where no OS backdrop exists. pub desktop_backdrop: bool, + /// Drawn in the app shell's panel slot: the shell paints the window fill + /// and the title bar, the spike only its page. + pub embedded: bool, } struct AvatarTex { @@ -73,6 +76,7 @@ pub struct AccshiftApp { avatar_rx: Option>, gradients: HashMap, backdrop: Option, + embedded: bool, frame_index: u64, ready_marked: bool, all_avatars_drawn_at: Option, @@ -105,12 +109,31 @@ impl AccshiftApp { avatar_rx, gradients: HashMap::new(), backdrop, + embedded: options.embedded, frame_index: 0, ready_marked: false, all_avatars_drawn_at: None, } } + /// Shows `id` (`personas` or a platform id) like a tab click, for the app + /// shell's navigation. Unknown ids are ignored. + pub fn show_tab(&mut self, id: &str) { + let tab = match id { + "personas" => Tab::Personas, + "steam" => Tab::Platform(Platform::Steam), + "riot" => Tab::Platform(Platform::Riot), + "roblox" => Tab::Platform(Platform::Roblox), + _ => return, + }; + if self.tab != tab { + self.tab = tab; + self.folder = None; + self.search.clear(); + self.armed = None; + } + } + /// Every avatar texture has been uploaded (the offscreen captures wait /// for this before taking a frame). pub fn avatars_ready(&self) -> bool { @@ -150,13 +173,14 @@ impl AccshiftApp { Color32::WHITE, ); } - painter.rect_filled( - full, - CornerRadius::ZERO, - self.theme.window_fill.to_color32(), - ); - - self.title_bar(ui, full, now); + if !self.embedded { + painter.rect_filled( + full, + CornerRadius::ZERO, + self.theme.window_fill.to_color32(), + ); + self.title_bar(ui, full, now); + } match self.tab { Tab::Personas => self.personas_page(ui, full), Tab::Platform(platform) => self.platform_page(ui, full, platform, now), diff --git a/crates/accshift-egui/src/screens/mod.rs b/crates/accshift-egui/src/screens/mod.rs index 506209c..3d820e8 100644 --- a/crates/accshift-egui/src/screens/mod.rs +++ b/crates/accshift-egui/src/screens/mod.rs @@ -3,51 +3,47 @@ pub mod main_spike; pub mod primitives; +use std::path::Path; + +use crate::app::{AppModel, MockSetup}; use crate::harness::Scene; pub enum Screen { /// Nothing yet: a scene run before its first scene is set up. Blank, - Main(Box), + /// The app: shell, title bar and the panel slot. + App(Box), Primitives(primitives::Primitives), } impl Screen { - /// The screen a scene asks for, or why the egui side cannot draw it yet. + /// The screen a scene asks for: its `html` fragment, or the app on the + /// mock with the scene's scenario, OS, theme, locale and seed. pub fn for_scene(ctx: &egui::Context, scene: &Scene) -> Result { if let Some((_, html)) = &scene.html { return Ok(Screen::Primitives(primitives::Primitives::parse(html)?)); } - // The spike only knows its own demo data and Glass Dark; the mock - // data in parity/data/ is wired by a later phase. - let supported = scene.scenario == "demo" - && scene.theme.as_deref().is_none_or(|t| t == "glass-dark") - && scene.locale.as_deref().is_none_or(|l| l == "en") - && scene.os.as_deref().is_none_or(|o| o == "windows") - && scene.seed.is_none(); - if !supported { - return Err(format!( - "the spike screen only draws scenario demo, theme glass-dark, locale en, os windows, no seed (got scenario {}, theme {:?}, locale {:?}, os {:?}, seed {})", - scene.scenario, - scene.theme, - scene.locale, - scene.os, - if scene.seed.is_some() { "set" } else { "none" }, - )); - } - Ok(Screen::Main(Box::new(main_spike::AccshiftApp::new( - ctx, - main_spike::Options { - desktop_backdrop: true, - }, - )))) + let data_dir = scene.data_dir(); + let data_dir = std::path::absolute(&data_dir).unwrap_or(data_dir); + // parity/data sits in crates/accshift-egui: the repo is four up. + let repo_root = data_dir.ancestors().nth(4).map(Path::to_path_buf); + let setup = MockSetup { + scenario: scene.scenario.clone(), + os: scene.os.clone(), + theme: scene.theme.clone(), + locale: scene.locale.clone(), + seed: scene.seed.clone(), + data_dir, + repo_root, + }; + Ok(Screen::App(Box::new(AppModel::mock(ctx, setup)?))) } - /// Ready to be photographed: every asset uploaded. + /// Ready to be photographed: booted, entrance over, every asset uploaded. pub fn ready(&self) -> bool { match self { Screen::Blank => false, - Screen::Main(app) => app.avatars_ready(), + Screen::App(app) => app.ready(), Screen::Primitives(p) => p.ready(), } } @@ -55,7 +51,7 @@ impl Screen { pub fn ui(&mut self, ui: &mut egui::Ui) { match self { Screen::Blank => {} - Screen::Main(app) => app.ui(ui), + Screen::App(app) => app.frame(ui), Screen::Primitives(p) => p.ui(ui), } } diff --git a/crates/accshift-egui/src/ui/components/icon.rs b/crates/accshift-egui/src/ui/components/icon.rs new file mode 100644 index 0000000..cb7b560 --- /dev/null +++ b/crates/accshift-egui/src/ui/components/icon.rs @@ -0,0 +1,128 @@ +//! Inline SVG icons, rasterized where Chromium puts them. +//! +//! Chromium pixel-snaps the box of an inline `` root: its origin is +//! rounded to the nearest device pixel, then the vector drawing is laid on +//! that grid. A 12 px caption icon centred at y = 11.5 CSS px lands on row 12 +//! at 100 % and stays crisp; drawing it at the fractional position would blur +//! every 1 px stroke over two rows. Each icon is rasterized once per device +//! size and placed on the device grid. Textures live in the egui context +//! memory, keyed by SVG and device size, so components stay plain functions. + +use std::collections::HashMap; +use std::sync::Arc; + +use egui::{ + pos2, Color32, ColorImage, Context, Id, Painter, Pos2, Rect, TextureHandle, TextureOptions, +}; +use resvg::{tiny_skia, usvg}; + +use crate::theme::Rgba; + +type Key = (usize, u32); + +#[derive(Clone, Default)] +struct Cache(Arc>>); + +fn cache(ctx: &Context) -> Cache { + ctx.data_mut(|d| { + d.get_temp_mut_or_default::(Id::new("accshift::icon-cache")) + .clone() + }) +} + +/// Where Chromium paints an SVG root whose layout box starts at `min` +/// (points): the nearest device pixel, back in points. +pub fn snapped_origin(min: Pos2, ppp: f32) -> Pos2 { + pos2((min.x * ppp).round() / ppp, (min.y * ppp).round() / ppp) +} + +/// The SVG `svg` (white `currentColor`) drawn into `rect` (points) in +/// `color`, scaled from its `viewBox` like an `` element. +/// `scale` multiplies the drawing around the rect centre (`transform: +/// scale()` on the icon). +pub fn paint(painter: &Painter, svg: &'static [u8], rect: Rect, color: Rgba, scale: f32) { + if color.a <= 0.0 { + return; + } + let ctx = painter.ctx(); + let ppp = ctx.pixels_per_point(); + let rect = if (scale - 1.0).abs() > 1e-4 { + Rect::from_center_size(rect.center(), rect.size() * scale) + } else { + rect + }; + let size = rect.width() * ppp; + let key: Key = (svg.as_ptr() as usize, (size * 64.0).round() as u32); + let texture = { + let cache = cache(ctx); + let mut map = cache.0.lock().unwrap_or_else(|e| e.into_inner()); + map.entry(key) + .or_insert_with(|| { + // LINEAR on the exact device grid samples texel centres, so + // it reads every texel once. The vendored software + // renderer's NEAREST takes `floor(u * w - 0.5)`, which + // skips or repeats a texel there on float noise. + ctx.load_texture( + format!("icon-{key:?}"), + raster(svg, size), + TextureOptions::LINEAR, + ) + }) + .clone() + }; + let [w, h] = texture.size(); + let dest = Rect::from_min_size( + snapped_origin(rect.min, ppp), + egui::vec2(w as f32 / ppp, h as f32 / ppp), + ); + painter.image( + texture.id(), + dest, + Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), + color.to_color32(), + ); +} + +/// `svg` scaled to `size` device pixels, in a canvas rounded up to whole +/// pixels. +fn raster(svg: &[u8], size: f32) -> ColorImage { + let empty = || ColorImage::filled([1, 1], Color32::TRANSPARENT); + let Ok(tree) = usvg::Tree::from_data(svg, &usvg::Options::default()) else { + return empty(); + }; + let view = tree.size(); + let canvas = size.ceil().max(1.0) as u32; + let Some(mut pixmap) = tiny_skia::Pixmap::new(canvas, canvas) else { + return empty(); + }; + let sx = size / view.width(); + let sy = size / view.height(); + let transform = tiny_skia::Transform::from_row(sx, 0.0, 0.0, sy, 0.0, 0.0); + resvg::render(&tree, transform, &mut pixmap.as_mut()); + // tiny-skia pixels are premultiplied RGBA, like egui's. + let pixels = pixmap + .pixels() + .iter() + .map(|p| Color32::from_rgba_premultiplied(p.red(), p.green(), p.blue(), p.alpha())) + .collect(); + ColorImage::new([canvas as usize, canvas as usize], pixels) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn svg_roots_snap_to_the_device_grid() { + // The maximize glyph centred in a 35 px caption button: y = 11.5. + assert_eq!(snapped_origin(pos2(785.0, 11.5), 1.0), pos2(785.0, 12.0)); + // At 150 %: 11.5 CSS px is 17.25 device px, row 17. + let at = snapped_origin(pos2(785.0, 11.5), 1.5); + assert!((at.y * 1.5 - 17.0).abs() < 1e-4); + // A 1 px stroke on a half-pixel line of a 12 px icon covers two rows + // by half: the raster itself is exact. + let img = raster(crate::icons::MINIMIZE, 12.0); + let row = |y: usize| img.pixels[y * img.size[0] + 6].a(); + assert!(row(5) > 100 && row(6) > 100 && row(4) < 8 && row(7) < 8); + } +} diff --git a/crates/accshift-egui/src/ui/components/mod.rs b/crates/accshift-egui/src/ui/components/mod.rs new file mode 100644 index 0000000..663aeb2 --- /dev/null +++ b/crates/accshift-egui/src/ui/components/mod.rs @@ -0,0 +1,16 @@ +//! One module per Svelte component, same name in snake_case +//! (`TitleBar.svelte` is `title_bar.rs`). +//! +//! A component is a plain function `fn x(ui, rect, &props) -> Vec` +//! (or a struct with `show`): it reads its props and the [`Vars`], keeps its +//! transient state (hover, transitions, focus) in egui memory under its own +//! ids, never touches the app state, and returns what the user asked for as +//! [`Action`]s, which the [`AppModel`] applies after the frame is drawn. +//! +//! [`Vars`]: crate::app::vars::Vars +//! [`Action`]: crate::app::Action +//! [`AppModel`]: crate::app::AppModel + +pub mod icon; +pub mod platform_icons; +pub mod title_bar; diff --git a/crates/accshift-egui/src/ui/components/platform_icons.rs b/crates/accshift-egui/src/ui/components/platform_icons.rs new file mode 100644 index 0000000..5d34bcc --- /dev/null +++ b/crates/accshift-egui/src/ui/components/platform_icons.rs @@ -0,0 +1,42 @@ +//! The platform logos of the title bar tabs, generated from +//! `src/lib/shared/platformIcons.ts` (`PLATFORM_ICON_PATHS`, +//! `PERSONAS_ICON_PATH`, `PERSONAS_ACCENT`). Each is a fill-style 24x24 path; +//! `currentColor` is spelled white so a tint gives the final colour. + +/// `PERSONAS_ACCENT`. +pub const PERSONAS_ACCENT: u32 = 0xa855f7; + +macro_rules! fill_path { + ($d:literal) => { + concat!( + "" + ) + .as_bytes() + }; +} + +/// `PERSONAS_ICON_PATH`. +pub const PERSONAS: &[u8] = fill_path!("M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"); + +/// `PLATFORM_ICON_PATHS`, in declaration order. +pub const PLATFORMS: [(&str, &[u8]); 9] = [ + ("steam", fill_path!("M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658c.545-.371 1.203-.59 1.912-.59.063 0 .125.004.188.006l2.861-4.142V8.91c0-2.495 2.028-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.076 2.911c0 .052.004.105.004.159 0 1.875-1.515 3.396-3.39 3.396-1.635 0-3.016-1.173-3.331-2.727L.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999-5.373 11.999-12S18.605 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.455-.397.957-1.497 1.41-2.454 1.012H7.54zm11.415-9.303c0-1.662-1.353-3.015-3.015-3.015-1.665 0-3.015 1.353-3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015-1.35 3.015-3.015zm-5.273-.005c0-1.252 1.013-2.266 2.265-2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251-1.017 2.265-2.266 2.265-1.253 0-2.265-1.014-2.265-2.265z")), + ("riot", fill_path!("M13.458.86 0 7.093l3.353 12.761 2.552-.313-.701-8.024.838-.373 1.447 8.202 4.361-.535-.775-8.857.83-.37 1.591 9.025 4.412-.542-.849-9.708.84-.374 1.74 9.87L24 17.318V3.5Zm.316 19.356.222 1.256L24 23.14v-4.18l-10.22 1.256Z")), + ("battle-net", fill_path!("M18.94 8.296C15.9 6.892 11.534 6 7.426 6.332c.206-1.36.714-2.308 1.548-2.508 1.148-.275 2.4.48 3.594 1.854.782.102 1.71.28 2.355.429C12.747 2.013 9.828-.282 7.607.565c-1.688.644-2.553 2.97-2.448 6.094-2.2.468-3.915 1.3-5.013 2.495-.056.065-.181.227-.137.305.034.058.146-.008.194-.04 1.274-.89 2.904-1.373 5.027-1.676.303 3.333 1.713 7.56 4.055 10.952-1.28.502-2.356.536-2.946-.087-.812-.856-.784-2.318-.19-4.04a26.764 26.764 0 0 1-.807-2.254c-2.459 3.934-2.986 7.61-1.143 9.11 1.402 1.14 3.847.725 6.502-.926 1.505 1.672 3.083 2.74 4.667 3.094.084.015.287.043.332-.034.034-.06-.08-.124-.131-.149-1.408-.657-2.64-1.828-3.964-3.515 2.735-1.929 5.691-5.263 7.457-8.988 1.076.86 1.64 1.773 1.398 2.595-.336 1.131-1.615 1.84-3.403 2.185a27.697 27.697 0 0 1-1.548 1.826c4.634.16 8.08-1.22 8.458-3.565.286-1.786-1.295-3.696-4.053-5.17.696-2.139.832-4.04.346-5.588-.029-.08-.106-.27-.196-.27-.068 0-.067.13-.063.187.135 1.547-.263 3.2-1.062 5.19zm-8.533 9.869c-1.96-3.145-3.09-6.849-3.082-10.594 3.702-.124 7.474.748 10.714 2.627-1.743 3.269-4.385 6.1-7.633 7.966h.001z")), + ("ubisoft", fill_path!("M23.561 11.988C23.301-.304 6.954-4.89.656 6.634c.282.206.661.477.943.672a11.747 11.747 0 00-.976 3.067 11.885 11.885 0 00-.184 2.071C.439 18.818 5.621 24 12.005 24c6.385 0 11.556-5.17 11.556-11.556v-.455zm-20.27 2.06c-.152 1.246-.054 1.636-.054 1.788l-.282.098c-.108-.206-.37-.932-.488-1.908C2.163 10.308 4.7 6.96 8.57 6.33c3.544-.52 6.937 1.68 7.728 4.758l-.282.098c-.087-.087-.228-.336-.77-.878-4.281-4.281-11.002-2.32-11.956 3.74zm11.002 2.081a3.145 3.145 0 01-2.59 1.355 3.15 3.15 0 01-3.155-3.155 3.159 3.159 0 012.927-3.144c1.018-.043 1.972.51 2.416 1.398a2.58 2.58 0 01-.455 2.95c.293.205.575.4.856.595zm6.58.12c-1.669 3.782-5.106 5.766-8.77 5.712-7.034-.347-9.083-8.466-4.38-11.393l.207.206c-.076.108-.358.325-.791 1.182-.51 1.041-.672 2.081-.607 2.732.369 5.67 8.314 6.83 11.045 1.214C21.057 8.217 11.822.401 3.626 6.374l-.184-.184C5.599 2.808 9.816 1.3 13.837 2.309c6.147 1.55 9.453 7.956 7.035 13.94z")), + ("roblox", fill_path!("M18.926 23.998 0 18.892 5.075.002 24 5.108ZM15.348 10.09l-5.282-1.453-1.414 5.273 5.282 1.453z")), + ("gog", fill_path!("M7.15 15.24H4.36a.4.4 0 0 0-.4.4v2c0 .21.18.4.4.4h2.8v1.32h-3.5c-.56 0-1.02-.46-1.02-1.03v-3.39c0-.56.46-1.02 1.03-1.02h3.48v1.32zM8.16 11.54c0 .58-.47 1.05-1.05 1.05H2.63v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4H4.39a.4.4 0 0 0-.41.4v2.02c0 .23.18.4.4.4H6v1.35H3.68c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04H7.1c.58 0 1.05.47 1.05 1.04v5.86zM21.36 19.36h-1.32v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.12h-.93a.4.4 0 0 0-.4.4v3.72h-1.33v-4.42c0-.56.46-1.02 1.03-1.02h5.61v5.44zM21.37 11.54c0 .58-.47 1.05-1.05 1.05h-4.48v-1.35h3.78a.4.4 0 0 0 .4-.4V6.39a.4.4 0 0 0-.4-.4h-2.03a.4.4 0 0 0-.4.4v2.02c0 .23.18.4.4.4h1.62v1.35H16.9c-.58 0-1.05-.46-1.05-1.04V5.68c0-.57.47-1.04 1.05-1.04h3.43c.58 0 1.05.47 1.05 1.04v5.86zM13.72 4.64h-3.44c-.58 0-1.04.47-1.04 1.04v3.44c0 .58.46 1.04 1.04 1.04h3.44c.57 0 1.04-.46 1.04-1.04V5.68c0-.57-.47-1.04-1.04-1.04m-.3 1.75v2.02a.4.4 0 0 1-.4.4h-2.03a.4.4 0 0 1-.4-.4V6.4c0-.22.17-.4.4-.4H13c.23 0 .4.18.4.4zM12.63 13.92H9.24c-.57 0-1.03.46-1.03 1.02v3.39c0 .57.46 1.03 1.03 1.03h3.39c.57 0 1.03-.46 1.03-1.03v-3.39c0-.56-.46-1.02-1.03-1.02m-.3 1.72v2a.4.4 0 0 1-.4.4v-.01H9.94a.4.4 0 0 1-.4-.4v-1.99c0-.22.18-.4.4-.4h2c.22 0 .4.18.4.4zM23.49 1.1a1.74 1.74 0 0 0-1.24-.52H1.75A1.74 1.74 0 0 0 0 2.33v19.34a1.74 1.74 0 0 0 1.75 1.75h20.5A1.74 1.74 0 0 0 24 21.67V2.33c0-.48-.2-.92-.51-1.24m0 20.58a1.23 1.23 0 0 1-1.24 1.24H1.75A1.23 1.23 0 0 1 .5 21.67V2.33a1.23 1.23 0 0 1 1.24-1.24h20.5a1.24 1.24 0 0 1 1.24 1.24v19.34z")), + ("jagex", fill_path!("M7.8477 22.727 17.5163 3.4675h2.9257L16.1593 11.994l4.2771 8.5263H15.0566l1.0957 2.195h7.8477L18.6179 12.0001 24 1.285 16.1523 1.273l-.7672 1.5371-8.9025 17.7225h-2.9251l4.2819-8.5268L3.5639 3.4808h5.3789L7.8477 1.285H0l5.3819 10.7152L0 22.7153Z")), + ("discord", fill_path!("M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z")), + ("epic", fill_path!("M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4.262 4.262 0 00.02.433c.031.3.037.59.316.92.027.033.311.245.311.245.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2 0 0 .284-.211.311-.243.28-.33.285-.621.316-.92a4.261 4.261 0 00.02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.708 1.708 0 01.591.108 1.745 1.745 0 01.49.299l-.452.546a1.247 1.247 0 00-.308-.195.91.91 0 00-.363-.068.658.658 0 00-.28.06.703.703 0 00-.224.163.783.783 0 00-.151.243.799.799 0 00-.056.299v.008a.852.852 0 00.056.31.7.7 0 00.157.245.736.736 0 00.238.16.774.774 0 00.303.058.79.79 0 00.445-.116v-.339h-.548v-.565H7.37v1.255a2.019 2.019 0 01-.524.307 1.789 1.789 0 01-.683.123 1.642 1.642 0 01-.602-.107 1.46 1.46 0 01-.478-.3 1.371 1.371 0 01-.318-.455 1.438 1.438 0 01-.115-.58v-.008a1.426 1.426 0 01.113-.57 1.449 1.449 0 01.312-.46 1.418 1.418 0 01.474-.309 1.58 1.58 0 01.598-.111 1.708 1.708 0 01.045 0zm11.963.008a2.006 2.006 0 01.612.094 1.61 1.61 0 01.507.277l-.386.546a1.562 1.562 0 00-.39-.205 1.178 1.178 0 00-.388-.07.347.347 0 00-.208.052.154.154 0 00-.07.127v.008a.158.158 0 00.022.084.198.198 0 00.076.066.831.831 0 00.147.06c.062.02.14.04.236.061a3.389 3.389 0 01.43.122 1.292 1.292 0 01.328.17.678.678 0 01.207.24.739.739 0 01.071.337v.008a.865.865 0 01-.081.382.82.82 0 01-.229.285 1.032 1.032 0 01-.353.18 1.606 1.606 0 01-.46.061 2.16 2.16 0 01-.71-.116 1.718 1.718 0 01-.593-.346l.43-.514c.277.223.578.335.9.335a.457.457 0 00.236-.05.157.157 0 00.082-.142v-.008a.15.15 0 00-.02-.077.204.204 0 00-.073-.066.753.753 0 00-.143-.062 2.45 2.45 0 00-.233-.062 5.036 5.036 0 01-.413-.113 1.26 1.26 0 01-.331-.16.72.72 0 01-.222-.243.73.73 0 01-.082-.36v-.008a.863.863 0 01.074-.359.794.794 0 01.214-.283 1.007 1.007 0 01.34-.185 1.423 1.423 0 01.448-.066 2.006 2.006 0 01.025 0zm-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z")), +]; + +/// The logo of a platform id, if `platformIcons.ts` has one. +pub fn platform(id: &str) -> Option<&'static [u8]> { + PLATFORMS + .iter() + .find(|(key, _)| *key == id) + .map(|(_, svg)| *svg) +} diff --git a/crates/accshift-egui/src/ui/components/title_bar.rs b/crates/accshift-egui/src/ui/components/title_bar.rs new file mode 100644 index 0000000..aca3d77 --- /dev/null +++ b/crates/accshift-egui/src/ui/components/title_bar.rs @@ -0,0 +1,637 @@ +//! `src/lib/shared/components/TitleBar.svelte`: the custom title bar. +//! +//! Every metric comes from the component's CSS. Tailwind's preflight makes +//! every box `border-box`, so the bar is 36 px with its 1 px bottom border +//! inside (35 px of content) on Windows and Linux, 32 with 31 of content on +//! macOS: the reference draws that border on row 35. +//! +//! Windows and Linux: action buttons on the left from x = 10, 6 apart; the +//! platform tabs centred on the bar; the 46 px caption strip flush right at +//! full content height. macOS: 72 px of padding for the traffic lights, the +//! actions on the right with 8 px of padding, an opaque `--bg-solid` fill, +//! no caption strip. + +use egui::accesskit::Role; +use egui::{ + pos2, vec2, Color32, CornerRadius, FontId, Id, LayerId, Order, PointerButton, Rect, Response, + Sense, Stroke, StrokeKind, Ui, WidgetInfo, WidgetType, +}; + +use accshift_app::i18n::{translate, Locale}; + +use super::icon; +use super::platform_icons; +use crate::app::platforms::{PlatformDef, RuntimeOs}; +use crate::app::vars::Vars; +use crate::app::Action; +use crate::icons; +use crate::theme::Rgba; +use crate::ui::anim::{self, Timing, Transition}; +use crate::ui::paint::{Border, BoxShadow, BoxStyle, Corners}; + +/// `.titlebar { height: 36px }`, its `border-bottom: 1px` included. +pub const HEIGHT: f32 = 36.0; +/// `.titlebar.macos { height: 32px }`, border included. +pub const HEIGHT_MACOS: f32 = 32.0; + +/// The bar's outer height for an OS. +pub fn height(os: RuntimeOs) -> f32 { + if os == RuntimeOs::Macos { + HEIGHT_MACOS + } else { + HEIGHT + } +} + +/// `transition: all 120ms ease-out` (`.btn`, `.tab`, `.update-btn`). +const T120: Transition = Transition::new(0.12, Timing::EASE_OUT); +/// `.tab-wrap:hover .tab-tooltip { transition-delay: 300ms }`. +const TOOLTIP_IN: Transition = Transition::new(0.12, Timing::EASE_OUT).with_delay(0.3); + +/// The update call to action (`updateCtaLabel` and friends). +#[derive(Clone, Debug)] +pub struct UpdateCta<'a> { + pub label: &'a str, + pub title: &'a str, + pub disabled: bool, +} + +/// `TitleBar.svelte`'s props, minus the callbacks: what a click means comes +/// back as an [`Action`]. +#[derive(Clone, Debug)] +pub struct TitleBarProps<'a> { + pub vars: &'a Vars, + pub locale: Locale, + pub runtime_os: RuntimeOs, + /// Onboarding outside its demo step: drag and caption buttons only. + pub hide_actions: bool, + pub personas_visible: bool, + pub personas_active: bool, + pub active_tab: &'a str, + pub enabled_platforms: &'a [PlatformDef], + /// Enabled platforms this OS cannot run. + pub unavailable: &'a [String], + pub can_refresh: bool, + pub can_add_account: bool, + pub show_bulk_edit: bool, + pub bulk_edit_active: bool, + pub show_settings: bool, + pub update_cta: Option>, + /// The window is maximized: the restore glyph replaces maximize. + pub maximized: bool, + /// Windows 11 reports the pointer over the snap-layout maximize button + /// (`titlebar:maximize-hover`); `:hover` never fires there. Always false + /// until the window-shell job answers `HTMAXBUTTON`. + pub snap_hover: bool, + /// Keyboard focus is showing (the last focus move came from Tab), so + /// `:focus-visible` rings draw. + pub focus_visible: bool, +} + +/// Draws the bar into `rect` (the bar's border box, its top-left at the +/// window's) and returns what the user asked for this frame. +pub fn title_bar(ui: &mut Ui, rect: Rect, props: &TitleBarProps) -> Vec { + let mut actions = Vec::new(); + let v = props.vars; + let macos = props.runtime_os == RuntimeOs::Macos; + // Chromium lays out in device pixels and snaps border widths down to + // whole ones (1 CSS px is 1 device px at 150 %), so the content box + // keeps the rest. + let ppp = ui.ctx().pixels_per_point(); + let border = (ppp.floor().max(1.0)) / ppp; + let content_h = rect.height() - border; + let painter = ui.painter().clone(); + + if macos { + // Opaque fill under the whole border box, then the border on top. + painter.rect_filled(rect, CornerRadius::ZERO, v.bg_solid.to_color32()); + } + painter.rect_filled( + Rect::from_min_max(pos2(rect.left(), rect.bottom() - border), rect.max), + CornerRadius::ZERO, + v.bg_card.to_color32(), + ); + + // The bar itself: mousedown outside a button drags the window, a double + // click toggles maximize. Registered first so every button sits on top. + let bar = ui.interact(rect, Id::new("titlebar"), Sense::click_and_drag()); + if bar.drag_started_by(PointerButton::Primary) { + actions.push(Action::StartDrag); + } + if bar.double_clicked() { + actions.push(Action::ToggleMaximize); + } + + let show_actions = !props.hide_actions; + if show_actions { + let count = 3 + usize::from(props.show_bulk_edit); + // 26 px buttons, 6 apart. + let row_w = count as f32 * 26.0 + (count as f32 - 1.0) * 6.0; + let x0 = if macos { + // `.right { padding-right: 8px }`, the update button after them. + let cta_w = props + .update_cta + .as_ref() + .map(|cta| update_width(ui, cta.label) + 6.0) + .unwrap_or(0.0); + rect.right() - 8.0 - cta_w - row_w + } else { + rect.left() + 10.0 + }; + let y = rect.top() + (content_h - 26.0) / 2.0; + let buttons: [(&'static [u8], &str, bool, Action); 4] = [ + ( + icons::REFRESH, + "titlebar.refresh", + props.can_refresh, + Action::Refresh, + ), + ( + icons::PLUS, + "titlebar.addAccount", + props.can_add_account, + Action::AddAccount, + ), + ( + icons::SETTINGS, + "titlebar.settings", + true, + Action::OpenSettings, + ), + (icons::BULK_EDIT, "bulkEdit.title", true, Action::BulkEdit), + ]; + for (index, (svg, key, enabled, action)) in buttons.into_iter().take(count).enumerate() { + let r = Rect::from_min_size(pos2(x0 + index as f32 * 32.0, y), vec2(26.0, 26.0)); + let label = translate(props.locale, key); + let active_mode = index == 3 && props.bulk_edit_active; + if action_button(ui, r, svg, &label, enabled, active_mode, props) { + actions.push(action); + } + } + } + + if show_actions && (props.enabled_platforms.len() > 1 || props.personas_visible) { + tabs(ui, rect, content_h, props, &mut actions); + } + + if !macos { + // Caption strip, flush right, full content height. + let strip_left = rect.right() - 3.0 * 46.0; + if let (true, Some(cta)) = (show_actions, &props.update_cta) { + // `.right { gap: 6px }` and `.win-controls { margin-left: 4px }`. + let w = update_width(ui, cta.label); + let r = Rect::from_min_size( + pos2( + strip_left - 4.0 - 6.0 - w, + rect.top() + (content_h - 24.0) / 2.0, + ), + vec2(w, 24.0), + ); + if update_button(ui, r, cta, props) { + actions.push(Action::ApplyUpdate); + } + } + let max_key = if props.maximized { + "titlebar.restore" + } else { + "titlebar.maximize" + }; + let caption: [(&'static [u8], &str, Action); 3] = [ + (icons::MINIMIZE, "titlebar.minimize", Action::Minimize), + ( + if props.maximized { + icons::RESTORE + } else { + icons::MAXIMIZE + }, + max_key, + Action::ToggleMaximize, + ), + (icons::CLOSE, "titlebar.close", Action::Close), + ]; + for (index, (svg, key, action)) in caption.into_iter().enumerate() { + let r = Rect::from_min_size( + pos2(strip_left + index as f32 * 46.0, rect.top()), + vec2(46.0, content_h), + ); + let label = translate(props.locale, key); + let (clicked, response) = caption_button(ui, r, svg, &label, index, props); + if clicked { + actions.push(action); + } + if index == 1 { + // Windows 11 Snap Layouts: the window must answer HTMAXBUTTON + // over this rect (`set_maximize_button_rect` in Tauri). + actions.push(Action::SetMaximizeButtonRect(Some(response.rect))); + } + } + } else if show_actions { + if let Some(cta) = &props.update_cta { + let w = update_width(ui, cta.label); + let r = Rect::from_min_size( + pos2( + rect.right() - 8.0 - w, + rect.top() + (content_h - 24.0) / 2.0, + ), + vec2(w, 24.0), + ); + if update_button(ui, r, cta, props) { + actions.push(Action::ApplyUpdate); + } + } + } + if macos { + actions.push(Action::SetMaximizeButtonRect(None)); + } + actions +} + +/// Chromium focuses a button on mousedown; any other press blurs it. The +/// focused button stays in egui memory under this id. +fn focus_id() -> Id { + Id::new("titlebar::focused") +} + +fn focused(ui: &Ui) -> Option { + ui.ctx().data(|d| d.get_temp::(focus_id())) +} + +/// Track mousedown focus for `response`; returns whether it holds focus. +fn track_focus(ui: &Ui, response: &Response, enabled: bool) -> bool { + let pressed_here = + enabled && response.is_pointer_button_down_on() && ui.input(|i| i.pointer.any_pressed()); + if pressed_here { + ui.ctx() + .data_mut(|d| d.insert_temp(focus_id(), response.id)); + } else if ui.input(|i| i.pointer.any_pressed()) + && focused(ui) == Some(response.id) + && !response.contains_pointer() + { + ui.ctx().data_mut(|d| d.remove::(focus_id())); + } + response.has_focus() || focused(ui) == Some(response.id) +} + +/// The UA `:focus-visible` ring of a button, keyboard focus only. +fn focus_ring(ui: &Ui, rect: Rect, radius: f32, props: &TitleBarProps, focused: bool) { + if focused && props.focus_visible { + ui.painter().rect_stroke( + rect, + CornerRadius::same(radius.round() as u8), + Stroke::new(2.0, props.vars.fg.to_color32()), + StrokeKind::Outside, + ); + } +} + +/// `.btn`: 26x26, radius 4. Returns whether it was clicked. +fn action_button( + ui: &mut Ui, + rect: Rect, + svg: &'static [u8], + label: &str, + enabled: bool, + active_mode: bool, + props: &TitleBarProps, +) -> bool { + let v = props.vars; + let id = Id::new(("titlebar::btn", label)); + let response = ui.interact( + rect, + id, + if enabled { + Sense::click() + } else { + Sense::hover() + }, + ); + response.widget_info(|| WidgetInfo::selected(WidgetType::Button, enabled, active_mode, label)); + let has_focus = track_focus(ui, &response, enabled); + let hovered = enabled && response.hovered(); + let pressed = enabled && response.is_pointer_button_down_on(); + // `.btn.active-mode` comes after `:hover` and wins over it. + let (bg, fg) = if active_mode { + (v.accent.mix(0.24, v.bg_muted), v.accent_text) + } else if hovered { + (v.bg_muted, v.fg) + } else { + (Rgba::TRANSPARENT, v.fg_muted) + }; + let ctx = ui.ctx().clone(); + let bg = anim::transition(&ctx, id.with("bg"), bg, &T120); + let fg = anim::transition(&ctx, id.with("fg"), fg, &T120); + let scale = anim::transition( + &ctx, + id.with("scale"), + if pressed { 0.92 } else { 1.0 }, + &T120, + ); + let opacity = anim::transition(&ctx, id.with("op"), if enabled { 1.0 } else { 0.45 }, &T120); + let mut painter = ui.painter().clone(); + painter.multiply_opacity(opacity); + let shown = Rect::from_center_size(rect.center(), rect.size() * scale); + painter.rect_filled( + shown, + CornerRadius::same((4.0 * scale).round() as u8), + bg.to_color32(), + ); + icon::paint( + &painter, + svg, + Rect::from_center_size(shown.center(), vec2(14.0, 14.0) * scale), + fg, + 1.0, + ); + focus_ring(ui, rect, 4.0, props, has_focus); + enabled && response.clicked() +} + +/// `.win-btn`: 46 px wide, full height, no radius. Index 2 is `.close`. +fn caption_button( + ui: &mut Ui, + rect: Rect, + svg: &'static [u8], + label: &str, + index: usize, + props: &TitleBarProps, +) -> (bool, Response) { + let v = props.vars; + let id = Id::new(("titlebar::caption", index)); + let response = ui.interact(rect, id, Sense::click()); + response.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, label)); + let has_focus = track_focus(ui, &response, true); + let hovered = response.hovered() || (index == 1 && props.snap_hover); + let pressed = response.is_pointer_button_down_on(); + let close = index == 2; + let white = Rgba::hex(0xffffff, 1.0); + let (bg, fg) = match (close, hovered, pressed) { + (true, _, true) => (v.danger.mix(0.78, Rgba::hex(0x000000, 1.0)), white), + (true, true, false) => (v.danger, white), + (false, _, true) => (v.bg_elevated, if hovered { v.fg } else { v.fg_muted }), + (false, true, false) => (v.bg_muted, v.fg), + (_, false, false) => (Rgba::TRANSPARENT, v.fg_muted), + }; + let ctx = ui.ctx().clone(); + let bg = anim::transition(&ctx, id.with("bg"), bg, &T120); + let fg = anim::transition(&ctx, id.with("fg"), fg, &T120); + let scale = anim::transition( + &ctx, + id.with("scale"), + if pressed { 0.9 } else { 1.0 }, + &T120, + ); + let painter = ui.painter(); + painter.rect_filled(rect, CornerRadius::ZERO, bg.to_color32()); + icon::paint( + painter, + svg, + Rect::from_center_size(rect.center(), vec2(12.0, 12.0)), + fg, + scale, + ); + focus_ring(ui, rect, 0.0, props, has_focus); + (response.clicked(), response) +} + +/// `.tabs`: centred on the bar's padding box, 32x28 tabs 2 apart. +fn tabs(ui: &mut Ui, bar: Rect, content_h: f32, props: &TitleBarProps, actions: &mut Vec) { + struct Tab<'a> { + key: &'a str, + name: String, + svg: Option<&'static [u8]>, + accent: Rgba, + active: bool, + unavailable: bool, + } + let mut list = Vec::new(); + if props.personas_visible { + list.push(Tab { + key: "__personas__", + name: translate(props.locale, "titlebar.personas"), + svg: Some(platform_icons::PERSONAS), + accent: Rgba::hex(platform_icons::PERSONAS_ACCENT, 1.0), + active: props.personas_active, + unavailable: false, + }); + } + for platform in props.enabled_platforms { + list.push(Tab { + key: &platform.id, + name: platform.name.clone(), + svg: platform_icons::platform(&platform.id), + accent: platform.accent, + active: !props.show_settings + && !props.personas_active + && props.active_tab == platform.id, + unavailable: props.unavailable.contains(&platform.id), + }); + } + let n = list.len() as f32; + let total = n * 32.0 + (n - 1.0).max(0.0) * 2.0; + let x0 = bar.center().x - total / 2.0; + let y = bar.top() + content_h / 2.0 - 14.0; + let strip = Rect::from_min_size(pos2(x0, y), vec2(total, 28.0)); + let v = props.vars; + let ctx = ui.ctx().clone(); + for (index, tab) in list.iter().enumerate() { + let rect = Rect::from_min_size(pos2(x0 + index as f32 * 34.0, y), vec2(32.0, 28.0)); + let id = Id::new(("titlebar::tab", tab.key)); + let enabled = !tab.unavailable; + let response = ui.interact( + rect, + id, + if enabled { + Sense::click() + } else { + Sense::hover() + }, + ); + response.widget_info(|| { + WidgetInfo::selected(WidgetType::SelectableLabel, enabled, tab.active, &tab.name) + }); + ctx.accesskit_node_builder(id, |node| { + node.set_role(Role::Tab); + node.set_selected(tab.active); + }); + let has_focus = track_focus(ui, &response, enabled); + let hovered = enabled && response.hovered(); + // `.tab.active` keeps the card fill and the inline accent colour beats + // `:hover`'s; `.tab.disabled:hover` (more specific) clears both. + let disabled_hover = !enabled && response.hovered(); + let bg = if (hovered || tab.active) && !disabled_hover { + v.bg_card + } else { + Rgba::TRANSPARENT + }; + let fg = if tab.active { + tab.accent + } else if hovered { + v.fg_muted + } else { + v.fg_subtle + }; + let bg = anim::transition(&ctx, id.with("bg"), bg, &T120); + let fg = anim::transition(&ctx, id.with("fg"), fg, &T120); + let opacity = + anim::transition(&ctx, id.with("op"), if enabled { 1.0 } else { 0.45 }, &T120); + let mut painter = ui.painter().clone(); + painter.multiply_opacity(opacity); + painter.rect_filled(rect, CornerRadius::same(4), bg.to_color32()); + match tab.svg { + Some(svg) => icon::paint( + &painter, + svg, + Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), + fg, + 1.0, + ), + None => { + // `.tab-text`: the first two characters, 11px 600. + let short: String = tab.name.chars().take(2).collect(); + let galley = + painter.layout_no_wrap(short, FontId::new(11.0, semibold()), fg.to_color32()); + let at = rect.center() - galley.size() / 2.0; + painter.galley(at, galley, fg.to_color32()); + } + } + focus_ring(ui, rect, 4.0, props, has_focus); + if enabled && response.clicked() { + actions.push(if tab.key == "__personas__" { + Action::OpenPersonas + } else { + Action::TabChange(tab.key.to_string()) + }); + } + let label = if tab.unavailable { + format!( + "{} ({})", + tab.name, + translate(props.locale, "settings.platformUnsupportedOs") + ) + } else { + tab.name.clone() + }; + // `.tab-wrap:hover` covers the disabled button too. + tooltip( + ui, + rect, + strip, + &label, + response.hovered() || has_focus, + props, + ); + } +} + +/// `.tab-tooltip`: under the tab, centred, fading in after 300 ms, clipped +/// to `strip`, the `.tabs` box. Its `overflow-x: auto` makes `overflow-y` +/// auto as well, so the tooltip below the tabs never shows: the reference +/// draws none, and neither does this until the CSS changes. +fn tooltip(ui: &Ui, tab: Rect, strip: Rect, label: &str, shown: bool, props: &TitleBarProps) { + let ctx = ui.ctx(); + let id = Id::new(("titlebar::tooltip", label)); + let spec = if shown { TOOLTIP_IN } else { T120 }; + let opacity = anim::transition(ctx, id, if shown { 1.0f32 } else { 0.0 }, &spec); + if opacity <= 0.0 { + return; + } + let v = props.vars; + // 11px, weight 500 (Regular until the text port lands its faces). + let font = FontId::proportional(11.0); + let galley = ctx.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font, v.fg.to_color32())); + let border = v.border_px(); + // `line-height: normal` of the 11 px UI face, until `ui::text` owns it. + let line = 15.0; + let size = vec2( + galley.size().x + 16.0 + 2.0 * border, + line + 8.0 + 2.0 * border, + ); + // `left: 50%; top: 100%; transform: translateX(-50%) translateY(4px)`. + let rect = Rect::from_min_size( + pos2(tab.center().x - size.x / 2.0, tab.bottom() + 4.0), + size, + ); + let mut painter = ctx + .layer_painter(LayerId::new(Order::Foreground, Id::new("titlebar::tips"))) + .with_clip_rect(strip); + painter.multiply_opacity(opacity); + BoxStyle { + background: v.bg_overlay, + radius: Corners::all(4.0), + border: Border::all(border, v.border), + shadows: vec![BoxShadow { + offset: vec2(0.0, 4.0), + blur: 12.0, + spread: 0.0, + color: Rgba::new(0.0, 0.0, 0.0, 0.3), + inset: false, + }], + ..BoxStyle::default() + } + .paint(&painter, rect); + let text_top = rect.top() + border + 4.0 + (line - galley.size().y) / 2.0; + painter.galley( + pos2(rect.left() + border + 8.0, text_top), + galley, + Color32::WHITE, + ); +} + +/// `.update-btn` width: text plus `padding: 0 10px` and the 1 px border. +fn update_width(ui: &Ui, label: &str) -> f32 { + let font = FontId::new(11.0, semibold()); + let w = ui.ctx().fonts_mut(|f| { + f.layout_no_wrap(label.to_owned(), font, Color32::WHITE) + .size() + .x + }); + w + 20.0 + 2.0 +} + +/// `.update-btn`: pill, 24 px high, 11px 600. +fn update_button(ui: &mut Ui, rect: Rect, cta: &UpdateCta, props: &TitleBarProps) -> bool { + let v = props.vars; + let id = Id::new("titlebar::update"); + let enabled = !cta.disabled; + let response = ui.interact( + rect, + id, + if enabled { + Sense::click() + } else { + Sense::hover() + }, + ); + response.widget_info(|| WidgetInfo::labeled(WidgetType::Button, enabled, cta.label)); + let hovered = enabled && response.hovered(); + let ctx = ui.ctx().clone(); + let bg = if hovered { + v.bg_card.mix(0.70, v.fg) + } else { + v.bg_card.mix(0.92, v.fg) + }; + let bg = anim::transition(&ctx, id.with("bg"), bg, &T120); + let opacity = anim::transition(&ctx, id.with("op"), if enabled { 1.0 } else { 0.6 }, &T120); + let mut painter = ui.painter().clone(); + painter.multiply_opacity(opacity); + BoxStyle { + background: bg, + radius: Corners::all(999.0), + border: Border::all(1.0, v.fg.mix(0.25, v.border)), + ..BoxStyle::default() + } + .paint(&painter, rect); + let font = FontId::new(11.0, semibold()); + let galley = painter.layout_no_wrap(cta.label.to_owned(), font, v.fg.to_color32()); + painter.galley( + rect.center() - galley.size() / 2.0, + galley, + v.fg.to_color32(), + ); + enabled && response.clicked() +} + +/// The 600 weight face the font setup registers (`font-weight: 600`). +fn semibold() -> egui::FontFamily { + egui::FontFamily::Name("semibold".into()) +} diff --git a/crates/accshift-egui/src/ui/mod.rs b/crates/accshift-egui/src/ui/mod.rs index f62b422..d688076 100644 --- a/crates/accshift-egui/src/ui/mod.rs +++ b/crates/accshift-egui/src/ui/mod.rs @@ -3,6 +3,7 @@ //! animation timing ([`anim`]). pub mod anim; +pub mod components; pub mod css; pub mod paint; pub mod text; From 0538b1a76cf992f121dc28a0bc5030539190284c Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:31:02 +0200 Subject: [PATCH 022/106] fix(egui): parse multi-line text tags in parity fragments --- .../accshift-egui/src/screens/primitives.rs | 49 ++++++++++++++++--- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/crates/accshift-egui/src/screens/primitives.rs b/crates/accshift-egui/src/screens/primitives.rs index 2992be4..231d88d 100644 --- a/crates/accshift-egui/src/screens/primitives.rs +++ b/crates/accshift-egui/src/screens/primitives.rs @@ -47,18 +47,29 @@ impl Primitives { } let mut texts = Vec::new(); let mut rest = html; - while let Some(start) = rest.find("

").ok_or("unclosed

') { + continue; + } + let tag_end = rest.find('>').ok_or("unclosed ").ok_or("unclosed

")?; // White space collapses (`nowrap` keeps that). - let content = rest[..close] + let content = unescape(&rest[..close]) .split_whitespace() .collect::>() .join(" "); rest = &rest[close..]; + let style = &unescape(style); let (pos, _) = css::position(style).ok_or_else(|| format!("bad position in {style}"))?; let text_style = @@ -91,6 +102,15 @@ impl Primitives { } } +/// The character references the fragments use. +fn unescape(s: &str) -> String { + s.replace(""", "\"") + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") +} + #[cfg(test)] mod tests { use super::*; @@ -104,6 +124,23 @@ mod tests { let html = std::fs::read_to_string(&path).unwrap(); let p = Primitives::parse(&html).unwrap_or_else(|e| panic!("{}: {e}", path.display())); assert!(p.boxes.len() > 1); + if path + .file_name() + .unwrap() + .to_string_lossy() + .starts_with("typography") + { + assert!( + p.texts.len() > 20, + "{}: {} text lines", + path.display(), + p.texts.len() + ); + assert!(p + .texts + .iter() + .all(|(_, text, _)| !text.is_empty() && !text.contains('\n'))); + } count += 1; } assert!(count >= 4); From 245012cd894336f02f488302f128ba59db0a1bbb Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:44:46 +0200 Subject: [PATCH 023/106] fix(egui): capture parity references at the real device scale factor --- crates/accshift-egui/parity/README.md | 2 + crates/accshift-egui/parity/harness.mjs | 11 ++++- crates/accshift-egui/parity/ref.mjs | 55 +++++++++++++++++-------- 3 files changed, 50 insertions(+), 18 deletions(-) diff --git a/crates/accshift-egui/parity/README.md b/crates/accshift-egui/parity/README.md index 41bd0b1..bef69c0 100644 --- a/crates/accshift-egui/parity/README.md +++ b/crates/accshift-egui/parity/README.md @@ -32,6 +32,8 @@ A debug `accshift-gui` built with the `parity-capture` feature. That build: The frontend comes from `vite dev` on port 1420 (the `devUrl` compiled into the debug app) with `VITE_MOCK=dev`, the non-frozen mock, so each scene picks its own scenario. CDP is opened on a free port through `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS`, which also turns off Chromium's native occlusion tracking so the unseen window keeps painting. +The scale is real: `ref.mjs` launches the app once per scene scale with `--force-device-scale-factor=` in the same arguments (`launchApp({ scale })` in `harness.mjs`), which is what a Windows display at that scaling gives, and sets only the viewport size, in CSS px, through `Emulation.setDeviceMetricsOverride` (`deviceScaleFactor: 0`). It refuses a scene whose `devicePixelRatio` differs from its scale. The CDP emulated factor is not used: it lays the page out at the window's own factor and scales the frame, so a 1px border stays 1.5 device px at 150 % and boxes snap to whole CSS px, where a real 150 % display lays out in device pixels (border floored to 1 device px, edges snapped to the device grid). References taken before 2026-10-12 used the emulation and are wrong at 1.25 and 1.5. Two runs at 1.5 give identical frames for the title bar and primitives scenes and 103 to 112 dB on `spike-main`. + ## Scene format `scenes/.json`, the contract between `ref.mjs` and the egui runner (`accshift-egui --scene`): diff --git a/crates/accshift-egui/parity/harness.mjs b/crates/accshift-egui/parity/harness.mjs index 8bb7ea9..a8871bd 100644 --- a/crates/accshift-egui/parity/harness.mjs +++ b/crates/accshift-egui/parity/harness.mjs @@ -155,8 +155,16 @@ export async function startVite({ logFile }) { * Launch the parity build against the dev server. `scratch` receives the app * directories (ACCSHIFT_PARITY_DIR), the WebView2 profile and the binary copy. * `onStderr` sees every stderr line (the `parity-window` samples among them). + * + * `scale` (optional) is Chromium's real device scale factor, through + * `--force-device-scale-factor`: what a Windows display at that scaling + * gives. Layout then runs in device pixels (border widths floored to whole + * device pixels, boxes snapped to the device grid). The CDP emulation + * (`Emulation.setDeviceMetricsOverride` with a `deviceScaleFactor`) is not + * the same thing: it lays the page out at the window's own factor and scales + * the frame, so 1 CSS px of border stays 1.5 device px at 150 %. */ -export async function launchApp({ exe, scratch, logFile, onStderr, windowMode }) { +export async function launchApp({ exe, scratch, logFile, onStderr, windowMode, scale }) { assertParityBuild(exe); rmSync(scratch, { recursive: true, force: true }); mkdirSync(join(scratch, "bin"), { recursive: true }); @@ -176,6 +184,7 @@ export async function launchApp({ exe, scratch, logFile, onStderr, windowMode }) // cloaked, off-screen window as occluded and stop painting. WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS: [ `--remote-debugging-port=${cdpPort}`, + ...(scale ? [`--force-device-scale-factor=${scale}`] : []), "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection,CalculateNativeWinOcclusion", "--disable-backgrounding-occluded-windows", "--disable-renderer-backgrounding", diff --git a/crates/accshift-egui/parity/ref.mjs b/crates/accshift-egui/parity/ref.mjs index 6ff4966..d52478c 100644 --- a/crates/accshift-egui/parity/ref.mjs +++ b/crates/accshift-egui/parity/ref.mjs @@ -9,6 +9,10 @@ // profile and binary copy, removed after the run; default /.scratch), // --window hidden|cloak (default hidden: the window is never shown). // +// The app is launched once per scale with `--force-device-scale-factor`, the +// device scale factor a Windows display at that scaling gives (harness.mjs, +// launchApp `scale`); the viewport is set in CSS px with CDP. +// // Output: //.png at viewport x scale, /manifest.json // (every shot, with its `alias` if the scene gives one, read by // diff.mjs --batch), /run.json (window samples and the real-dir check). @@ -186,12 +190,19 @@ function pngSize(buf) { async function runScene(page, scene, outDir, manifest) { const { send, js } = page; const vp = scene.viewport; + // The viewport in CSS px; the scale is the app's real device scale factor + // (launchApp `scale`), never the CDP emulated one: that one lays the page + // out at the window's factor and scales the frame (see harness.mjs). await send("Emulation.setDeviceMetricsOverride", { width: vp.width, height: vp.height, - deviceScaleFactor: vp.scale, + deviceScaleFactor: 0, mobile: false, }); + const dpr = await js("window.devicePixelRatio"); + if (Math.abs(dpr - vp.scale) > 1e-6) { + throw new Error(`${scene.id}: devicePixelRatio ${dpr}, scene scale ${vp.scale}`); + } const desk = await send("Page.addScriptToEvaluateOnNewDocument", { source: desktop(vp) }); // The mock import can hit a module still initialising (a reload in flight): @@ -394,29 +405,39 @@ async function main() { const vite = await startVite({ logFile: join(out, "vite.log") }); let app = null; const manifest = []; + // One app per scale: `--force-device-scale-factor` is fixed at launch. + const scales = [...new Set(scenes.map((s) => s.viewport.scale))]; try { - app = await launchApp({ - exe, - scratch: join(scratch, "run"), - logFile: join(out, "app.log"), - onStderr: watch.onLine, - windowMode: opts.window, - }); - const { send } = app.page; - await send("Page.enable"); - await send("Runtime.enable"); - await send("Emulation.setEmulatedMedia", { - features: [{ name: "prefers-reduced-motion", value: "no-preference" }], - }); - await send("Page.addScriptToEvaluateOnNewDocument", { source: PIN_CLOCK }); - for (const scene of scenes) await runScene(app.page, scene, out, manifest); + for (const scale of scales) { + app = await launchApp({ + exe, + scratch: join(scratch, "run"), + logFile: join(out, `app-${scale}.log`), + onStderr: watch.onLine, + windowMode: opts.window, + scale, + }); + const { send } = app.page; + await send("Page.enable"); + await send("Runtime.enable"); + await send("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-reduced-motion", value: "no-preference" }], + }); + await send("Page.addScriptToEvaluateOnNewDocument", { source: PIN_CLOCK }); + for (const scene of scenes.filter((s) => s.viewport.scale === scale)) { + await runScene(app.page, scene, out, manifest); + } + await app.close(); + app = null; + } } finally { await app?.close(); await vite.close(); } // The last samples land while the app shuts down. The scratch run (binary - // copy, app dirs, WebView2 profile) goes with it; app.log stays in . + // copy, app dirs, WebView2 profile) goes with it; app-.log stays in + // . await sleep(300); rmSync(join(scratch, "run"), { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); const after = Object.fromEntries(dirs.map((d) => [d, snapshotTree(d)])); From bed884fa181dcc7524fb171ff010f933f1f11242 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:46:53 +0200 Subject: [PATCH 024/106] feat(app): port the secure screen, streamer mode, bulk edit and add flows --- crates/accshift-app/src/bulk_edit.rs | 568 +++++++++++++ crates/accshift-app/src/cs2_bridge.rs | 208 +++++ crates/accshift-app/src/lib.rs | 11 + crates/accshift-app/src/platform_add_flow.rs | 769 ++++++++++++++++++ crates/accshift-app/src/platform_shell.rs | 194 +++++ .../accshift-app/src/platforms/steam/cs2.rs | 6 + crates/accshift-app/src/secure/inactivity.rs | 153 ++++ crates/accshift-app/src/secure/mod.rs | 60 ++ crates/accshift-app/src/secure/pin.rs | 158 ++++ crates/accshift-app/src/secure/screen.rs | 590 ++++++++++++++ .../src/secure/window_activity.rs | 144 ++++ crates/accshift-app/src/streamer.rs | 175 ++++ crates/accshift-app/tests/bulk_edit.rs | 318 ++++++++ .../accshift-app/tests/platform_add_flow.rs | 535 ++++++++++++ crates/accshift-app/tests/platform_shell.rs | 279 +++++++ crates/accshift-app/tests/secure_pin.rs | 349 ++++++++ crates/accshift-app/tests/secure_screen.rs | 463 +++++++++++ crates/accshift-app/tests/streamer_mode.rs | 178 ++++ 18 files changed, 5158 insertions(+) create mode 100644 crates/accshift-app/src/bulk_edit.rs create mode 100644 crates/accshift-app/src/cs2_bridge.rs create mode 100644 crates/accshift-app/src/platform_add_flow.rs create mode 100644 crates/accshift-app/src/platform_shell.rs create mode 100644 crates/accshift-app/src/secure/inactivity.rs create mode 100644 crates/accshift-app/src/secure/mod.rs create mode 100644 crates/accshift-app/src/secure/pin.rs create mode 100644 crates/accshift-app/src/secure/screen.rs create mode 100644 crates/accshift-app/src/secure/window_activity.rs create mode 100644 crates/accshift-app/src/streamer.rs create mode 100644 crates/accshift-app/tests/bulk_edit.rs create mode 100644 crates/accshift-app/tests/platform_add_flow.rs create mode 100644 crates/accshift-app/tests/platform_shell.rs create mode 100644 crates/accshift-app/tests/secure_pin.rs create mode 100644 crates/accshift-app/tests/secure_screen.rs create mode 100644 crates/accshift-app/tests/streamer_mode.rs diff --git a/crates/accshift-app/src/bulk_edit.rs b/crates/accshift-app/src/bulk_edit.rs new file mode 100644 index 0000000..d144e88 --- /dev/null +++ b/crates/accshift-app/src/bulk_edit.rs @@ -0,0 +1,568 @@ +//! Bulk edit: the multi-select mode and the Steam bulk edit bar, ported from +//! `src/lib/app/useBulkEdit.svelte.ts` and the non-drawing parts of +//! `src/lib/platforms/steam/BulkEditBar.svelte` (selection rules, request +//! building, progress and result states). +//! +//! The TS bar is a lazily loaded component chunk; here it is a plain model +//! created when the mode opens, so the load race of `toggleBulkEdit` (the +//! pending open token) has nothing left to guard. The paint gesture reads +//! the card under the pointer from the UI instead of `elementFromPoint`. +//! +//! The toasts for the outcome (`handleBulkEditResult`), the clipboard write +//! of the URLs and the card color write belong to the dialog and card-action +//! modules: [`BulkEdit::poll`] hands the result over, [`BulkEditBar::copy_urls`] +//! and [`BulkEditBar::apply_color`] the values. + +use crate::error::BackendResult; +use crate::jobs::{Jobs, Pending, Poll}; +use crate::platforms::steam::{api as steam_api, to_profile_url}; +use crate::platforms::PlatformDef; +use crate::text::Text; +use crate::types::{ + BulkEditFailure, BulkEditRequest, BulkEditResult, CopyableGame, LaunchOptionEdit, +}; + +/// `PAINT_THRESHOLD` (useBulkEdit line 34): manhattan pixels before a press +/// becomes a paint drag. +pub const PAINT_THRESHOLD: f32 = 5.0; + +/// `isBulkEditToggleAllowed` (App.svelte lines 571-577): the keyboard and +/// palette toggle. +pub fn toggle_allowed( + def: Option<&PlatformDef>, + active_tab_usable: bool, + settings_open: bool, +) -> bool { + def.is_some_and(|def| def.capabilities.bulk_edit) && active_tab_usable && !settings_open +} + +/// `showBulkEdit` of the titlebar (App.svelte line 765). +pub fn titlebar_button_visible( + def: Option<&PlatformDef>, + active_tab_usable: bool, + settings_open: bool, + personas_open: bool, +) -> bool { + toggle_allowed(def, active_tab_usable, settings_open) && !personas_open +} + +/// The paint gesture state (lines 30-41). +#[derive(Debug, Clone, Default)] +struct Paint { + active: bool, + /// Select (true) or deselect, locked by the start card for the gesture. + desired: bool, + moved: bool, + start: (f32, f32), + start_id: Option, +} + +/// `createBulkEditController`. +#[derive(Debug, Default)] +pub struct BulkEdit { + /// The platform whose bar is open; `Some` is `bulkEditMode`. + platform_id: Option, + /// `bulkEditSelectedIds`, in insertion order like a JS `Set`. + selected: Vec, + bar: Option, + paint: Paint, + eat_next_click: bool, +} + +impl BulkEdit { + pub fn new() -> Self { + Self::default() + } + + /// `bulkEditMode`. + pub fn is_open(&self) -> bool { + self.platform_id.is_some() + } + + /// The platform the open bar belongs to. + pub fn platform_id(&self) -> Option<&str> { + self.platform_id.as_deref() + } + + /// `bulkEditSelectedIds`. + pub fn selected_ids(&self) -> &[String] { + &self.selected + } + + pub fn is_selected(&self, account_id: &str) -> bool { + self.selected.iter().any(|id| id == account_id) + } + + /// The bar model while the mode is open. + pub fn bar(&self) -> Option<&BulkEditBar> { + self.bar.as_ref() + } + + pub fn bar_mut(&mut self) -> Option<&mut BulkEditBar> { + self.bar.as_mut() + } + + /// `bulkEditActiveAccountSelected` (lines 57-61). + pub fn active_account_selected(&self, current_account_id: Option<&str>) -> bool { + current_account_id.is_some_and(|id| !id.is_empty() && self.is_selected(id)) + } + + fn set_selected(&mut self, account_id: &str, selected: bool) { + if selected == self.is_selected(account_id) { + return; + } + if selected { + self.selected.push(account_id.to_string()); + } else { + self.selected.retain(|id| id != account_id); + } + } + + /// `toggleBulkEdit` (lines 63-94): closes an open mode, or opens the bar + /// of the active platform when it declares the capability. + pub fn toggle(&mut self, active_platform: Option<&PlatformDef>) { + if self.is_open() { + self.close(); + return; + } + let Some(def) = active_platform.filter(|def| def.capabilities.bulk_edit) else { + return; + }; + self.platform_id = Some(def.id.clone()); + self.bar = Some(BulkEditBar::new()); + } + + /// `toggleBulkEditAccount` (lines 96-101). + pub fn toggle_account(&mut self, account_id: &str) { + let selected = self.is_selected(account_id); + self.set_selected(account_id, !selected); + } + + /// `bulkEditSelectAll` (lines 103-110): additive, the selection spans the + /// whole platform. `visible_ids` is what the user can see (App.svelte + /// lines 151-154: never off-screen accounts). + pub fn select_all(&mut self, visible_ids: &[String]) { + for id in visible_ids { + self.set_selected(id, true); + } + } + + /// `bulkEditDeselectAll` (lines 112-114): the reset, across folders. + pub fn deselect_all(&mut self) { + self.selected.clear(); + } + + /// `closeBulkEdit` (lines 116-120): also on a tab change. + pub fn close(&mut self) { + self.platform_id = None; + self.bar = None; + self.selected.clear(); + self.paint = Paint::default(); + } + + // ------------------------------------------------------------ paint + + /// `handlePaintMouseDown` (lines 124-136): arms the gesture on a primary + /// press over a card. True when it armed, so the caller skips the + /// reorder drag and keeps the cards in place. + pub fn paint_mouse_down( + &mut self, + primary: bool, + card_id: Option<&str>, + pos: (f32, f32), + ) -> bool { + if !self.is_open() || !primary { + return false; + } + let Some(account_id) = card_id.filter(|id| !id.is_empty()) else { + return false; + }; + self.paint = Paint { + active: true, + desired: !self.is_selected(account_id), + moved: false, + start: pos, + start_id: Some(account_id.to_string()), + }; + true + } + + /// `handlePaintMouseMove` (lines 138-149). `card_id` is the card under + /// the pointer. + pub fn paint_mouse_move(&mut self, pos: (f32, f32), card_id: Option<&str>) { + if !self.paint.active { + return; + } + if !self.paint.moved { + let distance = (pos.0 - self.paint.start.0).abs() + (pos.1 - self.paint.start.1).abs(); + if distance < PAINT_THRESHOLD { + return; + } + self.paint.moved = true; + if let Some(start) = self.paint.start_id.clone() { + self.set_selected(&start, self.paint.desired); + } + } + if let Some(id) = card_id.filter(|id| !id.is_empty()) { + self.set_selected(id, self.paint.desired); + } + } + + /// `handlePaintMouseUp` (lines 151-166). After a real drag the click + /// that follows the release is swallowed for this frame. + pub fn paint_mouse_up(&mut self) { + if !self.paint.active { + return; + } + let moved = self.paint.moved; + self.paint = Paint::default(); + if moved { + self.eat_next_click = true; + } + } + + /// `handlePaintCaptureClick` (lines 168-174): true when the click must + /// not reach the card (no single-click toggle after a drag). + pub fn paint_capture_click(&mut self) -> bool { + std::mem::take(&mut self.eat_next_click) + } + + /// The `setTimeout(0)` that drops an unclaimed swallow: call at the end + /// of the frame that handled the release. + pub fn end_frame(&mut self) { + self.eat_next_click = false; + } + + pub fn is_painting(&self) -> bool { + self.paint.active + } + + // ------------------------------------------------------------ bar jobs + + /// The bar's "Next": [`BulkEditBar::go_to_settings`] with the selection. + pub fn go_to_settings(&mut self, jobs: &Jobs) { + let first = self.selected.first().cloned(); + if let Some(bar) = self.bar.as_mut() { + bar.go_to_settings(jobs, first); + } + } + + /// The bar's "Apply": [`BulkEditBar::apply`] with the selection. + pub fn apply(&mut self, jobs: &Jobs) { + let selected = self.selected.clone(); + if let Some(bar) = self.bar.as_mut() { + bar.apply(jobs, &selected); + } + } + + /// Escape (`handleOverlayKeydown`, bar lines 142-150): closes the color + /// menu, else steps back, else closes the mode. + pub fn escape(&mut self) { + if let Some(bar) = self.bar.as_mut() { + if bar.escape() { + self.close(); + } + } + } + + /// One frame: applies finished bar jobs. A finished apply closes the mode + /// (`onResult` then `onClose`) and hands the result over for the toast. + pub fn poll(&mut self) -> Option { + let result = self.bar.as_mut()?.poll()?; + self.close(); + Some(result) + } + + /// A games load or an apply is in flight. + pub fn is_busy(&self) -> bool { + self.bar.as_ref().is_some_and(BulkEditBar::is_busy) + } +} + +/// `TriState` of the bar (line 14). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TriState { + #[default] + Unchanged, + Enabled, + Disabled, +} + +impl TriState { + /// `cycleTriState` (lines 72-76). + pub fn cycle(self) -> Self { + match self { + TriState::Unchanged => TriState::Enabled, + TriState::Enabled => TriState::Disabled, + TriState::Disabled => TriState::Unchanged, + } + } + + /// `triStateLabel` (lines 78-82). + pub fn label(self) -> Text { + Text::key(match self { + TriState::Unchanged => "bulkEdit.unchanged", + TriState::Enabled => "common.enabled", + TriState::Disabled => "common.disabled", + }) + } + + /// `triStateToValue` (lines 84-88). + pub fn value(self) -> Option { + match self { + TriState::Unchanged => None, + TriState::Enabled => Some(true), + TriState::Disabled => Some(false), + } + } + + /// `class:active` on the button. + pub fn is_changed(self) -> bool { + self != TriState::Unchanged + } +} + +/// The bar's two steps (line 62). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum BarStep { + /// The bottom bar with the selection tools. + #[default] + Select, + /// The settings dialog. + Settings, +} + +/// One launch option row (`LaunchOptionEdit` of steamApi.ts). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LaunchOptionDraft { + pub app_id: String, + pub value: String, +} + +/// The state of `BulkEditBar.svelte`. +#[derive(Debug, Default)] +pub struct BulkEditBar { + step: BarStep, + color_menu_open: bool, + pub news_popup: TriState, + pub do_not_disturb: TriState, + launch_option_edits: Vec, + games: Vec, + games_pending: Option>>>, + /// `bind:value` of the game select; empty is "Select a game". + pub selected_game_id: String, + /// `bind:value` of the launch option field. + pub launch_option_input: String, + apply_pending: Option>>, +} + +impl BulkEditBar { + pub fn new() -> Self { + Self::default() + } + + pub fn step(&self) -> BarStep { + self.step + } + + pub fn color_menu_open(&self) -> bool { + self.color_menu_open + } + + /// `gamesLoading`. + pub fn games_loading(&self) -> bool { + self.games_pending.is_some() + } + + pub fn games(&self) -> &[CopyableGame] { + &self.games + } + + pub fn launch_option_edits(&self) -> &[LaunchOptionDraft] { + &self.launch_option_edits + } + + /// `applying`. + pub fn applying(&self) -> bool { + self.apply_pending.is_some() + } + + pub fn is_busy(&self) -> bool { + self.games_loading() || self.applying() + } + + /// `bulkEdit.selected { count }`, in the bar and the dialog header. + pub fn selected_label(count: usize) -> Text { + Text::with("bulkEdit.selected", [("count", count as u64)]) + } + + /// Copy URLs, color and Next are off without a selection (lines 160, + /// 167, 196). + pub fn selection_actions_enabled(selected_count: usize) -> bool { + selected_count > 0 + } + + /// The apply button (line 287). + pub fn apply_enabled(&self, selected_count: usize) -> bool { + !self.applying() && selected_count > 0 + } + + /// The apply button text (line 288): `...` while applying. + pub fn apply_label(&self) -> Text { + if self.applying() { + Text::raw("...") + } else { + Text::key("bulkEdit.apply") + } + } + + /// `copyUrls` (lines 38-40): the profile URLs to put on the clipboard. + pub fn copy_urls(selected_ids: &[String]) -> Vec { + selected_ids.iter().map(|id| to_profile_url(id)).collect() + } + + /// `toggleColorMenu` (lines 45-47). + pub fn toggle_color_menu(&mut self) { + self.color_menu_open = !self.color_menu_open; + } + + /// `applyColor` (lines 49-52): closes the menu and returns the color for + /// `onSetCardColor` (empty clears it). + pub fn apply_color(&mut self, color: &str) -> String { + self.color_menu_open = false; + color.to_string() + } + + /// `handleWindowPointerDown` (lines 56-60): a press outside the color + /// popover closes it, and the press still runs its own action. + pub fn pointer_down(&mut self, inside_color_menu: bool) { + if self.color_menu_open && !inside_color_menu { + self.color_menu_open = false; + } + } + + /// `gameName` (lines 90-92). + pub fn game_name<'a>(&'a self, app_id: &'a str) -> &'a str { + self.games + .iter() + .find(|game| game.app_id == app_id) + .map_or(app_id, |game| game.name.as_str()) + } + + /// `goToSettings` (lines 94-106): the games of the first selected + /// account fill the launch option picker, once. + pub fn go_to_settings(&mut self, jobs: &Jobs, first_selected: Option) { + self.step = BarStep::Settings; + if !self.games.is_empty() || self.games_pending.is_some() { + return; + } + if let Some(steam_id) = first_selected { + self.games_pending = + Some(jobs.run(move |backend| backend.steam_get_account_games(&steam_id))); + } + } + + /// Back to the selection (the overlay click and the Back button). + pub fn back(&mut self) { + self.step = BarStep::Select; + } + + /// The add button is enabled (line 256). + pub fn can_add_launch_option(&self) -> bool { + !self.selected_game_id.is_empty() && !crate::js::trim(&self.launch_option_input).is_empty() + } + + /// `addLaunchOption` (lines 108-116): one row per game, the last one wins. + pub fn add_launch_option(&mut self) { + if !self.can_add_launch_option() { + return; + } + let app_id = std::mem::take(&mut self.selected_game_id); + let value = crate::js::trim(&self.launch_option_input).to_string(); + self.launch_option_input.clear(); + self.launch_option_edits + .retain(|edit| edit.app_id != app_id); + self.launch_option_edits + .push(LaunchOptionDraft { app_id, value }); + } + + /// `removeLaunchOption` (lines 118-120). + pub fn remove_launch_option(&mut self, app_id: &str) { + self.launch_option_edits + .retain(|edit| edit.app_id != app_id); + } + + /// The request `applyEdits` sends (lines 126-131). + pub fn request(&self, selected_ids: &[String]) -> BulkEditRequest { + BulkEditRequest { + steam_ids: selected_ids.to_vec(), + news_popup: self.news_popup.value(), + do_not_disturb: self.do_not_disturb.value(), + launch_options: self + .launch_option_edits + .iter() + .map(|edit| LaunchOptionEdit { + app_id: edit.app_id.clone(), + value: edit.value.clone(), + }) + .collect(), + } + } + + /// `applyEdits` (lines 122-140), up to the backend call (logged by + /// `steamApi.bulkEdit`). + pub fn apply(&mut self, jobs: &Jobs, selected_ids: &[String]) { + if selected_ids.is_empty() || self.apply_pending.is_some() { + return; + } + let request = self.request(selected_ids); + self.apply_pending = Some(jobs.run(move |backend| steam_api::bulk_edit(backend, request))); + } + + /// `handleOverlayKeydown` (lines 142-150). True when the mode closes. + pub fn escape(&mut self) -> bool { + if self.color_menu_open { + self.color_menu_open = false; + return false; + } + if self.step == BarStep::Settings { + self.step = BarStep::Select; + return false; + } + true + } + + /// Applies finished jobs. `Some` once the apply ended: its result, or the + /// error as one `all` failure (line 135). + pub fn poll(&mut self) -> Option { + if let Some(pending) = self.games_pending.as_mut() { + match pending.poll() { + Poll::Waiting => {} + Poll::Ready(result) => { + self.games_pending = None; + self.games = result.unwrap_or_default(); + } + Poll::Gone => self.games_pending = None, + } + } + let pending = self.apply_pending.as_mut()?; + let result = match pending.poll() { + Poll::Waiting => return None, + Poll::Ready(Ok(result)) => result, + Poll::Ready(Err(error)) => failed_everywhere(error.message), + Poll::Gone => failed_everywhere("Bulk edit stopped".to_string()), + }; + self.apply_pending = None; + Some(result) + } +} + +fn failed_everywhere(error: String) -> BulkEditResult { + BulkEditResult { + succeeded: 0, + failed: vec![BulkEditFailure { + steam_id: "all".to_string(), + error, + }], + } +} diff --git a/crates/accshift-app/src/cs2_bridge.rs b/crates/accshift-app/src/cs2_bridge.rs new file mode 100644 index 0000000..6637490 --- /dev/null +++ b/crates/accshift-app/src/cs2_bridge.rs @@ -0,0 +1,208 @@ +//! The operation queue of `src/lib/platforms/steam/cs2Bridge.svelte.ts` +//! (`enqueueBridgeOperation`, `loadCs2BridgeData`'s in-flight dedup and +//! `triggerCs2BridgeCheck`) and the App.svelte effect that loads the bridge +//! when the Steam tab shows (lines 312-315). +//! +//! The cache itself is M1's [`Cs2Bridge`], in the Steam adapter's state; +//! this queue only orders the backend calls on [`Jobs`] and applies their +//! answers to that cache on the UI thread. + +use crate::backend::Backend; +use crate::jobs::{Jobs, Pending, Poll}; +use crate::platforms::api::log_app_event; +use crate::platforms::steam::cs2::{Cs2Bridge, Cs2LoadPlan, Cs2LoadResult}; +use crate::platforms::PlatformAdapter; +use crate::types::Cs2BridgeAccount; +use serde_json::json; +use std::collections::VecDeque; +use std::sync::Arc; + +/// One queued bridge call. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Cs2Operation { + /// `loadCs2BridgeData`. The TTL was checked when it was queued. + Load, + /// `triggerCs2BridgeCheck(steamId)`. + Check(String), +} + +/// What a check got. +#[derive(Debug, Clone)] +pub struct Cs2CheckResult { + /// The settings were read: the enabled flag. + pub enabled: Option, + pub account: Option, +} + +enum Running { + Load(Pending), + Check(Pending), +} + +/// The bridge exposes one shared server-side check queue. Every GET and POST +/// runs one at a time, in order, so a fetch never replaces a freshly checked +/// row and rapid switches never overlap checks. +#[derive(Default)] +pub struct Cs2BridgeQueue { + queue: VecDeque, + running: Option, + /// `inFlight`: a load is queued or running. + load_in_flight: bool, +} + +impl Cs2BridgeQueue { + pub fn new() -> Self { + Self::default() + } + + /// A call is queued or running. + pub fn is_busy(&self) -> bool { + self.running.is_some() || !self.queue.is_empty() + } + + /// `loadCs2BridgeData(force)` (lines 47-87): queues a load unless one is + /// already in flight or the cache is fresh. True when queued. + pub fn load(&mut self, cache: &Cs2Bridge, force: bool, now_ms: f64) -> bool { + if self.load_in_flight { + return false; + } + if cache.begin_load(force, now_ms).is_none() { + return false; + } + self.load_in_flight = true; + self.queue.push_back(Cs2Operation::Load); + true + } + + /// `triggerCs2BridgeCheck(steamId)` (lines 89-115): App.svelte reacts to + /// `LoadingEvent::Cs2Check` with this. + pub fn check(&mut self, steam_id: impl Into) { + self.queue.push_back(Cs2Operation::Check(steam_id.into())); + } + + /// App.svelte lines 312-315: the bridge loads when the Steam tab shows + /// accounts. True when a load was queued. + pub fn ensure_loaded( + &mut self, + cache: &Cs2Bridge, + active_tab: &str, + account_count: usize, + now_ms: f64, + ) -> bool { + if active_tab != "steam" || account_count == 0 { + return false; + } + self.load(cache, false, now_ms) + } + + /// One frame: applies the finished call to the Steam adapter's cache, + /// then starts the next queued one. `steam` is the Steam adapter. + pub fn tick(&mut self, jobs: &Jobs, steam: &Arc, now_ms: f64) { + if let Some(running) = self.running.as_mut() { + let done = match running { + Running::Load(pending) => match pending.poll() { + Poll::Waiting => false, + Poll::Ready(result) => { + steam.state().cs2.finish_load(result, now_ms); + self.load_in_flight = false; + true + } + Poll::Gone => { + self.load_in_flight = false; + true + } + }, + Running::Check(pending) => match pending.poll() { + Poll::Waiting => false, + Poll::Ready(result) => { + let mut state = steam.state(); + if let Some(enabled) = result.enabled { + state.cs2.note_enabled(enabled); + } + if let Some(account) = result.account { + state.cs2.apply_check(account); + } + true + } + Poll::Gone => true, + }, + }; + if !done { + return; + } + self.running = None; + } + let Some(next) = self.queue.pop_front() else { + return; + }; + // The enabled flag is read when the call runs, as the TS closure + // reads `enabledKnown` inside the queued operation. + let plan = steam + .state() + .cs2 + .begin_load(true, now_ms) + .unwrap_or(Cs2LoadPlan { + read_settings: true, + enabled: false, + }); + self.running = Some(match next { + Cs2Operation::Load => { + Running::Load(jobs.run(move |backend| Cs2Bridge::fetch(plan, backend))) + } + Cs2Operation::Check(steam_id) => { + Running::Check(jobs.run(move |backend| check(plan, backend, &steam_id))) + } + }); + } +} + +/// The backend half of `triggerCs2BridgeCheck`. Silent: on failure the +/// periodic read stays the fallback. +pub fn check(plan: Cs2LoadPlan, backend: &dyn Backend, steam_id: &str) -> Cs2CheckResult { + let mut result = Cs2CheckResult { + enabled: None, + account: None, + }; + let enabled = if plan.read_settings { + match backend.cs2_bridge_get_settings() { + Ok(settings) => { + result.enabled = Some(settings.enabled); + settings.enabled + } + Err(error) => { + warn(backend, &error.message); + return result; + } + } + } else { + plan.enabled + }; + if !enabled { + return result; + } + match backend.cs2_bridge_check(steam_id) { + Ok(Some(account)) => { + log_app_event( + backend, + "info", + "frontend.cs2_bridge", + "Bridge check applied", + Some(&json!({ "steamId": account.steam_id, "level": account.level })), + ); + result.account = Some(account); + } + Ok(None) => {} + Err(error) => warn(backend, &error.message), + } + result +} + +fn warn(backend: &dyn Backend, error: &str) { + log_app_event( + backend, + "warn", + "frontend.cs2_bridge", + "Bridge check failed", + Some(&json!({ "error": error })), + ); +} diff --git a/crates/accshift-app/src/lib.rs b/crates/accshift-app/src/lib.rs index c9fe423..9015203 100644 --- a/crates/accshift-app/src/lib.rs +++ b/crates/accshift-app/src/lib.rs @@ -15,9 +15,16 @@ //! and the settings refresh actions, driven through [`jobs`]. //! - [`display`]: folder navigation, search and the flat list of cards a grid //! draws. +//! - [`secure`]: the PIN, the inactivity blur, the window activity and the +//! secure screen; [`streamer`]: streamer mode. +//! - [`bulk_edit`], [`platform_add_flow`], [`platform_shell`] and +//! [`cs2_bridge`]: the Steam bulk edit, the add-account flow, the platform +//! shell state and the CS2 bridge queue. pub mod accounts; pub mod backend; +pub mod bulk_edit; +pub mod cs2_bridge; pub mod dispatch; pub mod display; pub mod error; @@ -29,11 +36,15 @@ pub mod legacy_web_storage; pub mod menu; pub mod mock; pub mod personas; +pub mod platform_add_flow; +pub mod platform_shell; pub mod platforms; pub mod real; +pub mod secure; pub mod settings; pub mod storage; pub mod stores; +pub mod streamer; pub mod text; pub mod time; pub mod types; diff --git a/crates/accshift-app/src/platform_add_flow.rs b/crates/accshift-app/src/platform_add_flow.rs new file mode 100644 index 0000000..ba8b4fc --- /dev/null +++ b/crates/accshift-app/src/platform_add_flow.rs @@ -0,0 +1,769 @@ +//! The add-account flow controller, ported from +//! `src/lib/app/platformAddFlow.svelte.ts` with its App.svelte glue (lines +//! 127-138, 687-690). +//! +//! [`crate::accounts::loading::AccountLoading`] starts a setup and reports it +//! as `LoadingEvent::AddFlowStarted`; this controller then polls it every +//! 1.5 s through the platform adapter, draws the pending card and its card +//! extension, and ends it on ready, failure or cancel. Polls, cancels, +//! telemetry and the profile prime run on [`Jobs`]; what the app must do in +//! return comes back as [`AddFlowEvent`]s. + +use crate::accounts::loader::LoadOptions; +use crate::accounts::profile_cache::{CacheWrite, ProfileCaches}; +use crate::accounts::{Toast, ToastKind}; +use crate::error::{BackendError, BackendResult}; +use crate::i18n::Locale; +use crate::jobs::{Jobs, Pending, Poll}; +use crate::platforms::card::{CardChip, CardLink, CardSection, ChipTone}; +use crate::platforms::{AddFlowState, AddFlowStatus, PlatformAccount, PlatformRegistry}; +use crate::stores::ClientStores; +use crate::text::Text; +use crate::time::Clock; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +/// The `setTimeout` of `schedulePoll` (line 155). +pub const POLL_DELAY: Duration = Duration::from_millis(1500); + +/// The kinds of `getSetupKey` (lines 37-59). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SetupKey { + PendingLabel, + WaitingForClient, + WaitingForLogin, + Detected, + Connected, + Ready, + ReadyWithProfile, + Failed, + FailedMessage, +} + +impl SetupKey { + fn suffix(self) -> &'static str { + match self { + SetupKey::PendingLabel => "PendingLabel", + SetupKey::WaitingForClient => "WaitingForClient", + SetupKey::WaitingForLogin => "WaitingForLogin", + SetupKey::Detected => "Detected", + SetupKey::Connected => "Connected", + SetupKey::Ready => "Ready", + SetupKey::ReadyWithProfile => "ReadyWithProfile", + SetupKey::Failed => "Failed", + SetupKey::FailedMessage => "FailedMessage", + } + } +} + +/// `getSetupKey`: `.setup`, battle-net under `battlenet`. +pub fn setup_key(platform_id: &str, kind: SetupKey) -> String { + let prefix = match platform_id { + "battle-net" => "battlenet", + other => other, + }; + format!("{prefix}.setup{}", kind.suffix()) +} + +/// `PlatformAddFlowEntry`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AddFlowEntry { + pub platform_id: String, + pub status: AddFlowStatus, +} + +/// What the flow reads from the app (`getActiveTab`, `getCurrentFolderId`, +/// `getIsSearching`). +#[derive(Debug, Clone, Copy)] +pub struct AddFlowView<'a> { + pub active_tab: &'a str, + pub current_folder_id: Option<&'a str>, + pub is_searching: bool, +} + +/// What the flow needs to run its jobs. +pub struct AddFlowContext<'a> { + pub jobs: &'a Jobs, + pub registry: &'a PlatformRegistry, + pub view: AddFlowView<'a>, + /// The profile caches the account loader uses, for the prime after an + /// add (`primeProfileAfterAdd`). + pub caches: &'a Arc, + pub clock: &'a Clock, + pub now: Instant, +} + +/// What the app does in return. +#[derive(Debug, Clone, PartialEq)] +pub enum AddFlowEvent { + /// `showToast(message)`. + Toast(Toast), + /// `loadAccounts(...)` on the active tab. + LoadAccounts(LoadOptions), + /// `onAccountAdded`: App.svelte opens the rename prompt + /// (`dialogs.promptRenameNewAccount`, lines 135-137). + AccountAdded { + platform_id: String, + account_id: String, + display_name: Option, + }, +} + +/// The pending card (`pendingSetupAccount`, lines 87-101). Texts resolve at +/// draw time; [`PendingSetupAccount::to_account`] gives the card's account. +#[derive(Debug, Clone, PartialEq)] +pub struct PendingSetupAccount { + pub id: String, + pub display_name: Text, + pub username: Text, +} + +impl PendingSetupAccount { + pub fn to_account(&self, locale: Locale) -> PlatformAccount { + PlatformAccount { + id: self.id.clone(), + display_name: self.display_name.resolve(locale), + username: self.username.resolve(locale), + last_login_at_sec: None, + } + } +} + +/// What a chip of the setup card does (the TS `onClick`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SetupChipAction { + /// The red Close chip: [`PlatformAddFlow::cancel`]. + Cancel, + /// Roblox's "Copy code": put the quick login code on the clipboard + /// (`copyToClipboard(text, text)`, App.svelte line 133). + CopyCode(String), +} + +/// `getSetupExtensionContent`: the sections and which chips act. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct SetupExtension { + pub sections: Vec, + /// `((section, chip), action)` for the chips that have an `onClick`. + pub chip_actions: Vec<((usize, usize), SetupChipAction)>, +} + +impl SetupExtension { + /// The action of chip `chip` in section `section`, if it has one. + pub fn chip_action(&self, section: usize, chip: usize) -> Option<&SetupChipAction> { + self.chip_actions + .iter() + .find(|(at, _)| *at == (section, chip)) + .map(|(_, action)| action) + } +} + +type PrimeJob = Pending<(String, Vec)>; + +/// `createPlatformAddFlowController`. +#[derive(Default)] +pub struct PlatformAddFlow { + flow: Option, + poll_deadline: Option, + /// The setup id the poll was sent for, and its answer. + poll: Option<(String, Pending>)>, + /// The flow being cancelled, and the cancel's answer. + cancel: Option<(AddFlowEntry, Pending>)>, + primes: Vec, + events: Vec, +} + +impl PlatformAddFlow { + pub fn new() -> Self { + Self::default() + } + + /// `flow`. + pub fn flow(&self) -> Option<&AddFlowEntry> { + self.flow.as_ref() + } + + /// The `add_flow_active` flag `AccountLoading::handle_add_account_click` + /// takes. + pub fn is_active(&self) -> bool { + self.flow.is_some() + } + + /// Jobs in flight. + pub fn is_busy(&self) -> bool { + self.poll.is_some() || self.cancel.is_some() || !self.primes.is_empty() + } + + /// When [`Self::tick`] next has work. + pub fn next_deadline(&self) -> Option { + self.poll_deadline + } + + /// `clearTimer` (lines 109-113). + pub fn clear_timer(&mut self) { + self.poll_deadline = None; + } + + /// `stop` (lines 115-118). + pub fn stop(&mut self) { + self.clear_timer(); + self.flow = None; + } + + fn schedule_poll(&mut self, now: Instant) { + self.poll_deadline = Some(now + POLL_DELAY); + } + + /// `start` (lines 228-245): a setup the account loader just began. + pub fn start(&mut self, ctx: &AddFlowContext<'_>, platform_id: &str, status: AddFlowStatus) { + self.flow = Some(AddFlowEntry { + platform_id: platform_id.to_string(), + status: status.clone(), + }); + // The opening is the denominator of the funnel. + track(ctx.jobs, platform_id, |backend, id| { + backend.telemetry_track_account_add_started(id) + }); + match status.state() { + // Some setups finish at once (Discord adopting the session). + AddFlowState::Ready => self.handle_ready(ctx, platform_id, &status), + AddFlowState::Failed => report_add_failure(ctx.jobs, platform_id), + _ => self.schedule_poll(ctx.now), + } + } + + /// `poll` (lines 158-194), up to the backend call: asks the adapter for + /// the setup's status now. Ends the flow when its platform has no + /// adapter. + pub fn poll_now(&mut self, ctx: &AddFlowContext<'_>) { + self.clear_timer(); + let Some(current) = self.flow.clone() else { + self.stop(); + return; + }; + let Some(adapter) = ctx.registry.adapter(¤t.platform_id) else { + self.stop(); + return; + }; + let setup_id = current.status.setup_id.clone(); + let id = setup_id.clone(); + self.poll = Some(( + setup_id, + ctx.jobs + .run(move |backend| adapter.poll_add_flow(backend, &id)), + )); + } + + /// The end of `poll`. + fn apply_poll( + &mut self, + ctx: &AddFlowContext<'_>, + setup_id: &str, + result: BackendResult, + ) { + let Some(flow) = self.flow.as_mut() else { + return; + }; + if flow.status.setup_id != setup_id { + return; + } + let platform_id = flow.platform_id.clone(); + match result { + Ok(next) => { + flow.status = next.clone(); + match next.state() { + AddFlowState::Ready => self.handle_ready(ctx, &platform_id, &next), + AddFlowState::Failed => report_add_failure(ctx.jobs, &platform_id), + _ => self.schedule_poll(ctx.now), + } + } + Err(error) => { + report_add_failure(ctx.jobs, &platform_id); + flow.status.state = "failed".to_string(); + flow.status.error_message = Some(error.message); + } + } + } + + /// `handleReady` (lines 206-226). + fn handle_ready( + &mut self, + ctx: &AddFlowContext<'_>, + platform_id: &str, + status: &AddFlowStatus, + ) { + track(ctx.jobs, platform_id, |backend, id| { + backend.telemetry_track_account_added(id) + }); + let adapter = ctx.registry.adapter(platform_id); + let prime = ctx + .registry + .definition(platform_id) + .is_some_and(|def| def.capabilities.prime_profile_after_add); + if let (true, Some(adapter), Some(account_id)) = + (prime, adapter.clone(), status.account_id.clone()) + { + let caches = Arc::clone(ctx.caches); + let clock = ctx.clock.clone(); + let id = platform_id.to_string(); + self.primes.push(ctx.jobs.run(move |backend| { + let fetched = + caches.profile_info(&adapter, backend, &account_id, &|| clock.now_ms()); + (id, fetched.writes) + })); + } + if ctx.view.active_tab == platform_id { + self.events.push(AddFlowEvent::LoadAccounts(LoadOptions { + silent: true, + ..LoadOptions::default() + })); + } + let text = match status.account_display_name.as_deref() { + Some(profile) if !profile.is_empty() => Text::with( + setup_key(platform_id, SetupKey::ReadyWithProfile), + [("profile", profile)], + ), + _ => Text::key(setup_key(platform_id, SetupKey::Ready)), + }; + self.events + .push(AddFlowEvent::Toast(Toast::new(text, ToastKind::Default))); + self.stop(); + if let (Some(adapter), Some(account_id)) = (adapter, status.account_id.as_deref()) { + if adapter.supports_account_labels() && !account_id.is_empty() { + self.events.push(AddFlowEvent::AccountAdded { + platform_id: platform_id.to_string(), + account_id: account_id.to_string(), + display_name: status.account_display_name.clone(), + }); + } + } + } + + /// `cancel` (lines 120-141), up to the backend call. The poll timer stops + /// at once, so no poll races the cancel. + pub fn cancel(&mut self, ctx: &AddFlowContext<'_>) { + let Some(current) = self.flow.clone() else { + self.stop(); + return; + }; + // Counted whether or not the adapter has anything to cancel: the + // funnel measures the user walking away. + track(ctx.jobs, ¤t.platform_id, |backend, id| { + backend.telemetry_track_account_add_cancelled(id) + }); + let Some(adapter) = ctx.registry.adapter(¤t.platform_id) else { + self.stop(); + return; + }; + if self.cancel.is_some() { + return; + } + self.clear_timer(); + let setup_id = current.status.setup_id.clone(); + self.cancel = Some(( + current, + ctx.jobs + .run(move |backend| adapter.cancel_add_flow(backend, &setup_id)), + )); + } + + /// `cancelIfConflicting` (lines 143-149): a switch, a back navigation or + /// a palette jump on the flow's platform drops it, unless the target is + /// the setup account itself. + pub fn cancel_if_conflicting( + &mut self, + ctx: &AddFlowContext<'_>, + target_platform_id: &str, + target_account_id: Option<&str>, + ) { + let Some(current) = self.flow.as_ref() else { + return; + }; + if current.platform_id != target_platform_id { + return; + } + if target_account_id.is_some_and(|id| !id.is_empty() && current.status.setup_id == id) { + return; + } + self.cancel(ctx); + } + + fn apply_cancel( + &mut self, + ctx: &AddFlowContext<'_>, + entry: AddFlowEntry, + result: BackendResult<()>, + ) { + if let Err(BackendError { message, .. }) = result { + self.events.push(AddFlowEvent::Toast(Toast::new( + Text::raw(message), + ToastKind::Default, + ))); + } + self.stop(); + if ctx.view.active_tab == entry.platform_id { + self.events.push(AddFlowEvent::LoadAccounts(LoadOptions { + silent: true, + ..LoadOptions::default() + })); + } + } + + /// One frame: applies finished polls, cancels and primes, then sends the + /// next poll when due. + pub fn tick( + &mut self, + ctx: &AddFlowContext<'_>, + stores: &mut ClientStores, + ) -> Vec { + if let Some((setup_id, pending)) = self.poll.as_mut() { + let answer = match pending.poll() { + Poll::Waiting => None, + Poll::Ready(result) => Some(result), + Poll::Gone => Some(Err(BackendError::new("Setup status check stopped"))), + }; + if let Some(result) = answer { + let setup_id = setup_id.clone(); + self.poll = None; + self.apply_poll(ctx, &setup_id, result); + } + } + if let Some((_, pending)) = self.cancel.as_mut() { + let answer = match pending.poll() { + Poll::Waiting => None, + Poll::Ready(result) => Some(result), + Poll::Gone => Some(Ok(())), + }; + if let Some(result) = answer { + if let Some((entry, _)) = self.cancel.take() { + self.apply_cancel(ctx, entry, result); + } + } + } + let caches = ctx.caches; + self.primes.retain_mut(|pending| match pending.poll() { + Poll::Waiting => true, + Poll::Ready((platform_id, writes)) => { + caches.apply_writes(&platform_id, stores.storage_mut(), writes); + false + } + Poll::Gone => false, + }); + if self.poll_deadline.is_some_and(|due| ctx.now >= due) && self.poll.is_none() { + self.poll_now(ctx); + } + std::mem::take(&mut self.events) + } + + // ------------------------------------------------------------ views + + fn shown_on(&self, view: &AddFlowView<'_>) -> Option<&AddFlowEntry> { + let flow = self.flow.as_ref()?; + if view.active_tab != flow.platform_id { + return None; + } + if view.is_searching || view.current_folder_id.is_some_and(|id| !id.is_empty()) { + return None; + } + Some(flow) + } + + /// `pendingSetupAccount` (lines 87-101): the placeholder card at the + /// root of the flow's tab, until the setup is ready. + pub fn pending_setup_account(&self, view: &AddFlowView<'_>) -> Option { + let flow = self.shown_on(view)?; + if flow.status.state() == AddFlowState::Ready { + return None; + } + let setup_id = crate::js::trim(&flow.status.setup_id); + if setup_id.is_empty() { + return None; + } + let detected = crate::js::trim( + flow.status + .account_display_name + .as_deref() + .unwrap_or_default(), + ); + let (display_name, username) = if detected.is_empty() { + ( + Text::key("platform.newAccountPending"), + pending_username(flow), + ) + } else { + ( + Text::raw(detected), + Text::key(setup_key(&flow.platform_id, SetupKey::Connected)), + ) + }; + Some(PendingSetupAccount { + id: setup_id.to_string(), + display_name, + username, + }) + } + + /// `isPendingSetupAccount` (lines 457-459). + pub fn is_pending_setup_account(&self, view: &AddFlowView<'_>, account_id: &str) -> bool { + self.pending_setup_account(view) + .is_some_and(|pending| pending.id == account_id) + } + + /// `pendingSetupAccountId` (App.svelte line 687). + pub fn pending_setup_account_id(&self, view: &AddFlowView<'_>) -> Option { + self.pending_setup_account(view).map(|pending| pending.id) + } + + /// `activePlatformAddSetupId` (App.svelte lines 688-690): the setup id + /// when the flow belongs to the active tab. + pub fn active_platform_setup_id(&self, active_tab: &str) -> Option<&str> { + self.flow + .as_ref() + .filter(|flow| flow.platform_id == active_tab) + .map(|flow| flow.status.setup_id.as_str()) + } + + /// `isForcedOpen` (lines 448-455): the setup card stays expanded. + pub fn is_forced_open(&self, active_tab: &str, account_id: &str) -> bool { + self.flow.as_ref().is_some_and(|flow| { + flow.platform_id == active_tab + && flow.status.setup_id == account_id + && flow.status.state() != AddFlowState::Ready + }) + } + + /// `getSetupExtensionContent` (lines 247-446). `locale` resolves the + /// plain `lines`, which the card model keeps as strings. + pub fn setup_extension_content( + &self, + account_id: &str, + locale: Locale, + ) -> Option { + let flow = self.flow.as_ref()?; + let setup_id = crate::js::trim(&flow.status.setup_id); + if setup_id.is_empty() || setup_id != account_id { + return None; + } + Some(setup_extension(flow, locale)).flatten() + } +} + +/// The one line the card shows while nothing is detected (lines 104-107). +fn pending_username(entry: &AddFlowEntry) -> Text { + if entry.status.state() == AddFlowState::Busy { + Text::key("platform.setupBusy") + } else { + Text::key(setup_key(&entry.platform_id, SetupKey::WaitingForLogin)) + } +} + +fn track(jobs: &Jobs, platform_id: &str, call: fn(&dyn crate::Backend, &str) -> BackendResult<()>) { + // Fire and forget (`telemetryClient.track`): the mock and an unconsented + // backend refuse, nobody cares. + let id = platform_id.to_string(); + drop(jobs.run(move |backend| { + let _ = call(backend, &id); + })); +} + +/// `reportAddFailure` (lines 202-204): the adapters report a message, not a +/// typed kind, so the code is `other`. +fn report_add_failure(jobs: &Jobs, platform_id: &str) { + let id = platform_id.to_string(); + drop(jobs.run(move |backend| { + let _ = backend.telemetry_track_operation_failed("account_add", "other", Some(&id)); + })); +} + +fn section_text(text: Text, loading: bool) -> CardSection { + CardSection { + text: Some(text), + loading, + ..CardSection::default() + } +} + +fn section_lines(lines: Vec) -> CardSection { + CardSection { + lines, + ..CardSection::default() + } +} + +fn failed_section(platform_id: &str, text: Text) -> CardSection { + CardSection { + title: Some(Text::key(setup_key(platform_id, SetupKey::Failed))), + text: Some(text), + ..CardSection::default() + } +} + +/// `createDetectedSection` (lines 61-72). +fn detected_section(platform_id: &str, display: &str) -> Option { + if display.is_empty() { + return None; + } + Some(CardSection { + title: Some(Text::key(setup_key(platform_id, SetupKey::Detected))), + text: Some(Text::raw(display)), + chips: vec![CardChip { + text: Text::key(setup_key(platform_id, SetupKey::Connected)), + tone: ChipTone::Green, + }], + ..CardSection::default() + }) +} + +fn close_chip() -> CardChip { + CardChip { + text: Text::key("common.close"), + tone: ChipTone::Red, + } +} + +/// The failure section with the error lines and the Close chip +/// (`{ ...(error ? { lines: [error] } : {}), chips: [closeChip] }`). +fn close_section(error: &str) -> CardSection { + CardSection { + lines: if error.is_empty() { + Vec::new() + } else { + vec![error.to_string()] + }, + chips: vec![close_chip()], + ..CardSection::default() + } +} + +fn setup_extension(flow: &AddFlowEntry, locale: Locale) -> Option { + let platform = flow.platform_id.as_str(); + let display = crate::js::trim( + flow.status + .account_display_name + .as_deref() + .unwrap_or_default(), + ); + let error = crate::js::trim(flow.status.error_message.as_deref().unwrap_or_default()); + let detected = detected_section(platform, display); + let state = flow.status.state(); + let line = |key: &str| Text::key(key).resolve(locale); + let mut actions = Vec::new(); + + // Before the per-platform branches: the cross-process lock is not a + // platform concern. Non-terminal, so the spinner stays on. + let sections: Vec = if state == AddFlowState::Busy { + std::iter::once(section_text(Text::key("platform.setupBusy"), true)) + .chain(detected) + .collect() + } else { + match (platform, state) { + (_, AddFlowState::Ready) => return None, + ("riot", AddFlowState::WaitingForClient) => vec![ + section_text(Text::key("riot.setupWaitingForClient"), true), + section_lines(vec![line("riot.setupStaySignedIn")]), + ], + ("riot", AddFlowState::Capturing) => { + std::iter::once(section_text(Text::key("riot.setupCapturing"), true)) + .chain(detected) + .collect() + } + ("riot", AddFlowState::Failed) => { + actions.push(((1, 0), SetupChipAction::Cancel)); + vec![ + failed_section(platform, Text::key("riot.setupFailedMessage")), + close_section(error), + ] + } + ("riot", _) => { + let first = if error.is_empty() { + section_text(Text::key("riot.setupWaitingForLogin"), true) + } else { + section_text(Text::raw(error), false) + }; + let second = + detected.unwrap_or_else(|| section_lines(vec![line("riot.setupStaySignedIn")])); + vec![first, second] + } + ("roblox", AddFlowState::Failed) => { + actions.push(((1, 0), SetupChipAction::Cancel)); + let text = if error.is_empty() { + Text::key("roblox.setupFailedMessage") + } else { + Text::raw(error) + }; + vec![ + failed_section(platform, text), + CardSection { + chips: vec![close_chip()], + ..CardSection::default() + }, + ] + } + ("roblox", _) => { + let mut chips = vec![CardChip { + text: Text::raw("Quick Login"), + tone: ChipTone::Green, + }]; + if !display.is_empty() { + actions.push(((0, 1), SetupChipAction::CopyCode(display.to_string()))); + chips.push(CardChip { + text: Text::key("roblox.copyCode"), + tone: ChipTone::Slate, + }); + } + vec![CardSection { + link: Some(CardLink { + label: Text::raw("roblox.com/quick-login"), + url: "https://www.roblox.com/crossdevicelogin/ConfirmCode".to_string(), + }), + chips, + loading: true, + ..CardSection::default() + }] + } + ("battle-net", AddFlowState::WaitingForClient) => vec![section_text( + Text::key("battlenet.setupWaitingForClient"), + true, + )], + ("battle-net", AddFlowState::Failed) => { + actions.push(((1, 0), SetupChipAction::Cancel)); + vec![ + failed_section(platform, Text::key("battlenet.setupFailedMessage")), + close_section(error), + ] + } + ("battle-net", _) => std::iter::once(section_text( + Text::key("battlenet.setupWaitingForLogin"), + true, + )) + .chain(detected) + .chain(std::iter::once(section_lines(vec![line( + "battlenet.setupKeepMeLoggedIn", + )]))) + .collect(), + (_, AddFlowState::WaitingForClient) => vec![section_text( + Text::key(setup_key(platform, SetupKey::WaitingForClient)), + true, + )], + (_, AddFlowState::Failed) => { + actions.push(((1, 0), SetupChipAction::Cancel)); + vec![ + failed_section( + platform, + Text::key(setup_key(platform, SetupKey::FailedMessage)), + ), + close_section(error), + ] + } + _ => std::iter::once(section_text( + Text::key(setup_key(platform, SetupKey::WaitingForLogin)), + true, + )) + .chain(detected) + .collect(), + } + }; + Some(SetupExtension { + sections, + chip_actions: actions, + }) +} diff --git a/crates/accshift-app/src/platform_shell.rs b/crates/accshift-app/src/platform_shell.rs new file mode 100644 index 0000000..574dfe4 --- /dev/null +++ b/crates/accshift-app/src/platform_shell.rs @@ -0,0 +1,194 @@ +//! The platform shell state, ported from `src/lib/app/platformShell.svelte.ts` +//! with the App.svelte effect that keeps the active tab usable (lines +//! 692-698). +//! +//! The Svelte `$derived` values are methods computed from the settings, the +//! runtime OS and the active tab; `adapterEpoch` only invalidated the derived +//! adapter, which [`PlatformShell::adapter`] reads fresh each call. + +use crate::accounts::loading::is_platform_usable; +use crate::i18n::Locale; +use crate::platforms::{PlatformAdapter, PlatformDef, PlatformRegistry, RuntimeOs}; +use crate::settings::AppSettings; +use std::collections::BTreeSet; +use std::sync::Arc; + +/// `isPlatformCompatibleWithOs` (lines 10-16). +pub fn is_platform_compatible_with_os(platform: Option<&PlatformDef>, os: RuntimeOs) -> bool { + platform.is_some_and(|def| def.supports(os)) +} + +/// `getInitialActiveTab` (lines 23-37): the default platform when enabled and +/// usable, else the first enabled usable one, else the first usable one, else +/// the first enabled one, else the first shipped one, else `steam`. +pub fn initial_active_tab( + registry: &PlatformRegistry, + settings: &AppSettings, + os: RuntimeOs, +) -> String { + let usable = |id: &str| is_platform_usable(registry, id, os); + if settings + .enabled_platforms + .contains(&settings.default_platform_id) + && usable(&settings.default_platform_id) + { + return settings.default_platform_id.clone(); + } + if let Some(id) = settings.enabled_platforms.iter().find(|id| usable(id)) { + return id.clone(); + } + if let Some(def) = registry.defs().iter().find(|def| usable(&def.id)) { + return def.id.clone(); + } + settings + .enabled_platforms + .first() + .filter(|id| !id.is_empty()) + .cloned() + .or_else(|| registry.defs().first().map(|def| def.id.clone())) + .unwrap_or_else(|| "steam".to_string()) +} + +/// `createPlatformShellState`. +#[derive(Debug, Clone)] +pub struct PlatformShell { + settings: AppSettings, + runtime_os: RuntimeOs, + active_tab: String, +} + +impl PlatformShell { + /// The OS starts `unknown` until the backend answers, as in TS (line 42). + pub fn new(registry: &PlatformRegistry, settings: AppSettings) -> Self { + let active_tab = initial_active_tab(registry, &settings, RuntimeOs::Unknown); + Self { + settings, + runtime_os: RuntimeOs::Unknown, + active_tab, + } + } + + pub fn settings(&self) -> &AppSettings { + &self.settings + } + + /// The `settings` setter. + pub fn set_settings(&mut self, settings: AppSettings) { + self.settings = settings; + } + + /// `refreshSettings`: takes the store's latest settings. + pub fn refresh_settings(&mut self, settings: &AppSettings) { + self.settings = settings.clone(); + } + + pub fn runtime_os(&self) -> RuntimeOs { + self.runtime_os + } + + /// `setRuntimeOs`. + pub fn set_runtime_os(&mut self, os: RuntimeOs) { + self.runtime_os = os; + } + + pub fn active_tab(&self) -> &str { + &self.active_tab + } + + /// `setActiveTab`. + pub fn set_active_tab(&mut self, tab: impl Into) { + self.active_tab = tab.into(); + } + + /// `locale`. + pub fn locale(&self) -> Locale { + self.settings.language + } + + /// `enabledPlatforms`: the shipped order, filtered by the settings. + pub fn enabled_platforms<'a>(&self, registry: &'a PlatformRegistry) -> Vec<&'a PlatformDef> { + registry + .defs() + .iter() + .filter(|def| self.settings.enabled_platforms.contains(&def.id)) + .collect() + } + + /// `compatiblePlatforms`: every usable platform on this OS. + pub fn compatible_platforms<'a>(&self, registry: &'a PlatformRegistry) -> Vec<&'a PlatformDef> { + registry + .defs() + .iter() + .filter(|def| is_platform_usable(registry, &def.id, self.runtime_os)) + .collect() + } + + /// `activePlatformDef`. + pub fn active_platform_def<'a>( + &self, + registry: &'a PlatformRegistry, + ) -> Option<&'a PlatformDef> { + registry.definition(&self.active_tab) + } + + /// `activeTabUsable`. + pub fn active_tab_usable(&self, registry: &PlatformRegistry) -> bool { + is_platform_usable(registry, &self.active_tab, self.runtime_os) + } + + /// `unavailablePlatformIds`: enabled platforms this OS cannot use. + pub fn unavailable_platform_ids(&self, registry: &PlatformRegistry) -> BTreeSet { + self.enabled_platforms(registry) + .into_iter() + .filter(|def| !is_platform_usable(registry, &def.id, self.runtime_os)) + .map(|def| def.id.clone()) + .collect() + } + + /// `accentColor`: the active platform's accent; `None` means the theme's + /// `--accent`. + pub fn accent_color(&self, registry: &PlatformRegistry) -> Option { + self.active_platform_def(registry) + .map(|def| def.accent.clone()) + .filter(|accent| !accent.is_empty()) + } + + /// The `themeId` `activeTheme` resolves. + pub fn theme_id(&self) -> &str { + &self.settings.theme_id + } + + /// `uiZoomFactor`: `uiScalePercent / 100`, held in 0.75..=1.5. + pub fn ui_zoom_factor(&self) -> f64 { + (self.settings.ui_scale_percent as f64 / 100.0).clamp(0.75, 1.5) + } + + /// `appStageStyle`: the stage scale, `None` at 100 %. + pub fn app_stage_zoom(&self) -> Option { + let zoom = self.ui_zoom_factor(); + ((zoom - 1.0).abs() >= 0.0001).then_some(zoom) + } + + /// `adapter`: the active tab's adapter while that tab is usable. + pub fn adapter(&self, registry: &PlatformRegistry) -> Option> { + if !self.active_tab_usable(registry) { + return None; + } + registry.adapter(&self.active_tab) + } + + /// `ensureActiveTab` (lines 93-99): moves off a tab this OS cannot use. + /// True when the tab changed. App.svelte runs it once the OS is known and + /// whenever the settings or the tab change (lines 692-698). + pub fn ensure_active_tab(&mut self, registry: &PlatformRegistry) -> bool { + if is_platform_usable(registry, &self.active_tab, self.runtime_os) { + return false; + } + let fallback = initial_active_tab(registry, &self.settings, self.runtime_os); + if fallback == self.active_tab { + return false; + } + self.active_tab = fallback; + true + } +} diff --git a/crates/accshift-app/src/platforms/steam/cs2.rs b/crates/accshift-app/src/platforms/steam/cs2.rs index 9c575a8..4dc0564 100644 --- a/crates/accshift-app/src/platforms/steam/cs2.rs +++ b/crates/accshift-app/src/platforms/steam/cs2.rs @@ -174,6 +174,12 @@ impl Cs2Bridge { } } + /// Records the enabled flag a bridge check read from the settings + /// (`enabledKnown = true` in `triggerCs2BridgeCheck`). + pub fn note_enabled(&mut self, enabled: bool) { + self.enabled = Some(enabled); + } + /// `triggerCs2BridgeCheck` result: merges a re-checked row. pub fn apply_check(&mut self, account: Cs2BridgeAccount) { self.data.insert(account.steam_id.clone(), account); diff --git a/crates/accshift-app/src/secure/inactivity.rs b/crates/accshift-app/src/secure/inactivity.rs new file mode 100644 index 0000000..aa229ec --- /dev/null +++ b/crates/accshift-app/src/secure/inactivity.rs @@ -0,0 +1,153 @@ +//! The inactivity timer, ported from `src/lib/shared/useInactivityBlur.svelte.ts`. +//! +//! The TS version arms a `setTimeout` and listens to `mousemove`, `mousedown` +//! and `keydown` on the document. Here the timer is a deadline the UI checks +//! each frame ([`InactivityBlur::tick`], [`InactivityBlur::next_deadline`]), +//! and the UI feeds the three input events while the listeners are +//! attached. The threshold is passed in on every call, as TS reads +//! `peekSettings().inactivityBlurSeconds` each time. Drawing the blur is UI. + +use std::time::{Duration, Instant}; + +/// `POINTER_ACTIVITY_THROTTLE_MS` (line 4). +pub const POINTER_ACTIVITY_THROTTLE: Duration = Duration::from_millis(200); + +fn threshold_of(seconds: i64) -> Option { + (seconds > 0).then(|| Duration::from_secs(seconds as u64)) +} + +/// `createInactivityBlur`. +#[derive(Debug, Clone)] +pub struct InactivityBlur { + last_activity: Instant, + /// `None` is the TS `0`: the first pointer event always counts. + last_pointer_activity: Option, + blurred: bool, + /// The armed `blurTimeoutId`. + deadline: Option, + /// `attachListeners` ran and `detachListeners` did not since. + listening: bool, +} + +impl InactivityBlur { + /// `lastActivity = Date.now()` at creation. + pub fn new(now: Instant) -> Self { + Self { + last_activity: now, + last_pointer_activity: None, + blurred: false, + deadline: None, + listening: false, + } + } + + /// `isBlurred`. + pub fn is_blurred(&self) -> bool { + self.blurred + } + + /// Whether the UI should feed input events. + pub fn is_listening(&self) -> bool { + self.listening + } + + /// When [`Self::tick`] next has work, for the repaint request. + pub fn next_deadline(&self) -> Option { + self.deadline + } + + /// `scheduleBlurCheck` (lines 18-41). + fn schedule(&mut self, now: Instant, threshold_secs: i64) { + self.deadline = None; + let Some(threshold) = threshold_of(threshold_secs) else { + self.blurred = false; + return; + }; + let elapsed = now.saturating_duration_since(self.last_activity); + self.deadline = Some(now + threshold.saturating_sub(elapsed)); + } + + /// The timeout callback (lines 27-39). True when `is_blurred` changed. + pub fn tick(&mut self, now: Instant, threshold_secs: i64) -> bool { + match self.deadline { + Some(deadline) if now >= deadline => {} + _ => return false, + } + self.deadline = None; + let before = self.blurred; + match threshold_of(threshold_secs) { + None => self.blurred = false, + Some(threshold) if now.saturating_duration_since(self.last_activity) >= threshold => { + self.blurred = true; + } + Some(_) => self.schedule(now, threshold_secs), + } + before != self.blurred + } + + /// `resetActivity` (lines 43-49). + pub fn reset_activity(&mut self, now: Instant, threshold_secs: i64) { + self.last_activity = now; + self.blurred = false; + self.schedule(now, threshold_secs); + } + + /// `resetPointerActivity` (lines 51-58): at most one reset per 200 ms. + pub fn reset_pointer_activity(&mut self, now: Instant, threshold_secs: i64) { + if let Some(last) = self.last_pointer_activity { + if now.saturating_duration_since(last) < POINTER_ACTIVITY_THROTTLE { + return; + } + } + self.last_pointer_activity = Some(now); + self.reset_activity(now, threshold_secs); + } + + /// `start` (lines 60-70). + pub fn start(&mut self, now: Instant, threshold_secs: i64) { + self.stop(); + if threshold_secs == 0 { + self.blurred = false; + return; + } + self.last_activity = now; + self.last_pointer_activity = Some(now); + self.schedule(now, threshold_secs); + } + + /// `stop` (lines 72-74): disarms the timer, keeps `is_blurred`. + pub fn stop(&mut self) { + self.deadline = None; + } + + /// `attachListeners` (lines 76-80). + pub fn attach_listeners(&mut self) { + self.listening = true; + } + + /// `detachListeners` (lines 82-86). + pub fn detach_listeners(&mut self) { + self.listening = false; + } + + /// A `mousemove` on the window. Ignored while detached. + pub fn on_pointer_move(&mut self, now: Instant, threshold_secs: i64) { + if self.listening { + self.reset_pointer_activity(now, threshold_secs); + } + } + + /// A `mousedown` on the window. Ignored while detached. + pub fn on_mouse_down(&mut self, now: Instant, threshold_secs: i64) { + if self.listening { + self.reset_activity(now, threshold_secs); + } + } + + /// A `keydown` on the window. Ignored while detached. + pub fn on_key_down(&mut self, now: Instant, threshold_secs: i64) { + if self.listening { + self.reset_activity(now, threshold_secs); + } + } +} diff --git a/crates/accshift-app/src/secure/mod.rs b/crates/accshift-app/src/secure/mod.rs new file mode 100644 index 0000000..811668c --- /dev/null +++ b/crates/accshift-app/src/secure/mod.rs @@ -0,0 +1,60 @@ +//! The security layer of the app: the PIN, the inactivity timer, the window +//! activity state and the secure screen that combines them. +//! +//! - [`pin`]: `pin.ts` and `pinSession.ts` over `accshift_core::pin`. +//! - The inactivity timer of `useInactivityBlur.svelte.ts` is +//! [`InactivityBlur`]. +//! - The state machine of `useWindowActivity.svelte.ts` is +//! [`WindowActivity`]. +//! - [`SecureScreen`]: `useSecureScreen.svelte.ts` plus the logic of +//! `AppScreenOverlays.svelte`. +//! - [`ScreenCover`]: what App.svelte hides behind the secure screen and the +//! streamer overlay. + +pub mod inactivity; +pub mod pin; +pub mod screen; +pub mod window_activity; + +pub use inactivity::InactivityBlur; +pub use screen::{SecureEvent, SecureInputs, SecureScreen, WindowView}; +pub use window_activity::WindowActivity; + +/// What the secure screen and streamer mode take away from the app, as +/// App.svelte applies it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ScreenCover { + /// `.app-stage.locked` (line 783): pointer events off under the PIN. + pub stage_locked: bool, + /// `.app-shell.obscured` (line 789): the app is blurred. + pub shell_obscured: bool, + /// `inert` on `.app-shell` (line 790): no focus, no click, no key reaches + /// the app. + pub shell_inert: bool, + /// `.inactivity-frost.visible` (line 822). + pub frost_visible: bool, + /// The keyboard scope is `locked` (line 561): only the overlay keys. + pub keys_locked: bool, + /// `isGridHidden` for visible priming (line 426): no avatar priming. + pub grid_hidden: bool, + /// Titlebar, panels and onboarding are not drawn (`renderSuspended`, + /// lines 780, 816, 935, 979). + pub render_suspended: bool, +} + +impl ScreenCover { + /// `streamer_active` is [`crate::streamer::StreamerModeController::active`], + /// `settings_open` is `settingsPanel.showSettings`. + pub fn compute(secure: &SecureScreen, streamer_active: bool, settings_open: bool) -> Self { + let suspended = secure.render_suspended(); + Self { + stage_locked: secure.is_pin_locked(), + shell_obscured: secure.is_obscured() || streamer_active, + shell_inert: secure.is_pin_locked() || streamer_active, + frost_visible: !suspended && secure.is_obscured(), + keys_locked: secure.is_pin_locked() || streamer_active || suspended, + grid_hidden: settings_open || !secure.window_foreground() || suspended, + render_suspended: suspended, + } + } +} diff --git a/crates/accshift-app/src/secure/pin.rs b/crates/accshift-app/src/secure/pin.rs new file mode 100644 index 0000000..ec28eba --- /dev/null +++ b/crates/accshift-app/src/secure/pin.rs @@ -0,0 +1,158 @@ +//! The PIN helpers of the frontend, ported from `src/lib/shared/pin.ts` and +//! `src/lib/shared/pinSession.ts`. +//! +//! Hashing and verification are `accshift_core::pin`, the very code the CLI +//! and the backend run (PBKDF2-HMAC-SHA256, 100 000 rounds, 16-byte salt, +//! `salt_hex:hash_hex`, legacy unsalted SHA-256 accepted for migration): the +//! TS file was a second copy of it and pin.test.ts pins both on the same +//! vectors. This module adds the frontend's view on top: the +//! `{ matches, rehashed }` verdict, the backend unlock with its local +//! fallback, and the wait after a wrong code. +//! +//! Nothing here logs or prints a PIN. PBKDF2 blocks for a while: run +//! [`unlock_pin_session`] and [`verify_pin_code`] on the job pool. + +use crate::backend::Backend; +use crate::error::BackendError; +use accshift_core::pin::{self as core_pin, PinVerdict}; + +pub use accshift_core::pin::{hash_pin_code, is_valid_pin_hash, sanitize_pin_digits}; + +/// `PIN_CODE_LENGTH` (pin.ts line 1). +pub const PIN_CODE_LENGTH: usize = core_pin::PIN_CODE_LENGTH; + +/// `PinVerification` (pin.ts lines 67-76). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PinVerification { + /// The attempt matches the stored hash. + pub matches: bool, + /// A PBKDF2 replacement for a legacy hash the attempt just cleared, to be + /// persisted by the caller. `None` on a failed attempt and on a hash that + /// is already PBKDF2. + pub rehashed: Option, +} + +/// `verifyPinCode` (pin.ts lines 87-107): a legacy hash that verifies comes +/// back with its PBKDF2 replacement. +pub fn verify_pin_code(code: &str, stored_hash: &str) -> PinVerification { + match core_pin::verify_pin_code(code, stored_hash) { + PinVerdict::Rejected => PinVerification { + matches: false, + rehashed: None, + }, + PinVerdict::Accepted => PinVerification { + matches: true, + rehashed: None, + }, + PinVerdict::AcceptedLegacy => PinVerification { + matches: true, + rehashed: hash_pin_code(code), + }, + } +} + +/// `PIN_LOCKED_PREFIX` (pinSession.ts line 10): the start of the error every +/// switch command returns while the backend session is locked. +pub const PIN_LOCKED_PREFIX: &str = "pin_locked:"; + +/// `isPinLockedError` (pinSession.ts lines 12-15) on an error message. +pub fn is_pin_locked_message(message: &str) -> bool { + message.starts_with(PIN_LOCKED_PREFIX) +} + +/// `isPinLockedError` on a backend error. +pub fn is_pin_locked_error(error: &BackendError) -> bool { + error.is_pin_locked() +} + +/// `PinCheck` (pinSession.ts lines 45-52). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PinCheck { + Match { + /// PBKDF2 replacement for a legacy hash the code just cleared. + rehashed: Option, + }, + Invalid { + retry_after_ms: u64, + }, + RetryLater { + retry_after_ms: u64, + }, +} + +/// `unlockPinSession` (pinSession.ts lines 63-82): the backend checks the +/// code and unlocks its session; when it holds no PIN (`not_configured`) or +/// the call fails, the hash this window holds decides. Blocks. +pub fn unlock_pin_session(backend: &dyn Backend, code: &str, stored_hash: &str) -> PinCheck { + // A failed call is ignored like the TS `console.error`: the local hash + // decides below. The error is not logged, it could echo the request. + if let Ok(result) = backend.pin_unlock(code) { + match result.status.as_str() { + "unlocked" => { + return PinCheck::Match { + rehashed: if result.legacy { + hash_pin_code(code) + } else { + None + }, + } + } + "invalid" => { + return PinCheck::Invalid { + retry_after_ms: result.retry_after_ms, + } + } + "retry_later" => { + return PinCheck::RetryLater { + retry_after_ms: result.retry_after_ms, + } + } + _ => {} + } + } + if !is_valid_pin_hash(stored_hash) { + // No PIN here either: nothing to protect. + return PinCheck::Match { rehashed: None }; + } + let verification = verify_pin_code(code, stored_hash); + if verification.matches { + PinCheck::Match { + rehashed: verification.rehashed, + } + } else { + PinCheck::Invalid { retry_after_ms: 0 } + } +} + +/// `lockPinSession` (pinSession.ts lines 85-89). Fire and forget: the screen +/// locks either way, so a failure is swallowed. +pub fn lock_pin_session(backend: &dyn Backend) { + let _ = backend.pin_lock(); +} + +/// `PIN_FAILURE_DELAY_MS` (pinSession.ts line 92): the shortest wait after +/// a wrong code, whatever the backend says. +pub const PIN_FAILURE_DELAY_MS: u64 = 1200; + +/// What `pinFailureWait` returns. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PinFailureWait { + pub wait_ms: u64, + /// Say the attempts ran out rather than "wrong PIN". + pub too_many_attempts: bool, +} + +impl PinCheck { + /// `pinFailureWait` (pinSession.ts lines 99-107). `None` for a match. + pub fn failure_wait(&self) -> Option { + let (retry_after_ms, retry_later) = match *self { + PinCheck::Match { .. } => return None, + PinCheck::Invalid { retry_after_ms } => (retry_after_ms, false), + PinCheck::RetryLater { retry_after_ms } => (retry_after_ms, true), + }; + Some(PinFailureWait { + wait_ms: PIN_FAILURE_DELAY_MS.max(retry_after_ms), + too_many_attempts: retry_later || retry_after_ms > PIN_FAILURE_DELAY_MS, + }) + } +} diff --git a/crates/accshift-app/src/secure/screen.rs b/crates/accshift-app/src/secure/screen.rs new file mode 100644 index 0000000..68317c2 --- /dev/null +++ b/crates/accshift-app/src/secure/screen.rs @@ -0,0 +1,590 @@ +//! The secure screen: the AFK cover and the PIN lock, ported from +//! `src/lib/app/useSecureScreen.svelte.ts` with the logic +//! `src/lib/app/AppScreenOverlays.svelte` and `src/App.svelte` keep inline. +//! +//! The Svelte `$effect`s become [`SecureScreen::update`], run once per frame +//! after the window and settings inputs are known: it applies finished +//! unlocks, fires due timers and replays the effects in source order. Timers +//! are deadlines ([`SecureScreen::next_deadline`]); the backend calls run on +//! [`Jobs`]. What the UI must do in return comes back as [`SecureEvent`]s. + +use super::inactivity::InactivityBlur; +use super::pin::{ + is_pin_locked_error, is_valid_pin_hash, lock_pin_session, sanitize_pin_digits, + unlock_pin_session, PinCheck, PIN_CODE_LENGTH, +}; +use super::window_activity::WindowActivity; +use crate::error::BackendError; +use crate::jobs::{Jobs, Pending, Poll}; +use crate::settings::AppSettings; +use crate::stores::ClientStores; +use crate::text::Text; +use std::time::{Duration, Instant}; + +/// `AFK_TEXT_FADE_MS` (line 57): how long the wave keeps running while the +/// AFK text fades out. +pub const AFK_TEXT_FADE: Duration = Duration::from_millis(900); + +/// `AFK_TEXT_REVEAL_DELAY_MS` (line 58): the `startDelayMs` of the wave text. +pub const AFK_TEXT_REVEAL_DELAY_MS: u64 = 2500; + +/// The pause between a matching code and the overlay going away (line 246). +pub const UNLOCK_SETTLE: Duration = Duration::from_millis(240); + +/// What the screen reads from the window (`windowActivity` deps). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WindowView { + pub foreground: bool, + pub minimized: bool, + pub page_visible: bool, +} + +impl Default for WindowView { + fn default() -> Self { + Self::from(&WindowActivity::new()) + } +} + +impl From<&WindowActivity> for WindowView { + fn from(activity: &WindowActivity) -> Self { + Self { + foreground: activity.is_foreground(), + minimized: activity.is_minimized(), + page_visible: activity.is_page_visible(), + } + } +} + +/// The per-frame inputs (`SecureScreenDeps` getters). +#[derive(Debug, Clone, Copy)] +pub struct SecureInputs<'a> { + /// `getSettings()`: the shell's settings. + pub settings: &'a AppSettings, + /// `getIsAccountSelectionView()`: no settings panel and an adapter + /// (App.svelte line 145). The AFK cover only covers the account grid; + /// the PIN lock engages in every view. + pub is_account_selection_view: bool, + pub window: WindowView, + /// `getAppVersion()`, empty until known. + pub app_version: &'a str, +} + +/// What the UI does in return. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SecureEvent { + /// `onCloseContextMenu()`. + CloseContextMenu, + /// Put keyboard focus in the PIN field (the `setTimeout` focus calls and + /// the AppScreenOverlays focus effect, lines 74-76). + FocusPinInput, + /// `persistPinHash(hash)`: a PBKDF2 hash replaced a legacy one. Write it + /// with [`persist_pin_hash`]. + PersistPinHash(String), +} + +#[derive(Debug, Clone, Default)] +struct Snapshot { + pin_enabled: bool, + pin_hash: String, + inactivity_blur_seconds: i64, + suspend_graphics_when_minimized: bool, + is_account_selection_view: bool, + window: WindowView, + app_version: String, +} + +impl Snapshot { + fn read(&mut self, inputs: &SecureInputs<'_>) { + let settings = inputs.settings; + self.pin_enabled = settings.pin_enabled; + if self.pin_hash != settings.pin_hash { + self.pin_hash = settings.pin_hash.clone(); + } + self.inactivity_blur_seconds = settings.inactivity_blur_seconds; + self.suspend_graphics_when_minimized = settings.suspend_graphics_when_minimized; + self.is_account_selection_view = inputs.is_account_selection_view; + self.window = inputs.window; + if self.app_version != inputs.app_version { + self.app_version = inputs.app_version.to_string(); + } + } + + fn has_valid_pin(&self) -> bool { + is_valid_pin_hash(&self.pin_hash) + } +} + +/// `createSecureScreenController`. +pub struct SecureScreen { + snap: Snapshot, + blur: InactivityBlur, + is_pin_locked: bool, + is_pin_unlocking: bool, + is_pin_retry_locked: bool, + pin_attempt: String, + pin_error: Option, + retry_deadline: Option, + settle_deadline: Option, + unlock: Option>, + /// The backend refused a switch for want of the PIN. The screen stays + /// locked on that alone, even when this window holds no PIN: only a code + /// the backend accepts clears it (lines 82-85). + backend_locked: bool, + afk_listeners_attached: bool, + afk_wave_active: bool, + afk_wave_stop_deadline: Option, + prev_afk_visible: bool, + prev_render_suspended: bool, + prev_wants_focus: bool, + events: Vec, +} + +impl SecureScreen { + /// Starts locked when the settings carry an enabled, well-formed PIN + /// (`startupPinLocked`, lines 71-73). + pub fn new(settings: &AppSettings, now: Instant) -> Self { + let mut snap = Snapshot::default(); + snap.read(&SecureInputs { + settings, + is_account_selection_view: false, + window: WindowView::default(), + app_version: "", + }); + let startup_locked = snap.pin_enabled && snap.has_valid_pin(); + Self { + snap, + blur: InactivityBlur::new(now), + is_pin_locked: startup_locked, + is_pin_unlocking: false, + is_pin_retry_locked: false, + pin_attempt: String::new(), + pin_error: None, + retry_deadline: None, + settle_deadline: None, + unlock: None, + backend_locked: false, + afk_listeners_attached: false, + afk_wave_active: false, + afk_wave_stop_deadline: None, + prev_afk_visible: false, + prev_render_suspended: false, + prev_wants_focus: false, + events: Vec::new(), + } + } + + // ------------------------------------------------------------ state + + pub fn blur(&self) -> &InactivityBlur { + &self.blur + } + + pub fn is_pin_locked(&self) -> bool { + self.is_pin_locked + } + + pub fn is_pin_unlocking(&self) -> bool { + self.is_pin_unlocking + } + + pub fn is_pin_retry_locked(&self) -> bool { + self.is_pin_retry_locked + } + + /// The digits typed so far. Draw it masked. + pub fn pin_attempt(&self) -> &str { + &self.pin_attempt + } + + /// `pinError`: `pin.invalid` or `pin.tooManyAttempts { seconds }`. + pub fn pin_error(&self) -> Option<&Text> { + self.pin_error.as_ref() + } + + /// `pinCodeLength`. + pub fn pin_code_length(&self) -> usize { + PIN_CODE_LENGTH + } + + /// `afkTextRevealDelayMs`. + pub fn afk_text_reveal_delay_ms(&self) -> u64 { + AFK_TEXT_REVEAL_DELAY_MS + } + + /// The backend said it is locked and no accepted code cleared it yet. + pub fn is_backend_locked(&self) -> bool { + self.backend_locked + } + + /// `windowForeground`. + pub fn window_foreground(&self) -> bool { + self.snap.window.foreground + } + + fn window_renderable(&self) -> bool { + self.snap.window.page_visible && !self.snap.window.minimized + } + + /// `renderSuspended`: minimized with "suspend graphics" on. + pub fn render_suspended(&self) -> bool { + self.snap.suspend_graphics_when_minimized && self.snap.window.minimized + } + + fn inactivity_enabled(&self) -> bool { + self.snap.inactivity_blur_seconds > 0 + } + + fn pin_overlay_up(&self) -> bool { + self.is_pin_locked || self.is_pin_unlocking || self.is_pin_retry_locked + } + + fn afk_covering(&self) -> bool { + self.inactivity_enabled() && self.blur.is_blurred() && self.snap.is_account_selection_view + } + + /// `isObscured` (lines 96-101): the frost over the app. + pub fn is_obscured(&self) -> bool { + self.afk_covering() || self.pin_overlay_up() + } + + /// `afkOverlayVisible` (lines 102-111). + pub fn afk_overlay_visible(&self) -> bool { + self.afk_covering() + && !self.pin_overlay_up() + && self.window_renderable() + && !self.render_suspended() + } + + /// `motionPaused`. + pub fn motion_paused(&self) -> bool { + !self.window_renderable() || self.render_suspended() + } + + /// `afkWaveActive`: the AFK wave text runs (it keeps running for + /// [`AFK_TEXT_FADE`] after the cover goes). + pub fn afk_wave_active(&self) -> bool { + self.afk_wave_active + } + + /// `afkVersionLabel`. + pub fn afk_version_label(&self) -> Option<&str> { + (self.afk_overlay_visible() && !self.snap.app_version.is_empty()) + .then_some(self.snap.app_version.as_str()) + } + + /// The PIN overlay is drawn (AppScreenOverlays lines 113 and 130). + pub fn pin_overlay_visible(&self) -> bool { + !self.render_suspended() && self.pin_overlay_up() + } + + /// The PIN field takes input (AppScreenOverlays line 161). + pub fn pin_input_enabled(&self) -> bool { + !self.is_pin_unlocking && !self.is_pin_retry_locked + } + + /// An unlock is waiting for the backend. + pub fn is_busy(&self) -> bool { + self.unlock.is_some() + } + + /// The earliest timer, for the repaint request. + pub fn next_deadline(&self) -> Option { + [ + self.retry_deadline, + self.settle_deadline, + self.afk_wave_stop_deadline, + self.blur.next_deadline(), + ] + .into_iter() + .flatten() + .min() + } + + // ------------------------------------------------------------ input + + /// `setPinAttempt`: keeps the digits only, four at most (the attempt + /// effect, lines 197-207). The unlock starts on the next + /// [`Self::update`]. + pub fn set_pin_attempt(&mut self, value: &str) { + self.pin_attempt = sanitize_pin_digits(value); + } + + /// A pointer move over the window (the blur's `mousemove` listener). + pub fn on_pointer_move(&mut self, now: Instant) { + self.blur + .on_pointer_move(now, self.snap.inactivity_blur_seconds); + } + + /// A mouse button press (`mousedown`). + pub fn on_mouse_down(&mut self, now: Instant) { + self.blur + .on_mouse_down(now, self.snap.inactivity_blur_seconds); + } + + /// A key press (`keydown`). + pub fn on_key_down(&mut self, now: Instant) { + self.blur + .on_key_down(now, self.snap.inactivity_blur_seconds); + } + + // ------------------------------------------------------------ lifecycle + + /// `handleAppMounted` (lines 261-276). Call once after the first + /// [`Self::update`] fed the real settings. + pub fn handle_app_mounted(&mut self, jobs: &Jobs, now: Instant) { + self.blur.start(now, self.snap.inactivity_blur_seconds); + self.blur.attach_listeners(); + self.afk_listeners_attached = true; + if self.is_pin_locked { + // The backend locks itself at start when the store holds a PIN. + // Say it anyway: this may be a reload of an unlocked session. + tell_backend_lock(jobs); + self.is_pin_retry_locked = false; + self.pin_attempt.clear(); + self.pin_error = None; + self.events.push(SecureEvent::FocusPinInput); + } + } + + /// `handleAppDestroyed` (lines 278-293). + pub fn handle_app_destroyed(&mut self) { + self.afk_wave_stop_deadline = None; + self.retry_deadline = None; + if self.afk_listeners_attached { + self.blur.detach_listeners(); + } + self.blur.stop(); + } + + /// `handleSettingsClosed` (lines 253-259): the timer starts over. + pub fn handle_settings_closed(&mut self, now: Instant) { + self.blur.start(now, self.snap.inactivity_blur_seconds); + if !self.afk_listeners_attached { + self.blur.attach_listeners(); + self.afk_listeners_attached = true; + } + } + + /// `handleBackendLocked` (lines 190-195): the backend refused a command + /// for want of the PIN. + pub fn handle_backend_locked(&mut self) { + self.backend_locked = true; + if self.is_pin_locked || self.is_pin_unlocking { + return; + } + // The backend is locked already: no need to tell it. + self.engage_lock(None); + } + + /// `reportPinLockedError` (pinSession.ts lines 33-37) wired to this + /// screen: true when `error` was the PIN refusal, so the caller skips + /// its generic failure message. + pub fn report_backend_error(&mut self, error: &BackendError) -> bool { + if !is_pin_locked_error(error) { + return false; + } + self.handle_backend_locked(); + true + } + + // ------------------------------------------------------------ frame + + /// One frame: read the inputs, apply a finished unlock, fire due timers, + /// then run the effects of lines 115-207 in order. + pub fn update( + &mut self, + inputs: &SecureInputs<'_>, + jobs: &Jobs, + now: Instant, + ) -> Vec { + self.snap.read(inputs); + self.poll_unlock(now); + self.fire_timers(now); + + // AFK wave (lines 115-131). + let afk_visible = self.afk_overlay_visible(); + if afk_visible && !self.prev_afk_visible { + self.afk_wave_stop_deadline = None; + self.events.push(SecureEvent::CloseContextMenu); + self.afk_wave_active = true; + } else if !afk_visible && self.prev_afk_visible && self.afk_wave_active { + self.afk_wave_stop_deadline = Some(now + AFK_TEXT_FADE); + } + self.prev_afk_visible = afk_visible; + + // Render suspend closes the context menu (lines 133-137). + let suspended = self.render_suspended(); + if suspended && !self.prev_render_suspended { + self.events.push(SecureEvent::CloseContextMenu); + } + self.prev_render_suspended = suspended; + + // Listeners follow render suspend (lines 139-151). + if suspended { + if self.afk_listeners_attached { + self.blur.detach_listeners(); + self.afk_listeners_attached = false; + } + } else if !self.afk_listeners_attached { + self.blur.attach_listeners(); + self.afk_listeners_attached = true; + } + + // No PIN any more: drop the lock (lines 153-162). + if (!self.snap.pin_enabled || !self.snap.has_valid_pin()) && !self.backend_locked { + self.is_pin_locked = false; + self.is_pin_retry_locked = false; + self.pin_attempt.clear(); + self.pin_error = None; + } + + // Inactivity engages the PIN lock (lines 164-177). + if self.blur.is_blurred() + && self.snap.pin_enabled + && self.snap.has_valid_pin() + && !self.is_pin_locked + && !self.is_pin_unlocking + { + self.engage_lock(Some(jobs)); + } + + // Four digits typed: unlock (lines 197-207). + if self.is_pin_locked + && !self.is_pin_unlocking + && !self.is_pin_retry_locked + && self.pin_attempt.len() == PIN_CODE_LENGTH + { + self.start_unlock(jobs); + } + + // The field holds focus while the lock waits for a code + // (AppScreenOverlays lines 74-76). + let wants_focus = self.is_pin_locked && !self.is_pin_unlocking; + if wants_focus && !self.prev_wants_focus { + self.events.push(SecureEvent::FocusPinInput); + } + self.prev_wants_focus = wants_focus; + + let mut events = std::mem::take(&mut self.events); + let mut seen = Vec::with_capacity(events.len()); + events.retain(|event| { + if seen.contains(event) { + return false; + } + seen.push(event.clone()); + true + }); + events + } + + fn fire_timers(&mut self, now: Instant) { + if self.retry_deadline.is_some_and(|due| now >= due) { + self.retry_deadline = None; + self.is_pin_retry_locked = false; + self.events.push(SecureEvent::FocusPinInput); + } + if self.settle_deadline.is_some_and(|due| now >= due) { + self.settle_deadline = None; + self.is_pin_locked = false; + self.is_pin_unlocking = false; + self.blur + .reset_activity(now, self.snap.inactivity_blur_seconds); + } + if self.afk_wave_stop_deadline.is_some_and(|due| now >= due) { + self.afk_wave_stop_deadline = None; + self.afk_wave_active = false; + } + self.blur.tick(now, self.snap.inactivity_blur_seconds); + } + + /// `engageLock` (lines 180-188). `None` skips telling the backend. + fn engage_lock(&mut self, tell_backend: Option<&Jobs>) { + if let Some(jobs) = tell_backend { + tell_backend_lock(jobs); + } + self.is_pin_locked = true; + self.is_pin_retry_locked = false; + self.pin_attempt.clear(); + self.pin_error = None; + self.events.push(SecureEvent::CloseContextMenu); + self.events.push(SecureEvent::FocusPinInput); + } + + /// The first half of `unlockWithPin` (lines 209-221). + fn start_unlock(&mut self, jobs: &Jobs) { + let expected = self.snap.pin_hash.clone(); + if !is_valid_pin_hash(&expected) && !self.backend_locked { + self.is_pin_locked = false; + return; + } + let attempt = sanitize_pin_digits(&self.pin_attempt); + if attempt.len() != PIN_CODE_LENGTH || self.is_pin_retry_locked { + return; + } + self.is_pin_unlocking = true; + self.pin_error = None; + // The backend checks the code and unlocks the switch commands. It + // also rate limits wrong codes, so its wait wins over the local one. + self.unlock = + Some(jobs.run(move |backend| unlock_pin_session(backend, &attempt, &expected))); + } + + fn poll_unlock(&mut self, now: Instant) { + let Some(pending) = self.unlock.as_mut() else { + return; + }; + let check = match pending.poll() { + Poll::Waiting => return, + Poll::Ready(check) => check, + // The check panicked: a plain wrong code, so the screen never + // hangs in "unlocking". + Poll::Gone => PinCheck::Invalid { retry_after_ms: 0 }, + }; + self.unlock = None; + self.apply_unlock(check, now); + } + + /// The second half of `unlockWithPin` (lines 222-251). + fn apply_unlock(&mut self, check: PinCheck, now: Instant) { + if let Some(wait) = check.failure_wait() { + self.is_pin_unlocking = false; + self.is_pin_retry_locked = true; + self.pin_error = Some(if wait.too_many_attempts { + Text::with( + "pin.tooManyAttempts", + [("seconds", wait.wait_ms.div_ceil(1000))], + ) + } else { + Text::key("pin.invalid") + }); + self.pin_attempt.clear(); + self.retry_deadline = Some(now + Duration::from_millis(wait.wait_ms)); + return; + } + self.backend_locked = false; + if let PinCheck::Match { + rehashed: Some(hash), + } = check + { + // Unlocked against the old unsalted hash: store the PBKDF2 one + // now, so the next unlock (here or in the CLI) runs salted. + self.events.push(SecureEvent::PersistPinHash(hash)); + } + self.pin_attempt.clear(); + self.settle_deadline = Some(now + UNLOCK_SETTLE); + } +} + +fn tell_backend_lock(jobs: &Jobs) { + // Fire and forget, as `lockPinSession`: the result is dropped. + drop(jobs.run(lock_pin_session)); +} + +/// `persistPinHash` (App.svelte lines 256-261): re-read the store rather +/// than write the shell's settings back, since the settings panel may hold a +/// draft; only the hash travels. +pub fn persist_pin_hash(stores: &mut ClientStores, hash: &str) { + let mut latest = stores.settings().clone(); + latest.pin_hash = hash.to_string(); + stores.save_settings(&latest); +} diff --git a/crates/accshift-app/src/secure/window_activity.rs b/crates/accshift-app/src/secure/window_activity.rs new file mode 100644 index 0000000..4dfebc6 --- /dev/null +++ b/crates/accshift-app/src/secure/window_activity.rs @@ -0,0 +1,144 @@ +//! Window focus and visibility, ported from +//! `src/lib/shared/useWindowActivity.svelte.ts` (the state machine only). +//! +//! TS listens to the DOM `focus`, `blur` and `visibilitychange` events and to +//! Tauri's `onFocusChanged` and `onResized`, and asks the native window +//! (`isFocused`, `isMinimized`) on a blur, a resize or a return to visible. +//! The egui shell feeds the same events from its viewport info and answers +//! the native query itself: [`WindowActivity::take_sync_request`] says a +//! query is due, [`WindowActivity::apply_sync`] takes its answer. +//! +//! The async registration race of TS (`takeNativeUnlisteners`, the +//! `generation` counter) has no equivalent: egui events are synchronous, so +//! "a listener that lands after stop" becomes "an event fed while stopped", +//! which is dropped. + +/// `createWindowActivity`. +#[derive(Debug, Clone)] +pub struct WindowActivity { + is_focused: bool, + is_minimized: bool, + is_page_visible: bool, + started: bool, + /// A `sync()` is due. Several requests before the answer collapse into + /// one, as the `syncing` / `pendingSync` pair does (lines 38-66). + sync_requested: bool, +} + +impl Default for WindowActivity { + fn default() -> Self { + Self::new() + } +} + +impl WindowActivity { + /// Starts focused, not minimized, visible (lines 22-24). + pub fn new() -> Self { + Self { + is_focused: true, + is_minimized: false, + is_page_visible: true, + started: false, + sync_requested: false, + } + } + + pub fn is_focused(&self) -> bool { + self.is_focused + } + + pub fn is_minimized(&self) -> bool { + self.is_minimized + } + + pub fn is_page_visible(&self) -> bool { + self.is_page_visible + } + + /// `isForeground` (lines 155-157). + pub fn is_foreground(&self) -> bool { + self.is_focused && self.is_page_visible && !self.is_minimized + } + + pub fn is_started(&self) -> bool { + self.started + } + + /// `start` (lines 69-134): takes the current visibility and asks for a + /// first native sync. A second start is a no-op. + pub fn start(&mut self, page_visible: bool) { + if self.started { + return; + } + self.started = true; + self.is_page_visible = page_visible; + self.sync_requested = true; + } + + /// `stop` (lines 136-143): events are dropped until the next start. + pub fn stop(&mut self) { + if !self.started { + return; + } + self.started = false; + self.sync_requested = false; + } + + /// The window gained or lost focus: the DOM `focus`/`blur` pair (lines + /// 79-87) and Tauri's `onFocusChanged` (lines 110-116), which fire + /// together in the webview. A focused window is not minimized; a blur + /// asks the native window whether it got minimized. + pub fn on_focus_changed(&mut self, focused: bool, page_visible: bool) { + if !self.started { + return; + } + self.is_focused = focused; + self.is_page_visible = page_visible; + if focused { + self.is_minimized = false; + } else { + self.sync_requested = true; + } + } + + /// `visibilitychange` (lines 95-100): back to visible asks for a sync. + pub fn on_visibility_changed(&mut self, visible: bool) { + if !self.started { + return; + } + self.is_page_visible = visible; + if visible { + self.sync_requested = true; + } + } + + /// `onResized` (lines 117-119): minimizing is a resize. + pub fn on_resized(&mut self) { + if self.started { + self.sync_requested = true; + } + } + + /// True once per due `sync()`: the shell then reads the native focus and + /// minimized state and calls [`Self::apply_sync`]. + pub fn take_sync_request(&mut self) -> bool { + std::mem::take(&mut self.sync_requested) + } + + /// The answer of `sync()` (lines 38-67). A `None` is a query that failed + /// (`Promise.allSettled` rejected) and keeps the old value. + pub fn apply_sync( + &mut self, + page_visible: bool, + focused: Option, + minimized: Option, + ) { + self.is_page_visible = page_visible; + if let Some(focused) = focused { + self.is_focused = focused; + } + if let Some(minimized) = minimized { + self.is_minimized = minimized; + } + } +} diff --git a/crates/accshift-app/src/streamer.rs b/crates/accshift-app/src/streamer.rs new file mode 100644 index 0000000..f5c92ca --- /dev/null +++ b/crates/accshift-app/src/streamer.rs @@ -0,0 +1,175 @@ +//! Streamer mode, ported from `src/lib/app/useStreamerMode.svelte.ts` and the +//! glue App.svelte keeps around it (lines 264-273, 719, 739). +//! +//! With `streamerMode: "auto"`, the app polls `detect_streaming_software` +//! and covers itself (the overlay is UI; what it hides is +//! [`crate::secure::ScreenCover`]) while streaming software runs. The +//! `setInterval` is a deadline checked each frame; the detection runs on +//! [`Jobs`]. + +use crate::error::BackendResult; +use crate::jobs::{Jobs, Pending, Poll}; +use crate::settings::{AppSettings, StreamerMode}; +use crate::stores::ClientStores; +use std::time::{Duration, Instant}; + +/// `POLL_INTERVAL_MS` (line 15). +pub const POLL_INTERVAL: Duration = Duration::from_millis(4000); + +/// `HIDDEN_POLL_INTERVAL_MS` (line 19): a hidden window shows nothing a +/// stream could capture, so it checks far less often. +pub const HIDDEN_POLL_INTERVAL: Duration = Duration::from_millis(30_000); + +/// `createStreamerModeController`. +#[derive(Debug, Default)] +pub struct StreamerModeController { + /// `getSettings().streamerMode === "auto"`, read by [`Self::sync_settings`]. + enabled: bool, + streaming_detected: bool, + /// "Disable for now": hides the overlay until no streaming software runs. + dismissed_this_session: bool, + /// The `setInterval` is armed; its next tick. + next_tick: Option, + /// The detection in flight (`polling`). + polling: Option>>, + last_poll_at: Option, +} + +impl StreamerModeController { + pub fn new(settings: &AppSettings) -> Self { + let mut controller = Self::default(); + controller.sync_settings(settings); + controller + } + + /// Reads `streamerMode` from the shell's settings. Call when they change. + pub fn sync_settings(&mut self, settings: &AppSettings) { + self.enabled = settings.streamer_mode == StreamerMode::Auto; + } + + /// `enabled`. + pub fn enabled(&self) -> bool { + self.enabled + } + + pub fn streaming_detected(&self) -> bool { + self.streaming_detected + } + + /// `active` (line 36): the overlay is up. + pub fn active(&self) -> bool { + self.enabled && self.streaming_detected && !self.dismissed_this_session + } + + /// The interval is running. + pub fn is_running(&self) -> bool { + self.next_tick.is_some() + } + + /// A detection is in flight. + pub fn is_busy(&self) -> bool { + self.polling.is_some() + } + + /// When [`Self::tick`] next has work. + pub fn next_deadline(&self) -> Option { + self.next_tick + } + + /// `poll` (lines 38-58), up to the backend call. + fn poll(&mut self, jobs: &Jobs, now: Instant) { + if !self.enabled { + self.streaming_detected = false; + return; + } + if self.polling.is_some() { + return; + } + self.last_poll_at = Some(now); + self.polling = Some(jobs.run(|backend| backend.detect_streaming_software())); + } + + /// The end of `poll`: applies the answer. A failure keeps the old state, + /// as the TS `console.error` does. + fn apply(&mut self, jobs: &Jobs, result: BackendResult) { + let Ok(detected) = result else { + return; + }; + if detected && !self.streaming_detected { + drop(jobs.run(|backend| { + let _ = backend.telemetry_track_streamer_mode(); + })); + } + self.streaming_detected = detected; + if !detected { + self.dismissed_this_session = false; + } + } + + /// `start` (lines 60-67): polls now, then every [`POLL_INTERVAL`]. App.svelte + /// starts it once the first frame is out (`accshift:boot-ready`, line + /// 719), not during boot: the scan shares the backend with the snapshot. + pub fn start(&mut self, jobs: &Jobs, now: Instant) { + if self.next_tick.is_some() { + return; + } + self.poll(jobs, now); + self.next_tick = Some(now + POLL_INTERVAL); + } + + /// `stop` (lines 69-74). + pub fn stop(&mut self) { + self.next_tick = None; + } + + /// One frame: applies a finished detection, then runs the interval + /// callback (lines 63-66) when due. `hidden` is `isHidden()`: minimized + /// or page hidden (App.svelte line 266). True when `active` changed. + pub fn tick(&mut self, jobs: &Jobs, now: Instant, hidden: bool) -> bool { + let before = self.active(); + if let Some(pending) = self.polling.as_mut() { + match pending.poll() { + Poll::Waiting => {} + Poll::Ready(result) => { + self.polling = None; + self.apply(jobs, result); + } + Poll::Gone => self.polling = None, + } + } + if let Some(due) = self.next_tick { + if now >= due { + // One callback per frame, however many intervals a stalled + // frame skipped, as a throttled browser timer does. + let mut next = due + POLL_INTERVAL; + while next <= now { + next += POLL_INTERVAL; + } + self.next_tick = Some(next); + let recent = self + .last_poll_at + .is_some_and(|last| now.saturating_duration_since(last) < HIDDEN_POLL_INTERVAL); + if !(hidden && recent) { + self.poll(jobs, now); + } + } + } + before != self.active() + } + + /// `dismiss` (lines 76-78): "Disable for now". + pub fn dismiss(&mut self) { + self.dismissed_this_session = true; + } + + /// `disablePermanently` (lines 80-83) with App.svelte's `setStreamerMode` + /// (lines 267-272): saves `streamerMode: "off"` from the store's latest + /// settings. The shell refreshes its settings from the store after. + pub fn disable_permanently(&mut self, stores: &mut ClientStores) { + self.dismissed_this_session = true; + let mut latest = stores.settings().clone(); + latest.streamer_mode = StreamerMode::Off; + stores.save_settings(&latest); + self.sync_settings(stores.settings()); + } +} diff --git a/crates/accshift-app/tests/bulk_edit.rs b/crates/accshift-app/tests/bulk_edit.rs new file mode 100644 index 0000000..3e44b2e --- /dev/null +++ b/crates/accshift-app/tests/bulk_edit.rs @@ -0,0 +1,318 @@ +//! `useBulkEdit.test.ts`, the logic of `BulkEditBar.svelte`, and one bulk +//! edit on the demo accounts with a failure, against MockBackend. + +mod support; + +use accshift_app::backend::Backend; +use accshift_app::bulk_edit::{ + titlebar_button_visible, toggle_allowed, BarStep, BulkEdit, BulkEditBar, TriState, +}; +use accshift_app::jobs::Jobs; +use accshift_app::platforms::PlatformRegistry; +use accshift_app::text::Text; +use serde_json::json; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +fn ids(values: &[&str]) -> Vec { + values.iter().map(|value| value.to_string()).collect() +} + +fn open_steam(bulk: &mut BulkEdit, registry: &PlatformRegistry) { + bulk.toggle(registry.definition("steam")); + assert!(bulk.is_open()); +} + +/// Polls until the bar has no job in flight; the apply result if one ended. +fn settle(bulk: &mut BulkEdit) -> Option { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Some(result) = bulk.poll() { + return Some(result); + } + if !bulk.is_busy() { + return None; + } + assert!(Instant::now() < deadline, "bulk edit job never finished"); + std::thread::sleep(Duration::from_millis(2)); + } +} + +// ------------------------------------------------------------ selection + +#[test] +fn select_all_inside_a_folder_keeps_what_was_picked_elsewhere() { + let registry = PlatformRegistry::shipped(); + let mut bulk = BulkEdit::new(); + open_steam(&mut bulk, ®istry); + bulk.select_all(&ids(&["root-1", "root-2"])); + assert_eq!(bulk.selected_ids(), ids(&["root-1", "root-2"])); + // Navigating into a folder only changes what is on screen. + bulk.select_all(&ids(&["folder-1"])); + assert_eq!(bulk.selected_ids(), ids(&["root-1", "root-2", "folder-1"])); +} + +#[test] +fn a_manual_pick_in_another_folder_is_kept() { + let mut bulk = BulkEdit::new(); + bulk.toggle_account("root-1"); + bulk.toggle_account("folder-1"); + assert_eq!(bulk.selected_ids(), ids(&["root-1", "folder-1"])); +} + +#[test] +fn deselect_all_is_the_reset_across_folders() { + let mut bulk = BulkEdit::new(); + bulk.select_all(&ids(&["root-1"])); + bulk.toggle_account("folder-1"); + bulk.deselect_all(); + assert!(bulk.selected_ids().is_empty()); +} + +// ------------------------------------------------------------ opening + +#[test] +fn opens_only_on_a_platform_with_the_capability() { + let registry = PlatformRegistry::shipped(); + let mut bulk = BulkEdit::new(); + bulk.toggle(registry.definition("riot")); + assert!(!bulk.is_open()); + bulk.toggle(None); + assert!(!bulk.is_open()); + bulk.toggle(registry.definition("steam")); + assert_eq!(bulk.platform_id(), Some("steam")); + assert_eq!(bulk.bar().map(BulkEditBar::step), Some(BarStep::Select)); +} + +#[test] +fn a_second_toggle_closes_and_a_third_opens_again() { + let registry = PlatformRegistry::shipped(); + let mut bulk = BulkEdit::new(); + let steam = registry.definition("steam"); + bulk.toggle(steam); + bulk.toggle_account("a"); + bulk.toggle(steam); + assert!(!bulk.is_open()); + assert!(bulk.selected_ids().is_empty()); + bulk.toggle(steam); + assert!(bulk.is_open()); +} + +#[test] +fn a_tab_change_closes_the_mode() { + let registry = PlatformRegistry::shipped(); + let mut bulk = BulkEdit::new(); + open_steam(&mut bulk, ®istry); + bulk.toggle_account("a"); + bulk.close(); + assert!(!bulk.is_open()); + assert!(bulk.bar().is_none()); + assert!(!bulk.is_selected("a")); +} + +#[test] +fn the_toggle_follows_the_tab_and_the_panels() { + let registry = PlatformRegistry::shipped(); + let steam = registry.definition("steam"); + assert!(toggle_allowed(steam, true, false)); + assert!(!toggle_allowed(steam, false, false)); + assert!(!toggle_allowed(steam, true, true)); + assert!(!toggle_allowed(registry.definition("riot"), true, false)); + assert!(titlebar_button_visible(steam, true, false, false)); + assert!(!titlebar_button_visible(steam, true, false, true)); +} + +// ------------------------------------------------------------ paint + +#[test] +fn painting_selects_every_card_crossed_and_eats_the_click() { + let registry = PlatformRegistry::shipped(); + let mut bulk = BulkEdit::new(); + open_steam(&mut bulk, ®istry); + assert!(bulk.paint_mouse_down(true, Some("a"), (10.0, 10.0))); + // Under the threshold: still a click. + bulk.paint_mouse_move((12.0, 11.0), Some("a")); + assert!(!bulk.is_selected("a")); + bulk.paint_mouse_move((40.0, 10.0), Some("b")); + bulk.paint_mouse_move((80.0, 10.0), Some("c")); + bulk.paint_mouse_up(); + assert_eq!(bulk.selected_ids(), ids(&["a", "b", "c"])); + assert!(bulk.paint_capture_click()); + assert!(!bulk.paint_capture_click()); + + // Starting on a selected card deselects. + assert!(bulk.paint_mouse_down(true, Some("b"), (0.0, 0.0))); + bulk.paint_mouse_move((0.0, 20.0), Some("c")); + bulk.paint_mouse_up(); + bulk.end_frame(); + assert!(!bulk.paint_capture_click()); + assert_eq!(bulk.selected_ids(), ids(&["a"])); +} + +#[test] +fn painting_needs_the_mode_a_card_and_the_primary_button() { + let registry = PlatformRegistry::shipped(); + let mut bulk = BulkEdit::new(); + assert!(!bulk.paint_mouse_down(true, Some("a"), (0.0, 0.0))); + open_steam(&mut bulk, ®istry); + assert!(!bulk.paint_mouse_down(false, Some("a"), (0.0, 0.0))); + assert!(!bulk.paint_mouse_down(true, None, (0.0, 0.0))); + // A plain click: no swallow. + assert!(bulk.paint_mouse_down(true, Some("a"), (0.0, 0.0))); + bulk.paint_mouse_up(); + assert!(!bulk.paint_capture_click()); +} + +// ------------------------------------------------------------ bar + +#[test] +fn the_bar_steps_cycles_and_escapes() { + let mut bar = BulkEditBar::new(); + assert_eq!(bar.news_popup, TriState::Unchanged); + bar.news_popup = bar.news_popup.cycle(); + assert_eq!(bar.news_popup, TriState::Enabled); + assert_eq!(bar.news_popup.label(), Text::key("common.enabled")); + bar.news_popup = bar.news_popup.cycle(); + assert_eq!(bar.news_popup.value(), Some(false)); + bar.news_popup = bar.news_popup.cycle(); + assert!(!bar.news_popup.is_changed()); + + bar.toggle_color_menu(); + bar.pointer_down(true); + assert!(bar.color_menu_open()); + bar.pointer_down(false); + assert!(!bar.color_menu_open()); + bar.toggle_color_menu(); + assert_eq!(bar.apply_color("#ff0000"), "#ff0000"); + assert!(!bar.color_menu_open()); + + // Escape: the color menu, then the settings step, then the mode. + bar.toggle_color_menu(); + let backend: Arc = Arc::new(support::mock("demo")); + let jobs = Jobs::new(backend, 1, || {}); + bar.go_to_settings(&jobs, None); + assert!(!bar.escape()); + assert!(!bar.color_menu_open()); + assert_eq!(bar.step(), BarStep::Settings); + assert!(!bar.escape()); + assert_eq!(bar.step(), BarStep::Select); + assert!(bar.escape()); + assert_eq!( + BulkEditBar::selected_label(3), + Text::with("bulkEdit.selected", [("count", 3u64)]) + ); + assert!(!BulkEditBar::selection_actions_enabled(0)); + assert_eq!( + BulkEditBar::copy_urls(&ids(&["76561198100000001"])), + vec!["https://steamcommunity.com/profiles/76561198100000001"] + ); +} + +#[test] +fn launch_options_keep_one_row_per_game() { + let mut bar = BulkEditBar::new(); + bar.selected_game_id = "730".into(); + bar.launch_option_input = " ".into(); + assert!(!bar.can_add_launch_option()); + bar.launch_option_input = " -novid ".into(); + bar.add_launch_option(); + assert_eq!(bar.launch_option_edits()[0].value, "-novid"); + assert!(bar.selected_game_id.is_empty() && bar.launch_option_input.is_empty()); + bar.selected_game_id = "730".into(); + bar.launch_option_input = "-high".into(); + bar.add_launch_option(); + assert_eq!(bar.launch_option_edits().len(), 1); + assert_eq!(bar.launch_option_edits()[0].value, "-high"); + bar.remove_launch_option("730"); + assert!(bar.launch_option_edits().is_empty()); +} + +// ------------------------------------------------------------ demo apply + +#[test] +fn a_bulk_edit_on_the_demo_accounts_reports_its_failure() { + let backend = Arc::new(support::scripted("demo")); + let jobs = Jobs::new(backend.clone() as Arc, 1, || {}); + let registry = PlatformRegistry::shipped(); + let steam = registry.adapter("steam").expect("steam adapter"); + let accounts = steam.to_accounts(&backend.inner.platform_get_accounts("steam").unwrap()); + let visible: Vec = accounts.iter().map(|account| account.id.clone()).collect(); + assert!(visible.len() >= 2, "the demo has Steam accounts"); + + let mut bulk = BulkEdit::new(); + open_steam(&mut bulk, ®istry); + bulk.select_all(&visible); + + // Next: the games of the first account fill the picker. + backend.push( + "steam_get_account_games", + Ok(json!([{ "app_id": "730", "name": "Counter-Strike 2" }])), + ); + bulk.go_to_settings(&jobs); + assert!(bulk.bar().unwrap().games_loading()); + assert_eq!(settle(&mut bulk).map(|_| ()), None); + let bar = bulk.bar_mut().unwrap(); + assert_eq!(bar.game_name("730"), "Counter-Strike 2"); + assert_eq!(bar.game_name("440"), "440"); + assert_eq!( + backend.calls("steam_get_account_games"), + vec![format!("({:?},)", visible[0])] + ); + + bar.news_popup = TriState::Disabled; + bar.do_not_disturb = TriState::Enabled; + bar.selected_game_id = "730".into(); + bar.launch_option_input = "-novid".into(); + bar.add_launch_option(); + + let failed_id = visible[1].clone(); + backend.push( + "steam_bulk_edit", + Ok(json!({ + "succeeded": visible.len() - 1, + "failed": [{ "steamId": failed_id, "error": "localconfig.vdf is locked" }], + })), + ); + bulk.apply(&jobs); + assert!(bulk.bar().unwrap().applying()); + assert_eq!(bulk.bar().unwrap().apply_label(), Text::raw("...")); + let result = settle(&mut bulk).expect("a result"); + assert_eq!(result.succeeded as usize, visible.len() - 1); + assert_eq!(result.failed.len(), 1); + assert_eq!(result.failed[0].steam_id, failed_id); + // The finished apply closes the mode. + assert!(!bulk.is_open()); + + let sent = backend.calls("steam_bulk_edit"); + assert_eq!(sent.len(), 1); + assert!(sent[0].contains("news_popup: Some(false)")); + assert!(sent[0].contains("do_not_disturb: Some(true)")); + assert!(sent[0].contains(r#"app_id: "730", value: "-novid""#)); + for id in &visible { + assert!(sent[0].contains(id.as_str())); + } + // steamApi.bulkEdit logged both ends. + let logs = backend.calls("log_app_event").join("\n"); + assert!(logs.contains("Bulk edit started") && logs.contains("Bulk edit completed")); +} + +#[test] +fn a_backend_error_fails_every_account() { + let backend = Arc::new(support::scripted("demo")); + let jobs = Jobs::new(backend.clone() as Arc, 1, || {}); + let registry = PlatformRegistry::shipped(); + let mut bulk = BulkEdit::new(); + open_steam(&mut bulk, ®istry); + // Nothing selected: nothing sent. + bulk.apply(&jobs); + assert!(!bulk.is_busy()); + bulk.toggle_account("76561198100000001"); + // The plain mock has no bulk edit: its refusal is the error. + bulk.apply(&jobs); + let result = settle(&mut bulk).expect("a result"); + assert_eq!(result.succeeded, 0); + assert_eq!(result.failed[0].steam_id, "all"); + assert!(result.failed[0].error.contains("not available")); + let logs = backend.calls("log_app_event").join("\n"); + assert!(logs.contains("Bulk edit failed")); +} diff --git a/crates/accshift-app/tests/platform_add_flow.rs b/crates/accshift-app/tests/platform_add_flow.rs new file mode 100644 index 0000000..90dc2ab --- /dev/null +++ b/crates/accshift-app/tests/platform_add_flow.rs @@ -0,0 +1,535 @@ +//! `platformAddFlow.test.ts` and the add flow against MockBackend, start to +//! success, cancel and error, for Steam and for a descriptor platform (GOG). + +mod support; + +use accshift_app::accounts::loader::LoadOptions; +use accshift_app::accounts::profile_cache::{ProfileCaches, UrlPolicy}; +use accshift_app::accounts::{Toast, ToastKind}; +use accshift_app::backend::Backend; +use accshift_app::i18n::Locale; +use accshift_app::jobs::Jobs; +use accshift_app::platform_add_flow::{ + setup_key, AddFlowContext, AddFlowEvent, AddFlowView, PlatformAddFlow, SetupChipAction, + SetupKey, POLL_DELAY, +}; +use accshift_app::platforms::card::ChipTone; +use accshift_app::platforms::{AddFlowStatus, PlatformRegistry}; +use accshift_app::settings::sanitize_settings; +use accshift_app::storage::ClientStorage; +use accshift_app::stores::{ClientStores, StoreContext}; +use accshift_app::text::Text; +use accshift_app::time::Clock; +use serde_json::{json, Value}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +fn status(setup_id: &str, state: &str) -> AddFlowStatus { + AddFlowStatus { + setup_id: setup_id.to_string(), + state: state.to_string(), + account_id: None, + account_display_name: None, + error_message: None, + } +} + +fn setup_json(setup_id: &str, state: &str, account_id: &str, name: &str, error: &str) -> Value { + json!({ + "setupId": setup_id, + "state": state, + "accountId": account_id, + "accountDisplayName": name, + "errorMessage": error, + }) +} + +struct Rig { + backend: Arc, + jobs: Jobs, + registry: PlatformRegistry, + caches: Arc, + clock: Clock, + stores: ClientStores, + flow: PlatformAddFlow, + active_tab: String, + t0: Instant, +} + +impl Rig { + fn new(active_tab: &str) -> Self { + let backend = Arc::new(support::scripted("dev")); + let jobs = Jobs::new(backend.clone() as Arc, 1, || {}); + let clock = Clock::fixed(1_700_000_000_000.0); + Self { + backend, + jobs, + registry: PlatformRegistry::shipped(), + caches: Arc::new(ProfileCaches::new(UrlPolicy::HttpOrFile, clock.now_ms())), + clock, + stores: ClientStores::new(ClientStorage::new(), StoreContext::shipped()), + flow: PlatformAddFlow::new(), + active_tab: active_tab.to_string(), + t0: Instant::now(), + } + } + + fn view(&self) -> AddFlowView<'_> { + AddFlowView { + active_tab: &self.active_tab, + current_folder_id: None, + is_searching: false, + } + } + + fn with_ctx( + &mut self, + ms: u64, + run: impl FnOnce(&mut PlatformAddFlow, &AddFlowContext<'_>, &mut ClientStores) -> R, + ) -> R { + let ctx = AddFlowContext { + jobs: &self.jobs, + registry: &self.registry, + view: AddFlowView { + active_tab: &self.active_tab, + current_folder_id: None, + is_searching: false, + }, + caches: &self.caches, + clock: &self.clock, + now: self.t0 + Duration::from_millis(ms), + }; + run(&mut self.flow, &ctx, &mut self.stores) + } + + /// What the account loader does on the add button: the adapter begins + /// the setup, the flow takes it over. + fn begin(&mut self, platform_id: &str, answer: Value) { + self.backend.push("platform_begin_setup", Ok(answer)); + let settings = sanitize_settings(&json!({}), &StoreContext::shipped()); + let adapter = self.registry.adapter(platform_id).expect("adapter"); + let started = adapter + .add_account(self.backend.as_ref(), &settings) + .expect("setup begins") + .setup_status + .expect("a setup status"); + self.with_ctx(0, |flow, ctx, _| flow.start(ctx, platform_id, started)); + } + + /// Ticks at `ms` until no job is in flight; every event. + fn tick(&mut self, ms: u64) -> Vec { + let deadline = Instant::now() + Duration::from_secs(10); + let mut events = Vec::new(); + loop { + events.extend(self.with_ctx(ms, |flow, ctx, stores| flow.tick(ctx, stores))); + if !self.flow.is_busy() { + // Fire-and-forget telemetry ran. + self.jobs.run(|_| ()).wait(); + return events; + } + assert!(Instant::now() < deadline, "add flow job never finished"); + std::thread::sleep(Duration::from_millis(2)); + } + } +} + +fn silent_load() -> AddFlowEvent { + AddFlowEvent::LoadAccounts(LoadOptions { + silent: true, + ..LoadOptions::default() + }) +} + +// ------------------------------------------------------------ TS tests + +#[test] +fn a_contended_lock_says_another_operation_is_running() { + let mut rig = Rig::new("steam"); + rig.with_ctx(0, |flow, ctx, _| { + flow.start(ctx, "steam", status("setup-1", "busy")) + }); + let content = rig + .flow + .setup_extension_content("setup-1", Locale::default()) + .expect("content"); + assert_eq!( + content.sections[0].text, + Some(Text::key("platform.setupBusy")) + ); + assert!(content.sections[0].loading); + let pending = rig + .flow + .pending_setup_account(&rig.view()) + .expect("pending"); + assert_eq!(pending.username, Text::key("platform.setupBusy")); + rig.flow.stop(); +} + +#[test] +fn busy_is_non_terminal_and_the_poll_keeps_the_flow_alive() { + let mut rig = Rig::new("steam"); + rig.with_ctx(0, |flow, ctx, _| { + flow.start(ctx, "steam", status("setup-1", "waiting_for_login")) + }); + rig.backend.push( + "platform_get_setup_status", + Ok(setup_json("setup-1", "busy", "", "", "")), + ); + rig.tick(POLL_DELAY.as_millis() as u64); + assert_eq!(rig.flow.flow().unwrap().status.state, "busy"); + assert!(rig + .backend + .calls("telemetry_track_operation_failed") + .is_empty()); + // And it polls again. + assert!(rig.flow.next_deadline().is_some()); +} + +// ------------------------------------------------------------ Steam + +#[test] +fn steam_from_start_to_success() { + let mut rig = Rig::new("steam"); + rig.begin( + "steam", + setup_json("setup-7", "waiting_for_client", "", "", ""), + ); + assert!(rig.flow.is_active()); + assert_eq!(rig.flow.active_platform_setup_id("steam"), Some("setup-7")); + assert_eq!(rig.flow.active_platform_setup_id("riot"), None); + assert!(rig.flow.is_forced_open("steam", "setup-7")); + let pending = rig + .flow + .pending_setup_account(&rig.view()) + .expect("pending"); + assert_eq!(pending.id, "setup-7"); + assert_eq!( + pending.display_name, + Text::key("platform.newAccountPending") + ); + let content = rig + .flow + .setup_extension_content("setup-7", Locale::default()) + .unwrap(); + assert_eq!( + content.sections[0].text, + Some(Text::key("steam.setupWaitingForClient")) + ); + + // Nothing before the 1.5 s timer. + rig.tick(1000); + assert!(rig.backend.calls("platform_get_setup_status").is_empty()); + + // Detected: the card shows the name. + rig.backend.push( + "platform_get_setup_status", + Ok(setup_json("setup-7", "waiting_for_login", "", "Gaben", "")), + ); + rig.tick(1500); + let pending = rig.flow.pending_setup_account(&rig.view()).unwrap(); + assert_eq!(pending.display_name, Text::raw("Gaben")); + assert_eq!(pending.username, Text::key("steam.setupConnected")); + + // Ready at the next poll. The prime gets a full profile, so no retry. + rig.backend.push( + "steam_get_profile_info", + Ok(json!({ + "avatar_url": "https://avatars.example/gaben.jpg", + "display_name": "Gaben", + "vac_banned": false, + "trade_ban_state": "None", + })), + ); + rig.backend.push( + "platform_get_setup_status", + Ok(setup_json( + "setup-7", + "ready", + "76561198100000099", + "Gaben", + "", + )), + ); + let events = rig.tick(3000); + assert_eq!( + events, + vec![ + silent_load(), + AddFlowEvent::Toast(Toast::new( + Text::with("steam.setupReadyWithProfile", [("profile", "Gaben")]), + ToastKind::Default, + )), + ] + ); + assert!(!rig.flow.is_active()); + assert_eq!(rig.flow.next_deadline(), None); + // Steam primes the new profile and counts the add. + assert_eq!( + rig.backend.calls("steam_get_profile_info"), + vec![r#"("76561198100000099",)"#] + ); + assert_eq!( + rig.backend + .calls("telemetry_track_account_add_started") + .len(), + 1 + ); + assert_eq!(rig.backend.calls("telemetry_track_account_added").len(), 1); +} + +#[test] +fn steam_cancel_stops_polling_and_reloads() { + let mut rig = Rig::new("steam"); + rig.begin( + "steam", + setup_json("setup-8", "waiting_for_login", "", "", ""), + ); + // The plain mock has no setup to cancel. + rig.backend.push("platform_cancel_setup", Ok(json!(null))); + rig.with_ctx(10, |flow, ctx, _| flow.cancel(ctx)); + assert_eq!(rig.flow.next_deadline(), None); + let events = rig.tick(10); + assert_eq!(events, vec![silent_load()]); + assert!(!rig.flow.is_active()); + assert_eq!( + rig.backend.calls("platform_cancel_setup"), + vec![r#"("steam", "setup-8")"#] + ); + assert_eq!( + rig.backend + .calls("telemetry_track_account_add_cancelled") + .len(), + 1 + ); + // The poll never fired. + rig.tick(5000); + assert!(rig.backend.calls("platform_get_setup_status").is_empty()); +} + +#[test] +fn steam_poll_error_marks_the_flow_failed() { + let mut rig = Rig::new("steam"); + rig.begin( + "steam", + setup_json("setup-9", "waiting_for_login", "", "", ""), + ); + rig.backend.push( + "platform_get_setup_status", + Err("Steam closed unexpectedly"), + ); + let events = rig.tick(1500); + assert!(events.is_empty()); + let current = rig.flow.flow().unwrap(); + assert_eq!(current.status.state, "failed"); + assert_eq!( + current.status.error_message.as_deref(), + Some("Steam closed unexpectedly") + ); + assert_eq!( + rig.backend.calls("telemetry_track_operation_failed"), + vec![r#"("account_add", "other", Some("steam"))"#] + ); + // No more polling, the card says it failed and offers Close. + assert_eq!(rig.flow.next_deadline(), None); + assert!(rig.flow.pending_setup_account(&rig.view()).is_some()); + let content = rig + .flow + .setup_extension_content("setup-9", Locale::default()) + .unwrap(); + assert_eq!( + content.sections[0].title, + Some(Text::key("steam.setupFailed")) + ); + assert_eq!( + content.sections[1].lines, + vec!["Steam closed unexpectedly".to_string()] + ); + assert_eq!(content.sections[1].chips[0].tone, ChipTone::Red); + assert_eq!(content.chip_action(1, 0), Some(&SetupChipAction::Cancel)); + + // Close: the cancel ends the flow. + rig.with_ctx(2000, |flow, ctx, _| flow.cancel(ctx)); + rig.tick(2000); + assert!(!rig.flow.is_active()); +} + +// ------------------------------------------------------------ descriptor (GOG) + +#[test] +fn descriptor_platform_from_start_to_success_asks_for_a_label() { + let mut rig = Rig::new("gog"); + assert!(rig.registry.definition("gog").is_some()); + rig.begin("gog", setup_json("gog-1", "waiting_for_login", "", "", "")); + let content = rig + .flow + .setup_extension_content("gog-1", Locale::default()) + .unwrap(); + assert_eq!( + content.sections[0].text, + Some(Text::key(setup_key("gog", SetupKey::WaitingForLogin))) + ); + rig.backend.push( + "platform_get_setup_status", + Ok(setup_json("gog-1", "ready", "gog-user-42", "", "")), + ); + let events = rig.tick(1500); + assert_eq!( + events, + vec![ + silent_load(), + AddFlowEvent::Toast(Toast::new(Text::key("gog.setupReady"), ToastKind::Default)), + AddFlowEvent::AccountAdded { + platform_id: "gog".into(), + account_id: "gog-user-42".into(), + display_name: None, + }, + ] + ); + // GOG does not prime a profile. + assert!(rig.backend.calls("steam_get_profile_info").is_empty()); +} + +#[test] +fn descriptor_platform_cancel_from_another_tab() { + let mut rig = Rig::new("gog"); + rig.begin("gog", setup_json("gog-2", "waiting_for_client", "", "", "")); + // The plain mock has no setup to cancel. + rig.backend.push("platform_cancel_setup", Ok(json!(null))); + // A switch to the setup card itself does not cancel. + rig.with_ctx(5, |flow, ctx, _| { + flow.cancel_if_conflicting(ctx, "gog", Some("gog-2")) + }); + assert!(rig.flow.is_active()); + // Another platform: nothing. + rig.with_ctx(5, |flow, ctx, _| { + flow.cancel_if_conflicting(ctx, "steam", None) + }); + assert!(rig.flow.is_active()); + // A switch to another GOG account drops the flow; the tab moved meanwhile. + rig.active_tab = "steam".into(); + rig.with_ctx(5, |flow, ctx, _| { + flow.cancel_if_conflicting(ctx, "gog", Some("gog-other")) + }); + let events = rig.tick(5); + assert!(events.is_empty(), "no reload off the tab"); + assert!(!rig.flow.is_active()); + assert_eq!( + rig.backend.calls("platform_cancel_setup"), + vec![r#"("gog", "gog-2")"#] + ); +} + +#[test] +fn descriptor_platform_failed_state_and_cancel_error() { + let mut rig = Rig::new("gog"); + rig.begin("gog", setup_json("gog-3", "waiting_for_login", "", "", "")); + rig.backend.push( + "platform_get_setup_status", + Ok(setup_json( + "gog-3", + "failed", + "", + "", + "GOG Galaxy refused the login", + )), + ); + rig.tick(1500); + assert_eq!(rig.flow.flow().unwrap().status.state, "failed"); + assert_eq!( + rig.backend.calls("telemetry_track_operation_failed"), + vec![r#"("account_add", "other", Some("gog"))"#] + ); + let content = rig + .flow + .setup_extension_content("gog-3", Locale::default()) + .unwrap(); + assert_eq!( + content.sections[0].text, + Some(Text::key("gog.setupFailedMessage")) + ); + + // The cancel itself fails: its message is the toast, the flow ends. + rig.backend.push("platform_cancel_setup", Err("setup gone")); + rig.with_ctx(2000, |flow, ctx, _| flow.cancel(ctx)); + let events = rig.tick(2000); + assert_eq!( + events, + vec![ + AddFlowEvent::Toast(Toast::new(Text::raw("setup gone"), ToastKind::Default)), + silent_load(), + ] + ); + assert!(!rig.flow.is_active()); +} + +#[test] +fn a_setup_ready_at_once_ends_without_polling() { + let mut rig = Rig::new("discord"); + rig.begin( + "discord", + setup_json("d-1", "ready", "discord-1", "Wumpus", ""), + ); + assert!(!rig.flow.is_active()); + assert_eq!(rig.flow.next_deadline(), None); + rig.tick(5000); + assert!(rig.backend.calls("platform_get_setup_status").is_empty()); +} + +// ------------------------------------------------------------ views + +#[test] +fn the_pending_card_hides_in_folders_searches_and_other_tabs() { + let mut rig = Rig::new("steam"); + rig.with_ctx(0, |flow, ctx, _| { + flow.start(ctx, "steam", status("s-1", "waiting_for_login")) + }); + let folder = AddFlowView { + active_tab: "steam", + current_folder_id: Some("folder-1"), + is_searching: false, + }; + let search = AddFlowView { + active_tab: "steam", + current_folder_id: None, + is_searching: true, + }; + let riot = AddFlowView { + active_tab: "riot", + current_folder_id: None, + is_searching: false, + }; + assert!(rig.flow.pending_setup_account(&folder).is_none()); + assert!(rig.flow.pending_setup_account(&search).is_none()); + assert!(rig.flow.pending_setup_account(&riot).is_none()); + assert!(rig.flow.is_pending_setup_account(&rig.view(), "s-1")); + assert_eq!( + rig.flow + .pending_setup_account(&rig.view()) + .unwrap() + .to_account(Locale::default()) + .id, + "s-1" + ); + assert!(rig + .flow + .setup_extension_content("other", Locale::default()) + .is_none()); +} + +#[test] +fn roblox_offers_the_quick_login_code() { + let mut rig = Rig::new("roblox"); + let mut started = status("r-1", "waiting_for_login"); + started.account_display_name = Some("ABC123".into()); + rig.with_ctx(0, |flow, ctx, _| flow.start(ctx, "roblox", started)); + let content = rig + .flow + .setup_extension_content("r-1", Locale::default()) + .unwrap(); + assert!(content.sections[0].link.is_some()); + assert_eq!( + content.chip_action(0, 1), + Some(&SetupChipAction::CopyCode("ABC123".into())) + ); + rig.flow.stop(); +} diff --git a/crates/accshift-app/tests/platform_shell.rs b/crates/accshift-app/tests/platform_shell.rs new file mode 100644 index 0000000..47e641a --- /dev/null +++ b/crates/accshift-app/tests/platform_shell.rs @@ -0,0 +1,279 @@ +//! The platform shell state (`platformShell.svelte.ts`) and the CS2 bridge +//! queue (`cs2Bridge.test.ts`) against MockBackend. + +mod support; + +use accshift_app::backend::Backend; +use accshift_app::cs2_bridge::Cs2BridgeQueue; +use accshift_app::jobs::Jobs; +use accshift_app::platform_shell::{ + initial_active_tab, is_platform_compatible_with_os, PlatformShell, +}; +use accshift_app::platforms::{PlatformAdapter, PlatformRegistry, RuntimeOs}; +use accshift_app::settings::{sanitize_settings, AppSettings}; +use accshift_app::stores::StoreContext; +use serde_json::{json, Value}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +fn settings(patch: Value) -> AppSettings { + sanitize_settings(&patch, &StoreContext::shipped()) +} + +// ------------------------------------------------------------ shell + +#[test] +fn the_initial_tab_prefers_the_enabled_usable_default() { + let registry = PlatformRegistry::shipped(); + let both = settings(json!({ + "enabledPlatforms": ["steam", "riot"], + "defaultPlatformId": "riot", + })); + assert_eq!( + initial_active_tab(®istry, &both, RuntimeOs::Windows), + "riot" + ); + // The default is not enabled: the first enabled usable one. + let not_enabled = settings(json!({ + "enabledPlatforms": ["steam"], + "defaultPlatformId": "riot", + })); + assert_eq!( + initial_active_tab(®istry, ¬_enabled, RuntimeOs::Windows), + "steam" + ); + // The OS is unknown at boot: nothing is usable, the first enabled wins. + assert_eq!( + initial_active_tab(®istry, &both, RuntimeOs::Unknown), + "steam" + ); +} + +#[test] +fn compatibility_follows_the_supported_os_list() { + let registry = PlatformRegistry::shipped(); + let steam = registry.definition("steam"); + assert!(is_platform_compatible_with_os(steam, RuntimeOs::Windows)); + assert!(!is_platform_compatible_with_os(steam, RuntimeOs::Unknown)); + assert!(!is_platform_compatible_with_os(None, RuntimeOs::Windows)); +} + +#[test] +fn ensure_active_tab_moves_off_an_unusable_tab() { + let registry = PlatformRegistry::shipped(); + let windows_only = registry + .defs() + .iter() + .find(|def| { + def.implemented && def.supports(RuntimeOs::Windows) && !def.supports(RuntimeOs::Linux) + }) + .map(|def| def.id.clone()); + let mut shell = PlatformShell::new( + ®istry, + settings(json!({ "enabledPlatforms": ["steam", "riot"], "defaultPlatformId": "steam" })), + ); + assert_eq!(shell.active_tab(), "steam"); + assert!( + shell.adapter(®istry).is_none(), + "nothing usable on an unknown OS" + ); + shell.set_runtime_os(RuntimeOs::Windows); + assert!(!shell.ensure_active_tab(®istry)); + assert!(shell.active_tab_usable(®istry)); + assert_eq!( + shell.adapter(®istry).map(|a| a.id().to_string()), + Some("steam".into()) + ); + assert_eq!( + shell.accent_color(®istry), + Some(registry.definition("steam").unwrap().accent.clone()) + ); + + if let Some(id) = windows_only { + shell.set_active_tab(id.clone()); + shell.set_runtime_os(RuntimeOs::Linux); + assert!(shell.ensure_active_tab(®istry)); + assert_ne!(shell.active_tab(), id); + assert!(shell.active_tab_usable(®istry)); + } +} + +#[test] +fn derived_lists_zoom_and_unavailable_ids() { + let registry = PlatformRegistry::shipped(); + let mut shell = PlatformShell::new( + ®istry, + settings(json!({ "enabledPlatforms": ["riot", "steam"], "uiScalePercent": 200 })), + ); + shell.set_runtime_os(RuntimeOs::Unknown); + // Shipped order, not the settings order. + let enabled: Vec<&str> = shell + .enabled_platforms(®istry) + .iter() + .map(|def| def.id.as_str()) + .collect(); + assert_eq!(enabled, vec!["steam", "riot"]); + assert_eq!( + shell + .unavailable_platform_ids(®istry) + .into_iter() + .collect::>(), + vec!["riot".to_string(), "steam".to_string()] + ); + shell.set_runtime_os(RuntimeOs::Windows); + assert!(shell.unavailable_platform_ids(®istry).is_empty()); + assert!(!shell.compatible_platforms(®istry).is_empty()); + assert_eq!(shell.ui_zoom_factor(), 1.5); + assert_eq!(shell.app_stage_zoom(), Some(1.5)); + let mut next = shell.settings().clone(); + next.ui_scale_percent = 100; + shell.refresh_settings(&next); + assert_eq!(shell.app_stage_zoom(), None); + next.ui_scale_percent = 10; + shell.set_settings(next); + assert_eq!(shell.ui_zoom_factor(), 0.75); +} + +// ------------------------------------------------------------ CS2 bridge queue + +fn account(steam_id: &str) -> Value { + json!({ "steamId": steam_id, "level": 10, "xp": 1200, "xpMax": 5000, "caseEarned": false }) +} + +struct Bridge { + backend: Arc, + jobs: Jobs, + steam: Arc, + queue: Cs2BridgeQueue, +} + +impl Bridge { + fn new() -> Self { + let backend = Arc::new(support::scripted("dev")); + let jobs = Jobs::new(backend.clone() as Arc, 2, || {}); + let steam = PlatformRegistry::shipped().adapter("steam").unwrap(); + Self { + backend, + jobs, + steam, + queue: Cs2BridgeQueue::new(), + } + } + + fn enabled(&self) { + self.backend.push( + "cs2_bridge_get_settings", + Ok(json!({ "enabled": true, "url": "http://127.0.0.1:1", "tokenConfigured": true })), + ); + } + + /// One frame. + fn tick(&mut self) { + self.queue.tick(&self.jobs, &self.steam, 1_000.0); + } + + fn settle(&mut self) { + let deadline = Instant::now() + Duration::from_secs(10); + while self.queue.is_busy() { + assert!(Instant::now() < deadline, "bridge queue never drained"); + self.tick(); + std::thread::sleep(Duration::from_millis(2)); + } + } + + fn has(&self, steam_id: &str) -> bool { + self.steam.state().cs2.data(steam_id).is_some() + } +} + +#[test] +fn rapid_checks_run_one_at_a_time_in_order() { + let mut bridge = Bridge::new(); + bridge.enabled(); + bridge.backend.push("cs2_bridge_check", Ok(account("one"))); + bridge.backend.push("cs2_bridge_check", Ok(account("two"))); + bridge.queue.check("one"); + bridge.queue.check("two"); + // The first frame sends only the first check. + bridge.tick(); + std::thread::sleep(Duration::from_millis(50)); + assert!(bridge.backend.calls("cs2_bridge_check").len() <= 1); + bridge.settle(); + assert_eq!( + bridge.backend.calls("cs2_bridge_check"), + vec![r#"("one",)"#, r#"("two",)"#] + ); + assert!(bridge.has("one") && bridge.has("two")); + // The settings were read once, by the first check. + assert_eq!(bridge.backend.calls("cs2_bridge_get_settings").len(), 1); + let logs = bridge.backend.calls("log_app_event").join("\n"); + assert!(logs.contains("Bridge check applied")); +} + +#[test] +fn a_check_waits_for_an_in_flight_full_fetch() { + let mut bridge = Bridge::new(); + bridge.enabled(); + bridge + .backend + .push("cs2_bridge_fetch", Ok(json!([account("stale")]))); + bridge + .backend + .push("cs2_bridge_check", Ok(account("fresh"))); + let cache = bridge.steam.state().cs2.clone(); + assert!(bridge.queue.load(&cache, true, 1_000.0)); + // A second load while one is in flight is the same load. + assert!(!bridge.queue.load(&cache, true, 1_000.0)); + bridge.queue.check("fresh"); + bridge.settle(); + assert_eq!(bridge.backend.calls("cs2_bridge_fetch").len(), 1); + assert_eq!(bridge.backend.calls("cs2_bridge_check").len(), 1); + assert!(bridge.has("stale") && bridge.has("fresh")); +} + +#[test] +fn a_disabled_bridge_asks_nothing_more() { + let mut bridge = Bridge::new(); + bridge.backend.push( + "cs2_bridge_get_settings", + Ok(json!({ "enabled": false, "url": "", "tokenConfigured": false })), + ); + bridge.queue.check("one"); + bridge.queue.check("two"); + bridge.settle(); + assert!(bridge.backend.calls("cs2_bridge_check").is_empty()); + // Known disabled: no second settings read. + assert_eq!(bridge.backend.calls("cs2_bridge_get_settings").len(), 1); +} + +#[test] +fn a_failed_check_is_silent_and_the_queue_goes_on() { + let mut bridge = Bridge::new(); + bridge.enabled(); + bridge.backend.push("cs2_bridge_check", Err("bridge down")); + bridge.backend.push("cs2_bridge_check", Ok(account("two"))); + bridge.queue.check("one"); + bridge.queue.check("two"); + bridge.settle(); + assert!(!bridge.has("one") && bridge.has("two")); + let logs = bridge.backend.calls("log_app_event").join("\n"); + assert!(logs.contains("Bridge check failed")); +} + +#[test] +fn the_steam_tab_loads_once_per_ttl() { + let mut bridge = Bridge::new(); + bridge.enabled(); + bridge + .backend + .push("cs2_bridge_fetch", Ok(json!([account("a")]))); + let cache = bridge.steam.state().cs2.clone(); + assert!(!bridge.queue.ensure_loaded(&cache, "riot", 3, 1_000.0)); + assert!(!bridge.queue.ensure_loaded(&cache, "steam", 0, 1_000.0)); + assert!(bridge.queue.ensure_loaded(&cache, "steam", 3, 1_000.0)); + bridge.settle(); + assert!(bridge.has("a")); + // Fresh: the next show does nothing. + let cache = bridge.steam.state().cs2.clone(); + assert!(!bridge.queue.ensure_loaded(&cache, "steam", 3, 2_000.0)); +} diff --git a/crates/accshift-app/tests/secure_pin.rs b/crates/accshift-app/tests/secure_pin.rs new file mode 100644 index 0000000..5a4fb31 --- /dev/null +++ b/crates/accshift-app/tests/secure_pin.rs @@ -0,0 +1,349 @@ +//! `src/lib/shared/pin.test.ts` and `pinSession.test.ts` against +//! `accshift_app::secure::pin`. + +mod support; + +use accshift_app::secure::pin::{ + hash_pin_code, is_pin_locked_error, is_pin_locked_message, is_valid_pin_hash, lock_pin_session, + sanitize_pin_digits, unlock_pin_session, verify_pin_code, PinCheck, PinFailureWait, + PinVerification, PIN_FAILURE_DELAY_MS, +}; +use accshift_app::BackendError; +use serde_json::json; + +// The cross-check vector of pin.test.ts: PIN 4321 under a fixed salt. +const PBKDF2_HASH: &str = + "000102030405060708090a0b0c0d0e0f:e19d9507e40b77fbb7503faedce7cb4ebf8c6820a8b746d9dfa9fcab899ec65d"; +// SHA-256 of the digits "1234", the legacy vector. +const LEGACY_1234: &str = "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4"; +const LOCKED: &str = "pin_locked: Accshift is locked. Enter the PIN to switch accounts."; + +fn is_pbkdf2(hash: &str) -> bool { + let Some((salt, digest)) = hash.split_once(':') else { + return false; + }; + salt.len() == 32 + && digest.len() == 64 + && hash + .chars() + .all(|c| c == ':' || c.is_ascii_digit() || ('a'..='f').contains(&c)) +} + +fn hash(code: &str) -> String { + hash_pin_code(code).expect("a four digit PIN hashes") +} + +const fn verdict(matches: bool) -> PinVerification { + PinVerification { + matches, + rehashed: None, + } +} + +// ------------------------------------------------------------ sanitizePinDigits + +#[test] +fn sanitize_removes_non_digits() { + assert_eq!(sanitize_pin_digits("1a2b3c4d"), "1234"); +} + +#[test] +fn sanitize_truncates_to_four_digits() { + assert_eq!(sanitize_pin_digits("123456"), "1234"); +} + +#[test] +fn sanitize_returns_empty_for_no_digits() { + assert_eq!(sanitize_pin_digits("abc"), ""); +} + +#[test] +fn sanitize_handles_empty_string() { + assert_eq!(sanitize_pin_digits(""), ""); +} + +// ------------------------------------------------------------ isValidPinHash + +#[test] +fn valid_hash_accepts_pbkdf2_format() { + assert!(is_valid_pin_hash(&format!( + "{}:{}", + "a".repeat(32), + "b".repeat(64) + ))); +} + +#[test] +fn valid_hash_accepts_legacy_sha256() { + assert!(is_valid_pin_hash(&"a".repeat(64))); +} + +#[test] +fn valid_hash_rejects_invalid_format() { + assert!(!is_valid_pin_hash("invalid")); +} + +#[test] +fn valid_hash_rejects_empty_string() { + assert!(!is_valid_pin_hash("")); +} + +// ------------------------------------------------------------ hashPinCode + +#[test] +fn hash_returns_salt_and_hash_for_valid_pin() { + assert!(is_pbkdf2(&hash("1234"))); +} + +#[test] +fn hash_returns_nothing_for_invalid_pin() { + assert_eq!(hash_pin_code("12"), None); + assert_eq!(hash_pin_code("abc"), None); +} + +#[test] +fn hash_generates_different_salts_each_call() { + assert_ne!(hash("1234"), hash("1234")); +} + +// ------------------------------------------------------------ verifyPinCode + +#[test] +fn verify_accepts_correct_pin_against_pbkdf2() { + assert_eq!(verify_pin_code("4321", PBKDF2_HASH), verdict(true)); +} + +#[test] +fn verify_rejects_wrong_pin() { + assert_eq!(verify_pin_code("0000", PBKDF2_HASH), verdict(false)); +} + +#[test] +fn verify_handles_legacy_sha256() { + assert!(verify_pin_code("1234", LEGACY_1234).matches); + assert!(!verify_pin_code("0000", LEGACY_1234).matches); +} + +#[test] +fn verify_handles_uppercase_stored_hashes() { + assert!(verify_pin_code("4321", &PBKDF2_HASH.to_uppercase()).matches); +} + +#[test] +fn verify_rejects_invalid_pin_length() { + assert_eq!(verify_pin_code("12", PBKDF2_HASH), verdict(false)); +} + +// ------------------------------------------------------------ legacy migration + +#[test] +fn legacy_hash_comes_back_with_a_pbkdf2_replacement_used_once() { + let first = verify_pin_code("1234", LEGACY_1234); + assert!(first.matches); + let rehashed = first.rehashed.expect("a replacement"); + assert!(is_pbkdf2(&rehashed)); + assert_ne!(rehashed, LEGACY_1234); + // The replacement takes the same PIN and has nothing left to migrate. + assert_eq!(verify_pin_code("1234", &rehashed), verdict(true)); + assert!(!verify_pin_code("0000", &rehashed).matches); +} + +#[test] +fn legacy_hash_rewrites_nothing_when_the_code_is_wrong() { + assert_eq!(verify_pin_code("0000", LEGACY_1234), verdict(false)); +} + +// ------------------------------------------------------------ CLI interop + +#[test] +fn verifies_the_shared_vectors() { + assert!(!verify_pin_code("1111", PBKDF2_HASH).matches); + assert!(verify_pin_code("1234", LEGACY_1234).matches); +} + +// ------------------------------------------------------------ pin_locked errors + +#[test] +fn recognizes_the_backend_refusal() { + assert!(is_pin_locked_message(LOCKED)); + assert!(is_pin_locked_error(&BackendError::new(LOCKED))); + assert!(!is_pin_locked_message("Steam is not installed")); + assert!(!is_pin_locked_message("")); +} + +// ------------------------------------------------------------ unlockPinSession + +#[test] +fn unlocks_through_the_backend() { + let backend = support::scripted("dev"); + backend.push( + "pin_unlock", + Ok(json!({ "status": "unlocked", "legacy": false, "retryAfterMs": 0 })), + ); + let check = unlock_pin_session(&backend, "4321", PBKDF2_HASH); + assert_eq!(backend.calls("pin_unlock"), vec![r#"("4321",)"#]); + assert_eq!(check, PinCheck::Match { rehashed: None }); +} + +#[test] +fn rehashes_a_legacy_pin_the_backend_accepted() { + let backend = support::scripted("dev"); + backend.push( + "pin_unlock", + Ok(json!({ "status": "unlocked", "legacy": true, "retryAfterMs": 0 })), + ); + match unlock_pin_session(&backend, "1234", "") { + PinCheck::Match { + rehashed: Some(hash), + } => assert!(is_pbkdf2(&hash)), + other => panic!("expected a rehashed match, got {other:?}"), + } +} + +#[test] +fn takes_the_backend_verdict_on_a_wrong_code() { + let backend = support::scripted("dev"); + backend.push( + "pin_unlock", + Ok(json!({ "status": "invalid", "legacy": false, "retryAfterMs": 8000 })), + ); + // 4321 matches the local hash: the backend still wins. + assert_eq!( + unlock_pin_session(&backend, "4321", PBKDF2_HASH), + PinCheck::Invalid { + retry_after_ms: 8000 + } + ); +} + +#[test] +fn passes_a_rate_limit_refusal_through() { + let backend = support::scripted("dev"); + backend.push( + "pin_unlock", + Ok(json!({ "status": "retry_later", "legacy": false, "retryAfterMs": 4000 })), + ); + assert_eq!( + unlock_pin_session(&backend, "4321", PBKDF2_HASH), + PinCheck::RetryLater { + retry_after_ms: 4000 + } + ); +} + +#[test] +fn checks_the_local_hash_when_the_backend_has_no_pin() { + let backend = support::scripted("dev"); + for _ in 0..2 { + backend.push( + "pin_unlock", + Ok(json!({ "status": "not_configured", "legacy": false, "retryAfterMs": 0 })), + ); + } + assert_eq!( + unlock_pin_session(&backend, "4321", PBKDF2_HASH), + PinCheck::Match { rehashed: None } + ); + assert_eq!( + unlock_pin_session(&backend, "1111", PBKDF2_HASH), + PinCheck::Invalid { retry_after_ms: 0 } + ); +} + +#[test] +fn checks_the_local_hash_when_the_backend_call_fails() { + let backend = support::scripted("dev"); + backend.push("pin_unlock", Err("ipc down")); + backend.push("pin_unlock", Err("ipc down")); + assert_eq!( + unlock_pin_session(&backend, "1111", PBKDF2_HASH), + PinCheck::Invalid { retry_after_ms: 0 } + ); + assert_eq!( + unlock_pin_session(&backend, "4321", PBKDF2_HASH), + PinCheck::Match { rehashed: None } + ); +} + +#[test] +fn lets_the_screen_go_when_neither_side_holds_a_pin() { + let backend = support::scripted("dev"); + backend.push( + "pin_unlock", + Ok(json!({ "status": "not_configured", "legacy": false, "retryAfterMs": 0 })), + ); + assert_eq!( + unlock_pin_session(&backend, "0000", ""), + PinCheck::Match { rehashed: None } + ); +} + +#[test] +fn the_mock_answers_with_its_own_hash() { + // MockBackend checks the code against the hash its settings hold. + let backend = support::scripted("dev"); + backend + .inner + .set_pin_settings(&json!({ "pinEnabled": true, "pinHash": PBKDF2_HASH })); + assert_eq!( + unlock_pin_session(&backend, "1111", PBKDF2_HASH), + PinCheck::Invalid { + retry_after_ms: 1000 + } + ); + assert_eq!( + unlock_pin_session(&backend, "4321", PBKDF2_HASH), + PinCheck::Match { rehashed: None } + ); +} + +// ------------------------------------------------------------ lockPinSession + +#[test] +fn locks_the_backend_and_swallows_a_failure() { + let backend = support::scripted("dev"); + backend.push("pin_lock", Err("ipc down")); + lock_pin_session(&backend); + assert_eq!(backend.calls("pin_lock").len(), 1); +} + +// ------------------------------------------------------------ pinFailureWait + +#[test] +fn keeps_the_local_pause_for_a_plain_wrong_code() { + assert_eq!( + PinCheck::Invalid { + retry_after_ms: 1000 + } + .failure_wait(), + Some(PinFailureWait { + wait_ms: PIN_FAILURE_DELAY_MS, + too_many_attempts: false, + }) + ); + assert_eq!(PinCheck::Match { rehashed: None }.failure_wait(), None); +} + +#[test] +fn follows_the_backend_once_its_wait_is_longer() { + assert_eq!( + PinCheck::Invalid { + retry_after_ms: 8000 + } + .failure_wait(), + Some(PinFailureWait { + wait_ms: 8000, + too_many_attempts: true, + }) + ); + assert_eq!( + PinCheck::RetryLater { + retry_after_ms: 500 + } + .failure_wait(), + Some(PinFailureWait { + wait_ms: PIN_FAILURE_DELAY_MS, + too_many_attempts: true, + }) + ); +} diff --git a/crates/accshift-app/tests/secure_screen.rs b/crates/accshift-app/tests/secure_screen.rs new file mode 100644 index 0000000..5653287 --- /dev/null +++ b/crates/accshift-app/tests/secure_screen.rs @@ -0,0 +1,463 @@ +//! The secure screen against MockBackend: lock at start, wrong codes up to +//! the lockout, unlock, the inactivity lock, the backend refusal, the legacy +//! rehash, plus `useWindowActivity.test.ts` and the inactivity timer. + +mod support; + +use accshift_app::backend::Backend; +use accshift_app::jobs::Jobs; +use accshift_app::secure::pin::PIN_FAILURE_DELAY_MS; +use accshift_app::secure::screen::{persist_pin_hash, UNLOCK_SETTLE}; +use accshift_app::secure::{ + InactivityBlur, ScreenCover, SecureEvent, SecureInputs, SecureScreen, WindowActivity, + WindowView, +}; +use accshift_app::settings::{sanitize_settings, AppSettings}; +use accshift_app::storage::ClientStorage; +use accshift_app::stores::{ClientStores, StoreContext}; +use accshift_app::text::Text; +use accshift_app::BackendError; +use serde_json::{json, Value}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +const PBKDF2_HASH: &str = + "000102030405060708090a0b0c0d0e0f:e19d9507e40b77fbb7503faedce7cb4ebf8c6820a8b746d9dfa9fcab899ec65d"; +const LEGACY_1234: &str = "03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4"; +const LOCKED: &str = "pin_locked: Accshift is locked. Enter the PIN to switch accounts."; + +fn settings(patch: Value) -> AppSettings { + sanitize_settings(&patch, &StoreContext::shipped()) +} + +fn pin_settings(hash: &str, blur_seconds: i64) -> AppSettings { + settings(json!({ + "pinEnabled": true, + "pinHash": hash, + "inactivityBlurSeconds": blur_seconds, + })) +} + +struct Rig { + backend: Arc, + jobs: Jobs, + settings: AppSettings, + window: WindowView, + selection_view: bool, + screen: SecureScreen, + t0: Instant, +} + +impl Rig { + fn new(settings: AppSettings) -> Self { + let backend = Arc::new(support::scripted("dev")); + backend.inner.set_pin_settings(&json!({ + "pinEnabled": settings.pin_enabled, + "pinHash": settings.pin_hash, + })); + let jobs = Jobs::new(backend.clone() as Arc, 1, || {}); + let t0 = Instant::now(); + let screen = SecureScreen::new(&settings, t0); + Self { + backend, + jobs, + settings, + window: WindowView::default(), + selection_view: true, + screen, + t0, + } + } + + fn at(&self, ms: u64) -> Instant { + self.t0 + Duration::from_millis(ms) + } + + /// One update at `ms`, then more at the same instant until no unlock is + /// in flight. Every event of the frames, in order. + fn frame(&mut self, ms: u64) -> Vec { + let now = self.at(ms); + let deadline = Instant::now() + Duration::from_secs(20); + let mut events = Vec::new(); + loop { + let inputs = SecureInputs { + settings: &self.settings, + is_account_selection_view: self.selection_view, + window: self.window, + app_version: "1.2.3", + }; + events.extend(self.screen.update(&inputs, &self.jobs, now)); + if !self.screen.is_busy() { + return events; + } + assert!(Instant::now() < deadline, "unlock never finished"); + std::thread::sleep(Duration::from_millis(2)); + } + } + + fn mount(&mut self) -> Vec { + let mut events = self.frame(0); + self.screen.handle_app_mounted(&self.jobs, self.t0); + events.extend(self.frame(0)); + events + } + + fn type_code(&mut self, code: &str, ms: u64) -> Vec { + self.screen.set_pin_attempt(code); + self.frame(ms) + } + + /// The fire-and-forget calls ran. + fn drain_jobs(&self) { + self.jobs.run(|_| ()).wait(); + } +} + +#[test] +fn starts_locked_with_a_valid_pin_and_tells_the_backend() { + let mut rig = Rig::new(pin_settings(PBKDF2_HASH, 0)); + assert!(rig.screen.is_pin_locked()); + let events = rig.mount(); + assert!(events.contains(&SecureEvent::FocusPinInput)); + rig.drain_jobs(); + assert_eq!(rig.backend.calls("pin_lock").len(), 1); + assert!(rig.screen.pin_overlay_visible()); + assert!(rig.screen.is_obscured()); + let cover = ScreenCover::compute(&rig.screen, false, false); + assert!(cover.stage_locked && cover.shell_inert && cover.keys_locked && cover.frost_visible); +} + +#[test] +fn starts_unlocked_without_a_pin_or_with_a_malformed_one() { + let rig = Rig::new(settings(json!({}))); + assert!(!rig.screen.is_pin_locked()); + let mut rig = Rig::new(pin_settings("not-a-hash", 0)); + assert!(!rig.screen.is_pin_locked()); + rig.mount(); + assert!(!rig.screen.is_pin_locked()); + assert_eq!(rig.backend.calls("pin_lock").len(), 0); +} + +#[test] +fn a_wrong_code_holds_the_field_then_the_right_one_unlocks() { + let mut rig = Rig::new(pin_settings(PBKDF2_HASH, 0)); + rig.mount(); + rig.drain_jobs(); + // The mock session follows: switching is refused while locked. + let refused = rig + .backend + .inner + .platform_switch_account("steam", "76561198100000001", &json!({})) + .expect_err("locked"); + assert!(rig.screen.report_backend_error(&refused)); + + // The mock checks the hash and answers a plain wrong code with 1 s. + rig.type_code("1111", 0); + assert_eq!(rig.screen.pin_error(), Some(&Text::key("pin.invalid"))); + assert!(rig.screen.is_pin_retry_locked()); + assert!(!rig.screen.pin_input_enabled()); + assert_eq!(rig.screen.pin_attempt(), ""); + + // Typing during the pause does not send anything. + rig.backend.clear_log(); + rig.type_code("4321", 100); + assert!(rig.backend.calls("pin_unlock").is_empty()); + assert!(rig.screen.is_pin_locked()); + + // The pause ends at 1200 ms, the field takes focus, the code goes out. + let events = rig.frame(PIN_FAILURE_DELAY_MS); + assert!(events.contains(&SecureEvent::FocusPinInput)); + assert_eq!(rig.backend.calls("pin_unlock").len(), 1); + assert_eq!(rig.screen.pin_error(), None); + // Accepted: the overlay settles for 240 ms before it lets go. + assert!(rig.screen.is_pin_locked()); + let settle = PIN_FAILURE_DELAY_MS + UNLOCK_SETTLE.as_millis() as u64; + rig.frame(settle); + assert!(!rig.screen.is_pin_locked()); + assert!(!rig.screen.is_obscured()); + + // The mock session is unlocked: a switch goes through. + assert_eq!( + rig.backend + .inner + .platform_switch_account("steam", "76561198100000001", &json!({})), + Ok(()) + ); +} + +#[test] +fn wrong_codes_escalate_to_the_lockout_then_unlock() { + let mut rig = Rig::new(pin_settings(PBKDF2_HASH, 0)); + rig.mount(); + // The backend's schedule (accshift_core::pin::failure_delay): four + // wrong codes at 1 s, then doubling. + let mut ms = 0; + for failures in 1..=6u32 { + let retry = accshift_core::pin::failure_delay(failures).as_millis() as u64; + rig.backend.push( + "pin_unlock", + Ok(json!({ "status": "invalid", "legacy": false, "retryAfterMs": retry })), + ); + rig.type_code("1111", ms); + let expected = if retry > PIN_FAILURE_DELAY_MS { + Text::with("pin.tooManyAttempts", [("seconds", retry.div_ceil(1000))]) + } else { + Text::key("pin.invalid") + }; + assert_eq!( + rig.screen.pin_error(), + Some(&expected), + "failure {failures}" + ); + let wait = retry.max(PIN_FAILURE_DELAY_MS); + // Still held one millisecond before the wait ends. + rig.frame(ms + wait - 1); + assert!(rig.screen.is_pin_retry_locked(), "failure {failures}"); + ms += wait; + rig.frame(ms); + assert!(!rig.screen.is_pin_retry_locked(), "failure {failures}"); + } + // Locked out: the backend refuses even the right code for a while. + rig.backend.push( + "pin_unlock", + Ok(json!({ "status": "retry_later", "legacy": false, "retryAfterMs": 3000 })), + ); + rig.type_code("4321", ms); + assert_eq!( + rig.screen.pin_error(), + Some(&Text::with("pin.tooManyAttempts", [("seconds", 3u64)])) + ); + ms += 3000; + rig.frame(ms); + // Then the mock checks it for real. + rig.type_code("4321", ms); + rig.frame(ms + UNLOCK_SETTLE.as_millis() as u64); + assert!(!rig.screen.is_pin_locked()); + assert_eq!(rig.backend.calls("pin_unlock").len(), 8); +} + +#[test] +fn inactivity_blurs_then_locks_when_a_pin_is_set() { + let mut rig = Rig::new(pin_settings(PBKDF2_HASH, 30)); + // Start unlocked, as after a first unlock. + rig.mount(); + rig.type_code("4321", 0); + rig.frame(UNLOCK_SETTLE.as_millis() as u64); + assert!(!rig.screen.is_pin_locked()); + rig.backend.clear_log(); + + // Activity pushes the deadline: a key at 20 s, nothing until 50 s. + rig.screen.on_key_down(rig.at(20_000)); + rig.frame(49_000); + assert!(!rig.screen.is_pin_locked()); + let events = rig.frame(50_000); + assert!(rig.screen.blur().is_blurred()); + assert!(rig.screen.is_pin_locked()); + assert!(events.contains(&SecureEvent::CloseContextMenu)); + assert!(events.contains(&SecureEvent::FocusPinInput)); + rig.drain_jobs(); + assert_eq!(rig.backend.calls("pin_lock").len(), 1); + // The PIN overlay wins over the AFK cover. + assert!(!rig.screen.afk_overlay_visible()); +} + +#[test] +fn inactivity_without_a_pin_shows_the_afk_cover() { + let mut rig = Rig::new(settings(json!({ "inactivityBlurSeconds": 10 }))); + rig.mount(); + rig.frame(9_999); + assert!(!rig.screen.afk_overlay_visible()); + let events = rig.frame(10_000); + assert!(rig.screen.afk_overlay_visible()); + assert!(rig.screen.afk_wave_active()); + assert_eq!(rig.screen.afk_version_label(), Some("1.2.3")); + assert!(events.contains(&SecureEvent::CloseContextMenu)); + assert!(!rig.screen.is_pin_locked()); + + // Any key lifts it; the wave text fades for 900 ms more. + rig.screen.on_key_down(rig.at(11_000)); + rig.frame(11_000); + assert!(!rig.screen.afk_overlay_visible()); + assert!(rig.screen.afk_wave_active()); + rig.frame(11_900); + assert!(!rig.screen.afk_wave_active()); + + // The AFK cover only covers the account grid. + rig.selection_view = false; + rig.frame(30_000); + assert!(rig.screen.blur().is_blurred()); + assert!(!rig.screen.afk_overlay_visible()); +} + +#[test] +fn the_backend_refusal_locks_until_it_accepts_a_code() { + // No PIN in this window's settings: only the backend's lock holds. + let mut rig = Rig::new(settings(json!({}))); + rig.mount(); + assert!(!rig + .screen + .report_backend_error(&BackendError::new("Steam is not installed"))); + assert!(!rig.screen.is_pin_locked()); + assert!(rig.screen.report_backend_error(&BackendError::new(LOCKED))); + let events = rig.frame(10); + assert!(rig.screen.is_pin_locked()); + assert!(rig.screen.is_backend_locked()); + assert!(events.contains(&SecureEvent::FocusPinInput)); + // The backend is locked already: no pin_lock from this side. + rig.drain_jobs(); + assert_eq!(rig.backend.calls("pin_lock").len(), 0); + + rig.backend.push( + "pin_unlock", + Ok(json!({ "status": "unlocked", "legacy": false, "retryAfterMs": 0 })), + ); + rig.type_code("2580", 20); + rig.frame(20 + UNLOCK_SETTLE.as_millis() as u64); + assert!(!rig.screen.is_pin_locked()); + assert!(!rig.screen.is_backend_locked()); +} + +#[test] +fn a_legacy_hash_is_rehashed_and_persisted() { + let mut rig = Rig::new(pin_settings(LEGACY_1234, 0)); + rig.mount(); + let events = rig.type_code("1234", 0); + let hash = events + .iter() + .find_map(|event| match event { + SecureEvent::PersistPinHash(hash) => Some(hash.clone()), + _ => None, + }) + .expect("a PBKDF2 replacement"); + assert!(hash.contains(':')); + assert!(accshift_app::secure::pin::verify_pin_code("1234", &hash).matches); + + let mut stores = ClientStores::new(ClientStorage::new(), StoreContext::shipped()); + let mut current = stores.settings().clone(); + current.pin_enabled = true; + current.pin_hash = LEGACY_1234.to_string(); + current.inactivity_blur_seconds = 120; + stores.save_settings(¤t); + persist_pin_hash(&mut stores, &hash); + assert_eq!(stores.settings().pin_hash, hash); + // Only the hash travels. + assert_eq!(stores.settings().inactivity_blur_seconds, 120); +} + +#[test] +fn turning_the_pin_off_drops_the_lock() { + let mut rig = Rig::new(pin_settings(PBKDF2_HASH, 0)); + rig.mount(); + rig.screen.set_pin_attempt("12"); + rig.settings.pin_enabled = false; + rig.frame(5); + assert!(!rig.screen.is_pin_locked()); + assert_eq!(rig.screen.pin_attempt(), ""); +} + +#[test] +fn minimized_with_suspend_stops_drawing_and_closes_the_menu() { + let mut patch = pin_settings(PBKDF2_HASH, 0); + patch.suspend_graphics_when_minimized = true; + let mut rig = Rig::new(patch); + rig.mount(); + rig.window = WindowView { + foreground: false, + minimized: true, + page_visible: true, + }; + let events = rig.frame(5); + assert!(rig.screen.render_suspended()); + assert!(events.contains(&SecureEvent::CloseContextMenu)); + assert!(!rig.screen.pin_overlay_visible()); + assert!(!rig.screen.blur().is_listening()); + let cover = ScreenCover::compute(&rig.screen, false, false); + assert!(cover.render_suspended && cover.grid_hidden && !cover.frost_visible); + rig.window = WindowView::default(); + rig.frame(10); + assert!(rig.screen.blur().is_listening()); +} + +#[test] +fn streamer_mode_obscures_and_freezes_the_shell() { + let rig = Rig::new(settings(json!({}))); + let cover = ScreenCover::compute(&rig.screen, true, false); + assert!(cover.shell_obscured && cover.shell_inert && cover.keys_locked); + assert!(!cover.stage_locked && !cover.frost_visible); + assert_eq!( + ScreenCover::compute(&rig.screen, false, false), + ScreenCover::default() + ); +} + +// ------------------------------------------------------------ inactivity timer + +#[test] +fn the_pointer_resets_the_timer_at_most_every_200_ms() { + let t0 = Instant::now(); + let at = |ms: u64| t0 + Duration::from_millis(ms); + let mut blur = InactivityBlur::new(t0); + blur.start(t0, 1); + blur.attach_listeners(); + blur.on_pointer_move(at(150), 1); + // Throttled: the deadline still counts from 0. + assert_eq!(blur.next_deadline(), Some(at(1000))); + blur.on_pointer_move(at(300), 1); + assert_eq!(blur.next_deadline(), Some(at(1300))); + // Detached: nothing counts. + blur.detach_listeners(); + blur.on_key_down(at(900), 1); + assert!(blur.tick(at(1300), 1)); + assert!(blur.is_blurred()); +} + +#[test] +fn a_zero_threshold_never_blurs() { + let t0 = Instant::now(); + let mut blur = InactivityBlur::new(t0); + blur.start(t0, 0); + assert_eq!(blur.next_deadline(), None); + assert!(!blur.tick(t0 + Duration::from_secs(3600), 0)); + assert!(!blur.is_blurred()); +} + +// ------------------------------------------------------------ window activity + +#[test] +fn window_activity_drops_events_after_stop() { + let mut activity = WindowActivity::new(); + activity.on_focus_changed(false, true); + assert!(activity.is_focused(), "not started yet"); + activity.start(true); + assert!(activity.take_sync_request()); + activity.stop(); + activity.on_focus_changed(false, true); + activity.on_resized(); + assert!(activity.is_focused()); + assert!(!activity.take_sync_request()); + + // A second pass works as the first. + activity.start(true); + assert!(activity.take_sync_request()); + activity.on_focus_changed(false, true); + assert!(!activity.is_foreground()); + assert!(activity.take_sync_request()); + activity.apply_sync(true, Some(false), Some(true)); + assert!(activity.is_minimized()); + activity.on_focus_changed(true, true); + assert!(activity.is_foreground()); + assert!(!activity.is_minimized()); +} + +#[test] +fn window_activity_keeps_old_values_when_a_query_fails() { + let mut activity = WindowActivity::new(); + activity.start(true); + activity.apply_sync(true, Some(false), Some(true)); + activity.apply_sync(false, None, None); + assert!(!activity.is_focused()); + assert!(activity.is_minimized()); + assert!(!activity.is_page_visible()); + activity.on_visibility_changed(true); + assert!(activity.take_sync_request()); +} diff --git a/crates/accshift-app/tests/streamer_mode.rs b/crates/accshift-app/tests/streamer_mode.rs new file mode 100644 index 0000000..0afd54d --- /dev/null +++ b/crates/accshift-app/tests/streamer_mode.rs @@ -0,0 +1,178 @@ +//! `useStreamerMode.test.ts` and streamer mode against MockBackend: on, off, +//! dismissed, and what the cover takes away. + +mod support; + +use accshift_app::backend::Backend; +use accshift_app::jobs::Jobs; +use accshift_app::secure::{ScreenCover, SecureScreen}; +use accshift_app::settings::{sanitize_settings, StreamerMode}; +use accshift_app::storage::ClientStorage; +use accshift_app::stores::{ClientStores, StoreContext}; +use accshift_app::streamer::{StreamerModeController, POLL_INTERVAL}; +use serde_json::json; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +struct Rig { + backend: Arc, + jobs: Jobs, + controller: StreamerModeController, + t0: Instant, +} + +impl Rig { + fn new() -> Self { + let backend = Arc::new(support::scripted("dev")); + let jobs = Jobs::new(backend.clone() as Arc, 1, || {}); + let settings = sanitize_settings(&json!({}), &StoreContext::shipped()); + assert_eq!(settings.streamer_mode, StreamerMode::Auto); + Self { + backend, + jobs, + controller: StreamerModeController::new(&settings), + t0: Instant::now(), + } + } + + fn at(&self, ms: u64) -> Instant { + self.t0 + Duration::from_millis(ms) + } + + /// Ticks at `ms` until no detection is in flight. True when `active` + /// changed on the way. + fn tick(&mut self, ms: u64, hidden: bool) -> bool { + let now = self.at(ms); + let deadline = Instant::now() + Duration::from_secs(10); + let mut changed = self.controller.tick(&self.jobs, now, hidden); + while self.controller.is_busy() { + assert!(Instant::now() < deadline, "detection never finished"); + std::thread::sleep(Duration::from_millis(2)); + changed |= self.controller.tick(&self.jobs, now, hidden); + } + changed + } + + fn detections(&self) -> usize { + self.backend.calls("detect_streaming_software").len() + } + + fn answer(&self, detected: bool) { + self.backend + .push("detect_streaming_software", Ok(json!(detected))); + } +} + +#[test] +fn polls_every_few_seconds_while_visible_and_far_less_while_hidden() { + let mut rig = Rig::new(); + rig.controller.start(&rig.jobs, rig.at(0)); + rig.tick(0, false); + assert_eq!(rig.detections(), 1); + + let step = POLL_INTERVAL.as_millis() as u64; + let mut ms = 0; + for _ in 0..2 { + ms += step; + rig.tick(ms, false); + } + assert_eq!(rig.detections(), 3); + + // 28 s hidden: one more poll at most, against seven while visible. + for _ in 0..7 { + ms += step; + rig.tick(ms, true); + } + assert!(rig.detections() <= 4); + + ms += step; + rig.tick(ms, false); + assert!(rig.detections() >= 4); + rig.controller.stop(); + assert!(!rig.controller.is_running()); +} + +#[test] +fn detection_turns_the_cover_on_and_off_and_reports_once() { + let mut rig = Rig::new(); + let step = POLL_INTERVAL.as_millis() as u64; + rig.answer(true); + rig.controller.start(&rig.jobs, rig.at(0)); + assert!(rig.tick(0, false)); + assert!(rig.controller.active()); + + // What it hides: the whole shell, blurred, inert and without keys. + let screen = SecureScreen::new( + &sanitize_settings(&json!({}), &StoreContext::shipped()), + rig.at(0), + ); + let cover = ScreenCover::compute(&screen, rig.controller.active(), false); + assert!(cover.shell_obscured && cover.shell_inert && cover.keys_locked); + + // Still streaming: no change, no second report. + rig.answer(true); + assert!(!rig.tick(step, false)); + rig.jobs.run(|_| ()).wait(); + assert_eq!(rig.backend.calls("telemetry_track_streamer_mode").len(), 1); + + // The software quits: the cover goes. + rig.answer(false); + assert!(rig.tick(2 * step, false)); + assert!(!rig.controller.active()); + assert_eq!( + ScreenCover::compute(&screen, rig.controller.active(), false), + ScreenCover::default() + ); +} + +#[test] +fn dismiss_holds_until_the_stream_stops() { + let mut rig = Rig::new(); + let step = POLL_INTERVAL.as_millis() as u64; + rig.answer(true); + rig.controller.start(&rig.jobs, rig.at(0)); + rig.tick(0, false); + rig.controller.dismiss(); + assert!(!rig.controller.active()); + rig.answer(true); + rig.tick(step, false); + assert!(!rig.controller.active()); + // A pause in the stream re-arms it. + rig.answer(false); + rig.tick(2 * step, false); + rig.answer(true); + rig.tick(3 * step, false); + assert!(rig.controller.active()); +} + +#[test] +fn a_failed_detection_keeps_the_state() { + let mut rig = Rig::new(); + rig.answer(true); + rig.controller.start(&rig.jobs, rig.at(0)); + rig.tick(0, false); + rig.backend + .push("detect_streaming_software", Err("scan failed")); + assert!(!rig.tick(POLL_INTERVAL.as_millis() as u64, false)); + assert!(rig.controller.active()); +} + +#[test] +fn disable_permanently_saves_off_and_stops_detecting() { + let mut rig = Rig::new(); + let mut stores = ClientStores::new(ClientStorage::new(), StoreContext::shipped()); + rig.answer(true); + rig.controller.start(&rig.jobs, rig.at(0)); + rig.tick(0, false); + rig.controller.disable_permanently(&mut stores); + assert_eq!(stores.settings().streamer_mode, StreamerMode::Off); + assert!(!rig.controller.enabled()); + assert!(!rig.controller.active()); + assert!(!stores.take_all_saves().is_empty()); + + // Off: the interval runs but asks nothing. + rig.backend.clear_log(); + rig.tick(POLL_INTERVAL.as_millis() as u64, false); + assert_eq!(rig.detections(), 0); + assert!(!rig.controller.streaming_detected()); +} From c935170edcb3eb6e37fa94287451f24ca90594d2 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:49:29 +0200 Subject: [PATCH 025/106] feat(app): port app actions, dialogs and keyboard to accshift-app --- .../src/actions/avatar_fallback.rs | 121 +++ .../accshift-app/src/actions/card_actions.rs | 249 +++++ crates/accshift-app/src/actions/dialogs.rs | 895 ++++++++++++++++++ crates/accshift-app/src/actions/extension.rs | 193 ++++ crates/accshift-app/src/actions/keyboard.rs | 699 ++++++++++++++ crates/accshift-app/src/actions/mod.rs | 177 ++++ crates/accshift-app/src/actions/navigation.rs | 441 +++++++++ crates/accshift-app/src/actions/onboarding.rs | 152 +++ crates/accshift-app/src/actions/palette.rs | 585 ++++++++++++ crates/accshift-app/src/actions/personas.rs | 369 ++++++++ crates/accshift-app/src/actions/shell.rs | 355 +++++++ crates/accshift-app/src/actions/toasts.rs | 257 +++++ crates/accshift-app/src/lib.rs | 3 + .../accshift-app/tests/app_actions_dialogs.rs | 631 ++++++++++++ .../tests/app_actions_keyboard.rs | 875 +++++++++++++++++ .../tests/app_actions_personas.rs | 396 ++++++++ .../tests/app_actions_toasts_palette.rs | 310 ++++++ .../tests/app_actions_workspace.rs | 860 +++++++++++++++++ 18 files changed, 7568 insertions(+) create mode 100644 crates/accshift-app/src/actions/avatar_fallback.rs create mode 100644 crates/accshift-app/src/actions/card_actions.rs create mode 100644 crates/accshift-app/src/actions/dialogs.rs create mode 100644 crates/accshift-app/src/actions/extension.rs create mode 100644 crates/accshift-app/src/actions/keyboard.rs create mode 100644 crates/accshift-app/src/actions/mod.rs create mode 100644 crates/accshift-app/src/actions/navigation.rs create mode 100644 crates/accshift-app/src/actions/onboarding.rs create mode 100644 crates/accshift-app/src/actions/palette.rs create mode 100644 crates/accshift-app/src/actions/personas.rs create mode 100644 crates/accshift-app/src/actions/shell.rs create mode 100644 crates/accshift-app/src/actions/toasts.rs create mode 100644 crates/accshift-app/tests/app_actions_dialogs.rs create mode 100644 crates/accshift-app/tests/app_actions_keyboard.rs create mode 100644 crates/accshift-app/tests/app_actions_personas.rs create mode 100644 crates/accshift-app/tests/app_actions_toasts_palette.rs create mode 100644 crates/accshift-app/tests/app_actions_workspace.rs diff --git a/crates/accshift-app/src/actions/avatar_fallback.rs b/crates/accshift-app/src/actions/avatar_fallback.rs new file mode 100644 index 0000000..b668eda --- /dev/null +++ b/crates/accshift-app/src/actions/avatar_fallback.rs @@ -0,0 +1,121 @@ +//! The fallback avatar of a card without a picture: initials over a gradient +//! derived from the account. Ported from `src/lib/shared/avatarFallback.ts`. + +use crate::js; +use std::collections::HashMap; + +/// `bitCycleHash`: every bit of every UTF-16 unit, low bit first. +fn bit_cycle_hash(text: &str, mult: u64, modulo: u64, seed: u64) -> u64 { + let mut acc = seed % modulo; + for unit in text.encode_utf16() { + for b in 0..16u64 { + let bit = (u64::from(unit) >> b) & 1; + acc = (acc * mult + bit + b) % modulo; + } + } + acc +} + +/// `normalizeSeed` without the NFKC step: trimmed, lowercase, whitespace +/// runs folded to one space. +fn normalize_seed(seed: &str) -> String { + js::trim(seed) + .to_lowercase() + .split_whitespace() + .collect::>() + .join(" ") +} + +/// `getAvatarInitials`: the first letter of the first two words, uppercase; +/// `?` for a blank name. +pub fn avatar_initials(name: &str) -> String { + let mut parts = name.split_whitespace(); + let Some(first) = parts.next() else { + return "?".to_string(); + }; + let mut initials: String = first.chars().take(1).collect(); + if let Some(second) = parts.next() { + initials.extend(second.chars().take(1)); + } + initials.to_uppercase() +} + +/// `getAvatarSeed`: the shown name (display name, else username, else +/// `unknown`), the id and the name reversed. +pub fn avatar_seed(display_name: &str, username: &str, account_id: &str) -> String { + let primary = if !display_name.is_empty() { + display_name + } else { + username + }; + let primary = js::trim(primary); + let stable = if primary.is_empty() { + "unknown" + } else { + primary + }; + let reversed: String = stable.chars().rev().collect(); + format!("{stable}::{account_id}::{reversed}") +} + +/// The two hues of the gradient, in degrees. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct AvatarGradient { + /// `hsl(hue 72% 43%)` under the gradient, `hsl(hue 80% 56%)` at its start. + pub hue: u16, + /// `hsl(hue_fade 66% 38%)` at its end. + pub hue_fade: u16, +} + +impl AvatarGradient { + /// `getAvatarGradientStyle`'s CSS, for parity checks. + pub fn css(&self) -> String { + let AvatarGradient { hue, hue_fade } = *self; + format!( + "background-color:hsl({hue} 72% 43%);background-image:linear-gradient(145deg,hsl({hue} 80% 56%),hsl({hue_fade} 66% 38%))" + ) + } +} + +/// The hues of `getAvatarGradientStyle(seed)`. +pub fn avatar_gradient(seed: &str) -> AvatarGradient { + let normalized = normalize_seed(if seed.is_empty() { "?" } else { seed }); + let base = bit_cycle_hash(&normalized, 33, 997, 17); + let fade = bit_cycle_hash(&normalized, 29, 991, 53); + let hue = (base * 47) % 360; + let hue_fade = (hue + ((fade * 61) % 181) + 37) % 360; + AvatarGradient { + hue: hue as u16, + hue_fade: hue_fade as u16, + } +} + +/// `gradientCache`: one hash per distinct seed, cleared past 1000 seeds. +#[derive(Debug, Clone, Default)] +pub struct GradientCache { + entries: HashMap, +} + +impl GradientCache { + pub const MAX: usize = 1000; + + pub fn get(&mut self, seed: &str) -> AvatarGradient { + if let Some(gradient) = self.entries.get(seed) { + return *gradient; + } + let gradient = avatar_gradient(seed); + if self.entries.len() >= Self::MAX { + self.entries.clear(); + } + self.entries.insert(seed.to_string(), gradient); + gradient + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} diff --git a/crates/accshift-app/src/actions/card_actions.rs b/crates/accshift-app/src/actions/card_actions.rs new file mode 100644 index 0000000..bc4b1ce --- /dev/null +++ b/crates/accshift-app/src/actions/card_actions.rs @@ -0,0 +1,249 @@ +//! What a click, a right click or a key does to an account or folder card, +//! and the bulk edit bar actions on the selected cards. Ported from +//! `src/lib/app/useCardActions.ts`. + +use super::dialogs::AppDialogs; +use super::AppEffect; +use crate::accounts::{Toast, ToastKind}; +use crate::folders::{ItemKind, ItemRef}; +use crate::platforms::{PlatformAccount, PlatformAdapter}; +use crate::stores::ClientStores; +use crate::text::Text; + +/// What the card handlers read. +pub struct CardContext<'a> { + /// `getActiveTab()`. + pub active_tab: &'a str, + /// `getIsSearching()`. + pub is_searching: bool, + /// `getAdapter()`. + pub adapter: Option<&'a PlatformAdapter>, + /// `bulkEdit.bulkEditMode`. + pub bulk_edit_mode: bool, + /// `addFlow.isPendingSetupAccount(id)`: the account a running add flow + /// is still setting up. + pub pending_setup_account_id: Option<&'a str>, + /// `display.renderedAccountMap`. + pub rendered_accounts: &'a [PlatformAccount], +} + +impl CardContext<'_> { + fn is_pending_setup(&self, account_id: &str) -> bool { + self.pending_setup_account_id == Some(account_id) + } + + fn rendered(&self, account_id: &str) -> Option<&PlatformAccount> { + self.rendered_accounts + .iter() + .find(|account| account.id == account_id) + } + + fn cancel_conflicting(&self, account_id: Option<&str>) -> AppEffect { + AppEffect::AddFlowCancelIfConflicting { + platform_id: self.active_tab.to_string(), + account_id: account_id.map(str::to_string), + } + } +} + +/// The focused card for the keyboard handlers: `cardFocus.focusedItem` and +/// the centre of its element (`findElement(item).getBoundingClientRect()`), +/// `None` when the card is not on screen. +#[derive(Debug, Clone, Copy)] +pub struct FocusedCard<'a> { + pub item: &'a ItemRef, + pub center: Option<(f32, f32)>, +} + +/// `handleBackgroundContextMenu`. +pub fn background_context_menu( + dialogs: &mut AppDialogs, + ctx: &CardContext<'_>, + x: f32, + y: f32, +) -> Vec { + let effects = vec![ctx.cancel_conflicting(None)]; + dialogs.open_background_context_menu(x, y); + effects +} + +/// `handleWorkspaceMouseDown`: in selection mode a press starts a paint +/// selection, otherwise the drag manager (never while searching). +pub fn workspace_mouse_down(ctx: &CardContext<'_>) -> Vec { + if ctx.bulk_edit_mode { + return vec![AppEffect::BulkEditPaintStart]; + } + if !ctx.is_searching { + return vec![AppEffect::DragGridMouseDown]; + } + Vec::new() +} + +/// `handleWorkspaceAccountActivate`. +pub fn account_activate(ctx: &CardContext<'_>, account: &PlatformAccount) -> Vec { + if ctx.bulk_edit_mode { + return Vec::new(); + } + vec![ctx.cancel_conflicting(Some(&account.id))] +} + +/// `handleWorkspaceAccountSwitch`. +pub fn account_switch(ctx: &CardContext<'_>, account: &PlatformAccount) -> Vec { + if ctx.bulk_edit_mode { + return vec![AppEffect::BulkEditToggleAccount(account.id.clone())]; + } + if ctx.is_pending_setup(&account.id) { + return Vec::new(); + } + vec![ + ctx.cancel_conflicting(Some(&account.id)), + AppEffect::SwitchAccount(account.clone()), + ] +} + +/// `handleWorkspaceAccountContextMenu`. +pub fn account_context_menu( + dialogs: &mut AppDialogs, + ctx: &CardContext<'_>, + account: &PlatformAccount, + x: f32, + y: f32, +) -> Vec { + if ctx.bulk_edit_mode { + return vec![AppEffect::BulkEditToggleAccount(account.id.clone())]; + } + if ctx.is_pending_setup(&account.id) { + return Vec::new(); + } + let effects = vec![ctx.cancel_conflicting(Some(&account.id))]; + dialogs.open_account_context_menu(x, y, account.clone()); + effects +} + +/// `handleWorkspaceFolderContextMenu`. +pub fn folder_context_menu( + dialogs: &mut AppDialogs, + ctx: &CardContext<'_>, + stores: &ClientStores, + folder_id: &str, + x: f32, + y: f32, +) -> Vec { + let effects = vec![ctx.cancel_conflicting(None)]; + if let Some(folder) = stores.folders().folder(folder_id) { + dialogs.open_folder_context_menu(x, y, folder.clone()); + } + effects +} + +/// `activateFocusedCard`: open a folder, or activate then switch an +/// account. `None` when nothing is focused or the card is gone (the key +/// then passes). +pub fn activate_focused_card( + ctx: &CardContext<'_>, + focused: Option<&ItemRef>, +) -> Option> { + let item = focused?; + if item.kind == ItemKind::Folder { + return Some(vec![AppEffect::NavigateToFolder(Some(item.id.clone()))]); + } + let account = ctx.rendered(&item.id)?; + let mut effects = account_activate(ctx, account); + effects.extend(account_switch(ctx, account)); + Some(effects) +} + +/// `openFocusedCardContextMenu`: the menu opens at the card's centre. +pub fn open_focused_card_context_menu( + dialogs: &mut AppDialogs, + ctx: &CardContext<'_>, + stores: &ClientStores, + focused: Option>, +) -> Option> { + let focused = focused?; + let (x, y) = focused.center?; + let item = focused.item; + if item.kind == ItemKind::Folder { + stores.folders().folder(&item.id)?; + return Some(folder_context_menu(dialogs, ctx, stores, &item.id, x, y)); + } + let account = ctx.rendered(&item.id)?.clone(); + Some(account_context_menu(dialogs, ctx, &account, x, y)) +} + +/// `renameFocusedCard`: true when a rename dialog opened. +pub fn rename_focused_card( + dialogs: &mut AppDialogs, + ctx: &CardContext<'_>, + stores: &ClientStores, + focused: Option<&ItemRef>, +) -> bool { + let Some(item) = focused else { + return false; + }; + if item.kind == ItemKind::Folder { + let Some(folder) = stores.folders().folder(&item.id) else { + return false; + }; + dialogs.open_rename_folder_dialog(folder); + return true; + } + let Some(account) = ctx.rendered(&item.id) else { + return false; + }; + let Some(adapter) = ctx + .adapter + .filter(|adapter| adapter.supports_account_labels()) + else { + return false; + }; + dialogs.open_rename_account_dialog(Some(adapter), account); + true +} + +/// `copyBulkEditUrls`: nothing for an empty list, otherwise the clipboard +/// write, one URL per line. +pub fn copy_bulk_edit_urls(urls: &[String]) -> Vec { + if urls.is_empty() { + return Vec::new(); + } + vec![AppEffect::CopyBulkUrls(urls.to_vec())] +} + +/// The toast after [`AppEffect::CopyBulkUrls`]: `bulkEdit.urlsCopied` with the +/// count, `toast.copyFailed` when the write failed. +pub fn bulk_urls_copied_toast(count: usize, write_ok: bool) -> Toast { + if write_ok { + Toast::new( + Text::with("bulkEdit.urlsCopied", [("count", count as f64)]), + ToastKind::Success, + ) + } else { + Toast::new(Text::key("toast.copyFailed"), ToastKind::Error) + } +} + +/// `applyBulkEditCardColor`: writes every selected id in the store (an empty +/// colour clears), then the version bump and the toast. +pub fn apply_bulk_edit_card_color( + stores: &mut ClientStores, + selected_ids: &[String], + color: &str, +) -> Vec { + if selected_ids.is_empty() { + return Vec::new(); + } + stores.set_account_card_colors(selected_ids, color); + let key = if color.is_empty() { + "bulkEdit.colorCleared" + } else { + "bulkEdit.colorApplied" + }; + vec![ + AppEffect::CardColorsChanged, + AppEffect::Toast(Toast::new( + Text::with(key, [("count", selected_ids.len() as f64)]), + ToastKind::Success, + )), + ] +} diff --git a/crates/accshift-app/src/actions/dialogs.rs b/crates/accshift-app/src/actions/dialogs.rs new file mode 100644 index 0000000..b60c8c8 --- /dev/null +++ b/crates/accshift-app/src/actions/dialogs.rs @@ -0,0 +1,895 @@ +//! Dialogs and the context menu, ported from `src/lib/app/useAppDialogs.svelte.ts`, +//! with the submit rule of `src/lib/shared/components/InputDialog.svelte`. +//! +//! TS dialogs carry an `onConfirm` closure. Here an input dialog carries an +//! [`InputPurpose`] and a confirm dialog a [`ConfirmPurpose`]: data saying +//! what confirming does. [`AppDialogs::submit_input`] and +//! [`AppDialogs::confirm_current`] apply it: the stores the dialog owns change +//! in place, the rest comes back as [`AppEffect`]s. +//! +//! `requestConfirm` returned a promise; here it returns a [`ConfirmTicket`] +//! and every way out of the dialog emits [`AppEffect::ConfirmSettled`] for it. + +use super::{load_options, AppEffect}; +use crate::accounts::{Toast, ToastKind}; +use crate::backend::Backend; +use crate::error::BackendResult; +use crate::folders::{FolderInfo, ItemRef}; +use crate::js; +use crate::menu::account::{ + build_account_context_menu, color_label_key, AccountFolderChoice, AppearanceInput, FolderInput, +}; +use crate::menu::{InputRequest, MenuCommand, MenuEntry, MenuSwatch, PlatformTask, SubmenuLoader}; +use crate::platforms::adapter::MenuContext; +use crate::platforms::{ + PlatformAccount, PlatformAdapter, PlatformBulkEditResult, PlatformRegistry, +}; +use crate::stores::{ClientStores, ACCOUNT_CARD_COLOR_PRESETS, MAX_NOTE_LENGTH}; +use crate::text::Text; +use std::sync::Arc; + +// ------------------------------------------------------------- menu + +/// What clicking an entry of the app's context menu does: the platform +/// menu's own [`MenuCommand`]s plus the folder and background entries +/// `useAppDialogs` builds itself. +#[derive(Debug, Clone, PartialEq)] +pub enum AppMenuCommand { + Platform(MenuCommand), + /// "Rename": the folder rename dialog. + RenameFolder(FolderInfo), + /// A folder colour swatch. + SetFolderColor { + folder_id: String, + color: String, + }, + /// "Delete folder": its confirm dialog. + DeleteFolder(FolderInfo), + /// Background "Refresh": `loadAccounts(false, true, false, true)`. + RefreshAccounts, + /// Background "New folder". + NewFolder, + /// An entry of a caller-built menu, by the id that caller gave it. + Custom(String), +} + +/// `ContextMenuSwatchAction` with an app command. +#[derive(Debug, Clone, PartialEq)] +pub struct AppMenuSwatch { + pub id: String, + pub label: Text, + pub color: String, + pub active: bool, + pub command: AppMenuCommand, +} + +/// `ContextMenuItem`: what the menu draws, in order. +#[derive(Debug, Clone, PartialEq)] +pub enum AppMenuEntry { + Separator, + Item { + id: String, + label: Text, + command: Option, + submenu: Option>, + loader: Option, + }, + Swatches { + id: String, + label: Text, + swatches: Vec, + }, +} + +impl AppMenuEntry { + /// A plain entry. + pub fn item(id: impl Into, label: Text, command: AppMenuCommand) -> Self { + AppMenuEntry::Item { + id: id.into(), + label, + command: Some(command), + submenu: None, + loader: None, + } + } + + pub fn id(&self) -> Option<&str> { + match self { + AppMenuEntry::Separator => None, + AppMenuEntry::Item { id, .. } | AppMenuEntry::Swatches { id, .. } => Some(id), + } + } + + pub fn label(&self) -> Option<&Text> { + match self { + AppMenuEntry::Separator => None, + AppMenuEntry::Item { label, .. } | AppMenuEntry::Swatches { label, .. } => Some(label), + } + } + + pub fn command(&self) -> Option<&AppMenuCommand> { + match self { + AppMenuEntry::Item { command, .. } => command.as_ref(), + _ => None, + } + } +} + +impl From for AppMenuSwatch { + fn from(swatch: MenuSwatch) -> Self { + Self { + id: swatch.id, + label: swatch.label, + color: swatch.color, + active: swatch.active, + command: AppMenuCommand::Platform(swatch.command), + } + } +} + +impl From for AppMenuEntry { + /// The platform menu (and a loaded submenu) as app entries. + fn from(entry: MenuEntry) -> Self { + match entry { + MenuEntry::Separator => AppMenuEntry::Separator, + MenuEntry::Item { + id, + label, + command, + submenu, + loader, + } => AppMenuEntry::Item { + id, + label, + command: command.map(AppMenuCommand::Platform), + submenu: submenu.map(|entries| entries.into_iter().map(Into::into).collect()), + loader, + }, + MenuEntry::Swatches { + id, + label, + swatches, + } => AppMenuEntry::Swatches { + id, + label, + swatches: swatches.into_iter().map(Into::into).collect(), + }, + } + } +} + +/// What the menu was opened on. +#[derive(Debug, Clone, PartialEq)] +pub enum ContextMenuTarget { + Account(PlatformAccount), + Folder(FolderInfo), + Background, + /// Pre-built entries from outside the account and folder world. + Custom(Vec), +} + +/// `ContextMenuState`. +#[derive(Debug, Clone, PartialEq)] +pub struct ContextMenuState { + pub x: f32, + pub y: f32, + pub target: ContextMenuTarget, +} + +/// What building the menu reads. +#[derive(Clone, Copy)] +pub struct MenuInput<'a> { + pub stores: &'a ClientStores, + /// `shell.adapter`: the active tab's adapter. + pub adapter: Option<&'a PlatformAdapter>, + pub active_tab: &'a str, + pub active_tab_usable: bool, + pub current_account_id: Option<&'a str>, +} + +/// `folderPathLabel`: "Parent / Child", the id when the folder is unknown. +pub fn folder_path_label(stores: &ClientStores, folder_id: &str) -> String { + let path = stores.folders().folder_path(Some(folder_id)); + if path.is_empty() { + return folder_id.to_string(); + } + path.iter() + .map(|folder| folder.name.as_str()) + .collect::>() + .join(" / ") +} + +fn color_label(preset_id: &str) -> Text { + Text::key(color_label_key(preset_id).unwrap_or(preset_id)) +} + +/// The folder card menu. +fn folder_menu(folder: &FolderInfo, stores: &ClientStores) -> Vec { + let current_color = stores.folder_card_color(&folder.id); + let swatches = ACCOUNT_CARD_COLOR_PRESETS + .iter() + .map(|preset| AppMenuSwatch { + id: preset.id.to_string(), + label: color_label(preset.id), + color: preset.color.to_string(), + active: current_color == preset.color, + command: AppMenuCommand::SetFolderColor { + folder_id: folder.id.clone(), + color: preset.color.to_string(), + }, + }) + .collect(); + vec![ + AppMenuEntry::item( + "folder.rename", + Text::key("context.menu.rename"), + AppMenuCommand::RenameFolder(folder.clone()), + ), + AppMenuEntry::Swatches { + id: "folder.color".to_string(), + label: Text::key("context.menu.folderColor"), + swatches, + }, + AppMenuEntry::item( + "folder.delete", + Text::key("context.menu.deleteFolder"), + AppMenuCommand::DeleteFolder(folder.clone()), + ), + ] +} + +/// The account card menu: the platform menu with the folder world wired in. +fn account_menu( + account: &PlatformAccount, + adapter: &PlatformAdapter, + input: &MenuInput<'_>, +) -> Vec { + let stores = input.stores; + let folders = FolderInput { + folders: stores + .folders() + .list_folders(input.active_tab) + .into_iter() + .map(|folder| AccountFolderChoice { + id: folder.id.clone(), + label: folder_path_label(stores, &folder.id), + }) + .collect(), + current_folder_id: stores + .folders() + .find_item_folder_id(&ItemRef::account(account.id.clone()), input.active_tab) + .map(str::to_string), + }; + let appearance = AppearanceInput { + current_color: stores.account_card_color(&account.id).to_string(), + existing_note: stores.account_card_note(&account.id).to_string(), + }; + let ctx = MenuContext { + current_account_id: input.current_account_id, + stores, + input_dialog: true, + }; + build_account_context_menu(account, adapter, &ctx, &appearance, Some(&folders)) + .into_iter() + .map(Into::into) + .collect() +} + +// ----------------------------------------------------------- dialogs + +/// What confirming an input dialog does. +#[derive(Debug, Clone, PartialEq)] +pub enum InputPurpose { + /// `openNewFolderDialog`: a folder in the current folder of the active + /// tab, both read when confirming. + NewFolder, + /// `openRenameFolderDialog`. + RenameFolder { folder_id: String }, + /// The note editor of the appearance submenu: set, or clear when blank. + CardNote { account_id: String }, + /// A platform menu's rename (`openInputDialog` from the adapter). + PlatformInput(InputRequest), + /// `openRenameAccountDialog` (F2). + RenameAccount { + platform_id: String, + account_id: String, + }, + /// `promptRenameNewAccount`: a blank answer keeps the detected name. + RenameNewAccount { + platform_id: String, + account_id: String, + }, +} + +/// `InputDialogConfig` plus the draft `InputDialog.svelte` edits. +#[derive(Debug, Clone, PartialEq)] +pub struct InputDialogState { + pub title: Text, + pub placeholder: Text, + pub initial_value: String, + pub allow_empty: bool, + /// `maxlength`, in UTF-16 units as the HTML input counts. + pub maxlength: Option, + pub purpose: InputPurpose, + value: String, +} + +impl InputDialogState { + fn new( + title: Text, + placeholder: Text, + initial_value: String, + allow_empty: bool, + maxlength: Option, + purpose: InputPurpose, + ) -> Self { + Self { + title, + placeholder, + value: initial_value.clone(), + initial_value, + allow_empty, + maxlength, + purpose, + } + } + + /// The draft. + pub fn value(&self) -> &str { + &self.value + } + + /// Typing: the input refuses what goes past `maxlength`. + pub fn set_value(&mut self, value: impl Into) { + let value = value.into(); + self.value = match self.maxlength { + Some(max) if js::utf16_len(&value) > max => js::utf16_prefix(&value, max), + _ => value, + }; + } + + /// The OK button: enabled with `allowEmpty` or a non-blank draft. + pub fn can_submit(&self) -> bool { + self.allow_empty || !js::trim(&self.value).is_empty() + } + + /// The `n/max` counter, shown in the last 20 characters. + pub fn char_count(&self) -> Option<(usize, usize)> { + let max = self.maxlength.filter(|max| *max > 0)?; + let len = js::utf16_len(&self.value); + (len + 20 >= max).then_some((len, max)) + } + + /// `submit`: the value handed to `onConfirm` (trimmed unless + /// `allowEmpty`), `None` when it cannot submit. + pub fn submitted_value(&self) -> Option { + if !self.can_submit() { + return None; + } + Some(if self.allow_empty { + self.value.clone() + } else { + js::trim(&self.value).to_string() + }) + } +} + +/// A `requestConfirm` in flight. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ConfirmTicket(pub u64); + +/// What confirming a confirm dialog does. +#[derive(Debug, Clone, PartialEq)] +pub enum ConfirmPurpose { + /// A platform menu's confirm: run its task. + PlatformTask(PlatformTask), + /// The folder's "Delete folder". + DeleteFolder { folder_id: String }, + /// `requestConfirm`: nothing to run, the ticket's owner acts. + Request(ConfirmTicket), +} + +/// `PlatformContextMenuConfirmConfig`. +#[derive(Debug, Clone, PartialEq)] +pub struct ConfirmDialogState { + pub title: Text, + pub message: Text, + pub confirm_label: Option, + pub confirm_color: String, + pub purpose: ConfirmPurpose, +} + +impl ConfirmDialogState { + /// `confirmDialogConfirmLabel`. + pub fn confirm_label(&self) -> Text { + self.confirm_label + .clone() + .unwrap_or_else(|| Text::key("common.confirm")) + } +} + +/// What a `requestConfirm` asks. +#[derive(Debug, Clone, PartialEq)] +pub struct ConfirmConfig { + pub title: Text, + pub message: Text, + pub confirm_label: Option, + pub confirm_color: Option, +} + +/// What the dialogs write and read when they act. +pub struct DialogContext<'a> { + pub stores: &'a mut ClientStores, + pub active_tab: &'a str, + pub current_folder_id: Option<&'a str>, +} + +/// `createAppDialogsController`. +#[derive(Debug, Clone, Default)] +pub struct AppDialogs { + context_menu: Option, + input_dialog: Option, + confirm_dialog: Option, + pending_confirm: Option, + next_ticket: u64, +} + +impl AppDialogs { + pub fn new() -> Self { + Self::default() + } + + pub fn context_menu(&self) -> Option<&ContextMenuState> { + self.context_menu.as_ref() + } + + pub fn input_dialog(&self) -> Option<&InputDialogState> { + self.input_dialog.as_ref() + } + + /// The draft the input dialog edits. + pub fn input_dialog_mut(&mut self) -> Option<&mut InputDialogState> { + self.input_dialog.as_mut() + } + + pub fn confirm_dialog(&self) -> Option<&ConfirmDialogState> { + self.confirm_dialog.as_ref() + } + + /// An input or confirm dialog is up (the `dialog` key scope). + pub fn has_dialog(&self) -> bool { + self.input_dialog.is_some() || self.confirm_dialog.is_some() + } + + /// The ticket a confirm dialog answers, if one is pending. + pub fn pending_confirm(&self) -> Option { + self.pending_confirm + } + + // ------------------------------------------------- context menu + + /// `contextMenuItems`. + pub fn context_menu_items(&self, input: &MenuInput<'_>) -> Vec { + let Some(menu) = &self.context_menu else { + return Vec::new(); + }; + match &menu.target { + ContextMenuTarget::Custom(items) => items.clone(), + ContextMenuTarget::Account(account) => match input.adapter { + Some(adapter) => account_menu(account, adapter, input), + None => Vec::new(), + }, + ContextMenuTarget::Folder(folder) => folder_menu(folder, input.stores), + ContextMenuTarget::Background => { + let mut items = Vec::new(); + if input.active_tab_usable && input.adapter.is_some() { + items.push(AppMenuEntry::item( + "background.refresh", + Text::key("context.menu.refresh"), + AppMenuCommand::RefreshAccounts, + )); + } + items.push(AppMenuEntry::item( + "background.newFolder", + Text::key("context.menu.newFolder"), + AppMenuCommand::NewFolder, + )); + items + } + } + } + + pub fn open_background_context_menu(&mut self, x: f32, y: f32) { + self.open_menu(x, y, ContextMenuTarget::Background); + } + + pub fn open_account_context_menu(&mut self, x: f32, y: f32, account: PlatformAccount) { + self.open_menu(x, y, ContextMenuTarget::Account(account)); + } + + pub fn open_folder_context_menu(&mut self, x: f32, y: f32, folder: FolderInfo) { + self.open_menu(x, y, ContextMenuTarget::Folder(folder)); + } + + /// `openCustomContextMenu`: pre-built entries (the persona cards). + pub fn open_custom_context_menu(&mut self, x: f32, y: f32, items: Vec) { + self.open_menu(x, y, ContextMenuTarget::Custom(items)); + } + + fn open_menu(&mut self, x: f32, y: f32, target: ContextMenuTarget) { + self.context_menu = Some(ContextMenuState { x, y, target }); + } + + pub fn close_context_menu(&mut self) { + self.context_menu = None; + } + + /// A click on an entry or swatch: the menu closes and the command runs. + pub fn activate( + &mut self, + command: &AppMenuCommand, + ctx: &mut DialogContext<'_>, + ) -> Vec { + self.close_context_menu(); + let mut effects = Vec::new(); + match command { + AppMenuCommand::Platform(command) => self.platform_command(command, ctx, &mut effects), + AppMenuCommand::RenameFolder(folder) => self.open_rename_folder_dialog(folder), + AppMenuCommand::SetFolderColor { folder_id, color } => { + ctx.stores.set_folder_card_color(folder_id, color); + effects.push(AppEffect::CardColorsChanged); + } + AppMenuCommand::DeleteFolder(folder) => self.open_confirm( + ConfirmDialogState { + title: Text::with("dialog.deleteFolderTitle", [("name", folder.name.as_str())]), + message: Text::key("dialog.deleteFolderMessage"), + confirm_label: Some(Text::key("context.menu.deleteFolder")), + confirm_color: String::new(), + purpose: ConfirmPurpose::DeleteFolder { + folder_id: folder.id.clone(), + }, + }, + &mut effects, + ), + AppMenuCommand::RefreshAccounts => effects.push(AppEffect::LoadAccounts(load_options( + false, true, false, true, + ))), + AppMenuCommand::NewFolder => self.open_new_folder_dialog(), + AppMenuCommand::Custom(id) => effects.push(AppEffect::CustomMenu(id.clone())), + } + effects + } + + fn platform_command( + &mut self, + command: &MenuCommand, + ctx: &mut DialogContext<'_>, + effects: &mut Vec, + ) { + match command { + MenuCommand::Close => {} + MenuCommand::Copy { value, label } => effects.push(AppEffect::CopyToClipboard { + text: value.clone(), + label: label.clone(), + }), + MenuCommand::Confirm(request) => self.open_confirm( + ConfirmDialogState { + title: request.title.clone(), + message: request.message.clone(), + confirm_label: request.confirm_label.clone(), + confirm_color: String::new(), + purpose: ConfirmPurpose::PlatformTask(request.on_confirm.clone()), + }, + effects, + ), + MenuCommand::Input(request) => { + self.input_dialog = Some(InputDialogState::new( + request.title.clone(), + request.placeholder.clone(), + request.initial_value.clone(), + request.allow_empty, + None, + InputPurpose::PlatformInput((**request).clone()), + )); + } + MenuCommand::MoveToFolder { + account_id, + folder_id, + } => { + let item = ItemRef::account(account_id.clone()); + let from = ctx + .stores + .folders() + .find_item_folder_id(&item, ctx.active_tab) + .map(str::to_string); + ctx.stores.move_item( + &item, + from.as_deref(), + folder_id.as_deref(), + ctx.active_tab, + None, + ); + effects.push(AppEffect::RefreshCurrentItems); + } + MenuCommand::EditNote { + account_id, + initial_note, + } => { + self.input_dialog = Some(InputDialogState::new( + Text::key("dialog.cardNoteTitle"), + Text::key("dialog.cardNotePlaceholder"), + initial_note.clone(), + true, + Some(MAX_NOTE_LENGTH), + InputPurpose::CardNote { + account_id: account_id.clone(), + }, + )); + } + MenuCommand::SetCardColor { account_id, color } => { + ctx.stores.set_account_card_color(account_id, color); + effects.push(AppEffect::CardColorsChanged); + } + MenuCommand::Task(task) => effects.push(AppEffect::RunPlatformTask(task.clone())), + } + } + + // ------------------------------------------------- input dialog + + /// `openNewFolderDialog`. + pub fn open_new_folder_dialog(&mut self) { + self.input_dialog = Some(InputDialogState::new( + Text::key("dialog.newFolderTitle"), + Text::key("dialog.folderNamePlaceholder"), + String::new(), + false, + None, + InputPurpose::NewFolder, + )); + } + + /// `openRenameFolderDialog`. + pub fn open_rename_folder_dialog(&mut self, folder: &FolderInfo) { + self.input_dialog = Some(InputDialogState::new( + Text::key("dialog.renameFolderTitle"), + Text::key("dialog.folderNamePlaceholder"), + folder.name.clone(), + false, + None, + InputPurpose::RenameFolder { + folder_id: folder.id.clone(), + }, + )); + } + + /// `openRenameAccountDialog`: nothing when the active adapter cannot + /// label accounts. + pub fn open_rename_account_dialog( + &mut self, + adapter: Option<&PlatformAdapter>, + account: &PlatformAccount, + ) { + let Some(adapter) = adapter.filter(|adapter| adapter.supports_account_labels()) else { + return; + }; + self.input_dialog = Some(InputDialogState::new( + Text::key("platform.renameTitle"), + Text::key("platform.renamePlaceholder"), + account.display_name.clone(), + true, + None, + InputPurpose::RenameAccount { + platform_id: adapter.id().to_string(), + account_id: account.id.clone(), + }, + )); + } + + /// `promptRenameNewAccount`: prefilled with the identity the platform + /// detected, when the platform can label accounts. + pub fn prompt_rename_new_account( + &mut self, + registry: &PlatformRegistry, + platform_id: &str, + account_id: &str, + display_name: Option<&str>, + ) { + let supported = registry + .adapter(platform_id) + .is_some_and(|adapter| adapter.supports_account_labels()); + if !supported { + return; + } + self.input_dialog = Some(InputDialogState::new( + Text::key("platform.renameNewAccount"), + Text::key("platform.renamePlaceholder"), + display_name.map(js::trim).unwrap_or_default().to_string(), + true, + None, + InputPurpose::RenameNewAccount { + platform_id: platform_id.to_string(), + account_id: account_id.to_string(), + }, + )); + } + + /// `closeInputDialog` (Cancel, Escape). + pub fn close_input_dialog(&mut self) { + self.input_dialog = None; + } + + /// The OK button or Enter: closes the dialog and applies its purpose with + /// the submitted value. Nothing while it cannot submit. + pub fn submit_input(&mut self, ctx: &mut DialogContext<'_>) -> Vec { + let Some(value) = self + .input_dialog + .as_ref() + .and_then(InputDialogState::submitted_value) + else { + return Vec::new(); + }; + let Some(dialog) = self.input_dialog.take() else { + return Vec::new(); + }; + let mut effects = Vec::new(); + match dialog.purpose { + InputPurpose::NewFolder => { + ctx.stores + .create_folder(&value, ctx.current_folder_id, ctx.active_tab); + effects.push(AppEffect::RefreshCurrentItems); + } + InputPurpose::RenameFolder { folder_id } => { + ctx.stores.rename_folder(&folder_id, &value); + effects.push(AppEffect::RefreshCurrentItems); + } + InputPurpose::CardNote { account_id } => { + if js::trim(&value).is_empty() { + ctx.stores.clear_account_card_note(&account_id); + } else { + ctx.stores.set_account_card_note(&account_id, &value); + } + effects.push(AppEffect::CardNotesChanged); + } + InputPurpose::PlatformInput(request) => { + effects.push(AppEffect::RunPlatformTask(request.confirm(&value))); + } + InputPurpose::RenameAccount { + platform_id, + account_id, + } => effects.push(AppEffect::RenameAccount { + platform_id, + account_id, + label: value, + }), + InputPurpose::RenameNewAccount { + platform_id, + account_id, + } => { + if !js::trim(&value).is_empty() { + effects.push(AppEffect::RenameAccount { + platform_id, + account_id, + label: value, + }); + } + } + } + effects + } + + // ----------------------------------------------- confirm dialog + + fn settle_pending(&mut self, allowed: bool, effects: &mut Vec) { + if let Some(ticket) = self.pending_confirm.take() { + effects.push(AppEffect::ConfirmSettled { ticket, allowed }); + } + } + + /// `openConfirmDialog`: a dialog replacing a pending request settles it + /// as refused first. + fn open_confirm(&mut self, dialog: ConfirmDialogState, effects: &mut Vec) { + self.settle_pending(false, effects); + self.confirm_dialog = Some(dialog); + } + + /// `requestConfirm`: opens the confirm dialog; its answer comes back as + /// [`AppEffect::ConfirmSettled`] for the returned ticket. A request open + /// before is settled as refused into `effects`. + pub fn request_confirm( + &mut self, + config: ConfirmConfig, + effects: &mut Vec, + ) -> ConfirmTicket { + self.next_ticket += 1; + let ticket = ConfirmTicket(self.next_ticket); + self.open_confirm( + ConfirmDialogState { + title: config.title, + message: config.message, + confirm_label: config.confirm_label, + confirm_color: config.confirm_color.unwrap_or_default(), + purpose: ConfirmPurpose::Request(ticket), + }, + effects, + ); + self.pending_confirm = Some(ticket); + ticket + } + + /// `closeConfirmDialog` (Cancel, Escape): a pending request is refused. + pub fn close_confirm_dialog(&mut self) -> Vec { + self.confirm_dialog = None; + let mut effects = Vec::new(); + self.settle_pending(false, &mut effects); + effects + } + + /// `confirmCurrentDialog`: runs the purpose, then a pending request is + /// accepted. + pub fn confirm_current(&mut self, ctx: &mut DialogContext<'_>) -> Vec { + let mut effects = Vec::new(); + if let Some(dialog) = self.confirm_dialog.take() { + match dialog.purpose { + ConfirmPurpose::PlatformTask(task) => { + effects.push(AppEffect::RunPlatformTask(task)) + } + ConfirmPurpose::DeleteFolder { folder_id } => { + ctx.stores.delete_folder(&folder_id); + effects.push(AppEffect::RefreshCurrentItems); + } + ConfirmPurpose::Request(_) => {} + } + } + self.settle_pending(true, &mut effects); + effects + } +} + +/// `handleBulkEditResult`: all done, partial, or failed. +pub fn bulk_edit_result_toast(result: &PlatformBulkEditResult) -> Toast { + if result.failed.is_empty() && result.succeeded > 0 { + Toast::new( + Text::with( + "bulkEdit.toastSuccess", + [("count", result.succeeded as f64)], + ), + ToastKind::Success, + ) + } else if !result.failed.is_empty() && result.succeeded > 0 { + Toast::new( + Text::with( + "bulkEdit.toastPartial", + [ + ("succeeded", result.succeeded as f64), + ("failed", result.failed.len() as f64), + ], + ), + ToastKind::Default, + ) + } else { + Toast::new(Text::key("bulkEdit.toastFailed"), ToastKind::Error) + } +} + +/// The blocking half of [`AppEffect::RenameAccount`], for the job pool. +pub fn rename_account_blocking( + backend: &dyn Backend, + adapter: &Arc, + account_id: &str, + label: &str, +) -> BackendResult<()> { + adapter.set_account_label(backend, account_id, label) +} + +/// The UI half: reload silently, or `toast.renameAccountFailed`. +pub fn finish_rename_account(result: BackendResult<()>) -> Vec { + match result { + Ok(()) => vec![AppEffect::LoadAccounts(load_options( + true, false, false, false, + ))], + Err(error) => vec![AppEffect::Toast(Toast::new( + Text::with("toast.renameAccountFailed", [("error", error.message)]), + ToastKind::Error, + ))], + } +} diff --git a/crates/accshift-app/src/actions/extension.rs b/crates/accshift-app/src/actions/extension.rs new file mode 100644 index 0000000..2187234 --- /dev/null +++ b/crates/accshift-app/src/actions/extension.rs @@ -0,0 +1,193 @@ +//! The extension panel content of each visible account card, ported from +//! `src/lib/app/useExtensionContent.svelte.ts`. +//! +//! A card shows, in order: the add flow's setup content when the account is +//! being set up (it replaces the rest), else a warnings section, a note +//! section (unless notes show inline) and the platform's extra sections (the +//! CS2 bridge on Steam). One memo entry per account is rebuilt only when that +//! account's own inputs change, so the other cards keep the same `Arc`. + +use crate::i18n::Locale; +use crate::js; +use crate::platforms::card::{warning_chips_to_extension_chips, CardSection}; +use crate::platforms::warnings::{WarningChip, WarningPresentation}; +use crate::text::Text; +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; + +/// `CardExtensionContent`: the sections of one card, `None` when it has none. +pub type CardExtension = Option>>; + +/// What the memo reads on each frame. +pub struct ExtensionInputs<'a> { + /// `shell.locale`. + pub locale: Locale, + /// `loader.warningStates`. + pub warning_states: &'a BTreeMap, + /// `display.visibleRenderedAccountIds`. + pub visible_ids: &'a [String], + /// `addFlow.getSetupExtensionContent(id)`, its sections. + pub setup_content: &'a dyn Fn(&str) -> Option>, + /// `getAccountNote(id)`. + pub account_note: &'a dyn Fn(&str) -> String, + /// `cardNoteVersion`. + pub card_note_version: u64, + /// `settings.accountDisplay.showCardNotesInline`. + pub show_card_notes_inline: bool, + /// `getExtraSections(id)`: Steam's CS2 sections, nothing elsewhere. + pub extra_sections: &'a dyn Fn(&str) -> Vec, + /// `getCs2BridgeVersion()`. + pub extra_sections_version: u64, +} + +#[derive(Debug, Clone, PartialEq)] +struct SharedKey { + locale: Locale, + card_note_version: u64, + show_card_notes_inline: bool, + extra_sections_version: u64, +} + +/// `accountKey(id)`: the account's own inputs. +#[derive(Debug, Clone, PartialEq)] +struct AccountKey { + tooltip: Option, + chips: Vec, + note: String, + setup: Option>, +} + +#[derive(Debug, Clone)] +struct Entry { + key: AccountKey, + content: CardExtension, +} + +/// `createExtensionContentController`. +#[derive(Debug, Clone, Default)] +pub struct ExtensionContent { + shared_key: Option, + entries: HashMap, + cache: Arc>, +} + +/// `createWarningExtensionSection`: chips when the warning has any, else the +/// tooltip lines joined with a bullet. +pub fn warning_section( + warning: Option<&WarningPresentation>, + locale: Locale, +) -> Option { + let warning = warning?; + let chips = warning_chips_to_extension_chips(&warning.chips); + let tooltip = warning.tooltip.resolve(locale); + let lines: Vec = tooltip + .split('\n') + .map(|line| js::trim(line).to_string()) + .filter(|line| !line.is_empty()) + .collect(); + if lines.is_empty() && chips.is_empty() { + return None; + } + let has_chips = !chips.is_empty(); + Some(CardSection { + title: Some(Text::key("card.extensionWarnings")), + text: (!has_chips).then(|| Text::raw(lines.join(" \u{2022} "))), + lines: if has_chips { Vec::new() } else { lines }, + chips, + ..CardSection::default() + }) +} + +/// `createNoteExtensionSection`. +pub fn note_section(note: &str, show_inline: bool) -> Option { + if show_inline { + return None; + } + let note = js::trim(note); + if note.is_empty() { + return None; + } + Some(CardSection { + title: Some(Text::key("card.extensionNote")), + lines: vec![note.to_string()], + ..CardSection::default() + }) +} + +impl ExtensionContent { + pub fn new() -> Self { + Self::default() + } + + fn account_key(inputs: &ExtensionInputs<'_>, id: &str) -> AccountKey { + let warning = inputs.warning_states.get(id); + AccountKey { + tooltip: warning.map(|warning| warning.tooltip.clone()), + chips: warning + .map(|warning| warning.chips.clone()) + .unwrap_or_default(), + note: (inputs.account_note)(id), + setup: (inputs.setup_content)(id), + } + } + + fn build_content(inputs: &ExtensionInputs<'_>, id: &str, key: &AccountKey) -> CardExtension { + if let Some(setup) = &key.setup { + return Some(Arc::new(setup.clone())); + } + let mut sections = Vec::new(); + sections.extend(warning_section( + inputs.warning_states.get(id), + inputs.locale, + )); + sections.extend(note_section(&key.note, inputs.show_card_notes_inline)); + sections.extend((inputs.extra_sections)(id)); + (!sections.is_empty()).then(|| Arc::new(sections)) + } + + /// `accountExtensionContentById`: the same map `Arc` while nothing + /// changed, and the same entry `Arc` for every card whose inputs held. + pub fn content_by_id( + &mut self, + inputs: &ExtensionInputs<'_>, + ) -> Arc> { + let shared = SharedKey { + locale: inputs.locale, + card_note_version: inputs.card_note_version, + show_card_notes_inline: inputs.show_card_notes_inline, + extra_sections_version: inputs.extra_sections_version, + }; + let same_shared = self.shared_key.as_ref() == Some(&shared); + let previous = if same_shared { + std::mem::take(&mut self.entries) + } else { + HashMap::new() + }; + let mut changed = !same_shared || inputs.visible_ids.len() != self.cache.len(); + let mut next = HashMap::with_capacity(inputs.visible_ids.len()); + for id in inputs.visible_ids { + let key = Self::account_key(inputs, id); + if let Some(cached) = previous.get(id).filter(|cached| cached.key == key) { + if !self.cache.contains_key(id) { + changed = true; + } + next.insert(id.clone(), cached.clone()); + continue; + } + let content = Self::build_content(inputs, id, &key); + next.insert(id.clone(), Entry { key, content }); + changed = true; + } + self.shared_key = Some(shared); + self.entries = next; + if changed { + let map = self + .entries + .iter() + .map(|(id, entry)| (id.clone(), entry.content.clone())) + .collect(); + self.cache = Arc::new(map); + } + Arc::clone(&self.cache) + } +} diff --git a/crates/accshift-app/src/actions/keyboard.rs b/crates/accshift-app/src/actions/keyboard.rs new file mode 100644 index 0000000..5c8e69b --- /dev/null +++ b/crates/accshift-app/src/actions/keyboard.rs @@ -0,0 +1,699 @@ +//! The keyboard model, ported from `src/lib/shared/keyboard/{controller,types}.ts` +//! and `src/lib/app/keyboardBindings.ts`. +//! +//! The binding table is data: each entry names a [`KeyCommand`] instead of a +//! closure. [`KeyboardController::handle`] walks it in order for a key event +//! egui feeds in, with the current [`KeyScope`], and runs the first match +//! through a [`KeyHost`] (the app). A command answers [`KeyResult::Pass`] for +//! "not handled here" (the TS `PASS`): the next binding is tried and the event +//! stays untouched. The DOM listener stays out. + +use crate::folders::ItemKind; +use crate::folders::ItemRef; + +/// `KeyScope`: the layer that owns the keyboard. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum KeyScope { + Locked, + Onboarding, + Palette, + Dialog, + ContextMenu, + BulkEdit, + Settings, + Personas, + App, +} + +impl KeyScope { + pub const ALL: [KeyScope; 9] = [ + KeyScope::Locked, + KeyScope::Onboarding, + KeyScope::Palette, + KeyScope::Dialog, + KeyScope::ContextMenu, + KeyScope::BulkEdit, + KeyScope::Settings, + KeyScope::Personas, + KeyScope::App, + ]; + + pub fn as_str(self) -> &'static str { + match self { + KeyScope::Locked => "locked", + KeyScope::Onboarding => "onboarding", + KeyScope::Palette => "palette", + KeyScope::Dialog => "dialog", + KeyScope::ContextMenu => "context-menu", + KeyScope::BulkEdit => "bulk-edit", + KeyScope::Settings => "settings", + KeyScope::Personas => "personas", + KeyScope::App => "app", + } + } +} + +/// The layers `createKeyScopeResolver` checks, topmost first. `App` is what +/// is left when none holds. +pub const LAYER_ORDER: [KeyScope; 8] = [ + KeyScope::Locked, + KeyScope::Palette, + KeyScope::Onboarding, + KeyScope::Dialog, + KeyScope::ContextMenu, + KeyScope::BulkEdit, + KeyScope::Settings, + KeyScope::Personas, +]; + +/// `currentKeyScope`: the first layer of [`LAYER_ORDER`] that is up. `is_up` +/// is asked in that order and no further than the first hit. +pub fn current_key_scope(mut is_up: impl FnMut(KeyScope) -> bool) -> KeyScope { + LAYER_ORDER + .into_iter() + .find(|scope| is_up(*scope)) + .unwrap_or(KeyScope::App) +} + +/// `CARD_NAV_SCOPES`: where the grid takes arrows and Enter. +pub const CARD_NAV_SCOPES: [KeyScope; 2] = [KeyScope::App, KeyScope::BulkEdit]; + +/// `ParsedCombo`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedCombo { + pub mod_key: bool, + pub shift: bool, + pub alt: bool, + /// Lowercased `e.key`, `digitN` for physical digits, `plus` / `minus` + /// for the zoom keys, `space`. + pub key: String, +} + +/// `parseCombo`. +pub fn parse_combo(combo: &str) -> ParsedCombo { + let lower = combo.to_lowercase(); + let parts: Vec<&str> = lower.split('+').collect(); + let key = parts.last().copied().unwrap_or_default().to_string(); + ParsedCombo { + mod_key: parts.contains(&"mod"), + shift: parts.contains(&"shift"), + alt: parts.contains(&"alt"), + key, + } +} + +/// A key press, as the DOM `KeyboardEvent` fields the controller reads. +/// `key` and `code` use the DOM names (`"Escape"`, `"ArrowLeft"`, `"F2"`, +/// `" "`, `"n"` or `"N"`; `"Digit2"`, `"Equal"`, `"NumpadAdd"`, `"Minus"`). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct KeyEvent { + pub key: String, + pub code: String, + pub ctrl: bool, + pub meta: bool, + pub shift: bool, + pub alt: bool, + /// `isEditableTarget(e.target)`: focus is in a text field. + pub editable_target: bool, +} + +impl KeyEvent { + pub fn new(key: impl Into) -> Self { + Self { + key: key.into(), + ..Self::default() + } + } + + pub fn code(mut self, code: impl Into) -> Self { + self.code = code.into(); + self + } + + pub fn ctrl(mut self) -> Self { + self.ctrl = true; + self + } + + pub fn meta(mut self) -> Self { + self.meta = true; + self + } + + pub fn shift(mut self) -> Self { + self.shift = true; + self + } + + pub fn alt(mut self) -> Self { + self.alt = true; + self + } + + pub fn in_input(mut self) -> Self { + self.editable_target = true; + self + } +} + +/// `eventMatchesKey`. +fn event_matches_key(event: &KeyEvent, key: &str) -> bool { + if key.starts_with("digit") { + return event.code.to_lowercase() == key; + } + match key { + "plus" => event.code == "Equal" || event.code == "NumpadAdd", + "minus" => event.code == "Minus" || event.code == "NumpadSubtract", + "space" => event.key == " ", + _ => event.key.to_lowercase() == key, + } +} + +/// `comboMatches`: `mod` is Cmd on macOS and Ctrl elsewhere; the other one +/// pressed alone spoils a modifier-free combo. +pub fn combo_matches(event: &KeyEvent, combo: &ParsedCombo, is_mac: bool) -> bool { + let mod_pressed = if is_mac { event.meta } else { event.ctrl }; + let stray_mod = if is_mac { event.ctrl } else { event.meta }; + if combo.mod_key != mod_pressed { + return false; + } + if !combo.mod_key && stray_mod { + return false; + } + if combo.shift != event.shift || combo.alt != event.alt { + return false; + } + event_matches_key(event, &combo.key) +} + +/// A direction for the card focus. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + Left, + Right, + Up, + Down, +} + +/// What a binding does: one variant per `run` closure of the TS table. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyCommand { + /// `run: () => {}`: take the key, do nothing (WebView built-ins). + Swallow, + /// `run: () => PASS`. + Pass, + /// Close the context menu, open the palette. + OpenPalette, + ClosePalette, + /// A card drag in flight is cancelled, else PASS. + EscapeCancelDrag, + /// Close the input dialog if one is up, else the confirm dialog. + EscapeCloseDialog, + /// The persona wizard steps back, else the panel closes. + EscapePersonas, + /// Clear and blur the search, else drop the card focus, else PASS. + EscapeApp, + /// Focus and select the grid search. + FocusSearch, + FocusSettingsSearch, + AddAccount, + NewFolder, + Refresh, + /// Toggle bulk edit when the active tab allows it; taken either way. + ToggleBulkEdit, + /// Close personas, toggle the settings panel. + ToggleSettings, + OpenPersonas, + ToggleViewMode, + /// `cycleTab(1)` / `cycleTab(-1)`. + CycleTab(i8), + /// `mod+digitN`: the N-th enabled platform (0 based) unless unavailable. + TabByIndex(usize), + ZoomIn, + ZoomOut, + ResetZoom, + /// `alt+arrowleft` in the app: up a folder when in one; taken either way. + AltBack, + /// `backspace`: up a folder, PASS at the root. + BackspaceBack, + /// Move the card focus, PASS when it cannot move. + CardMove(Direction), + /// Enter: activate the focused card when the grid owns the key. + CardActivate, + /// F2. + RenameFocused, + /// Delete, Shift+F10, ContextMenu. + ContextMenuFocused, + /// Space in bulk edit: toggle the focused account. + BulkToggleFocused, + BulkSelectAll, + BulkDeselectAll, +} + +/// The scopes a binding fires in (`"*"` or a list). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Scopes { + All, + Only(Vec), +} + +impl Scopes { + pub fn contains(&self, scope: KeyScope) -> bool { + match self { + Scopes::All => true, + Scopes::Only(list) => list.contains(&scope), + } + } +} + +/// `ShortcutBinding`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Binding { + pub combo: String, + pub scopes: Scopes, + /// Single keys run in a text field only with this set. + pub allow_in_input: bool, + /// Skip the binding, modifier combos included, in a text field. + pub skip_in_input: bool, + /// `preventDefault !== false`. Every binding of the app table keeps it. + pub prevent_default: bool, + pub command: KeyCommand, +} + +impl Binding { + fn new(combo: impl Into, scopes: Scopes, command: KeyCommand) -> Self { + Self { + combo: combo.into(), + scopes, + allow_in_input: false, + skip_in_input: false, + prevent_default: true, + command, + } + } + + fn in_input(mut self) -> Self { + self.allow_in_input = true; + self + } + + fn skip_input(mut self) -> Self { + self.skip_in_input = true; + self + } +} + +fn only(scopes: &[KeyScope]) -> Scopes { + Scopes::Only(scopes.to_vec()) +} + +/// `createKeyboardBindings`: the whole table, in match order. +pub fn app_bindings() -> Vec { + use KeyCommand as C; + use KeyScope as S; + let all = || Scopes::All; + let card_nav = || only(&CARD_NAV_SCOPES); + let mut table = vec![ + // WebView built-ins that must never fire in a desktop shell. + Binding::new("mod+w", all(), C::Swallow), + Binding::new("mod+p", all(), C::Swallow), + Binding::new("f3", all(), C::Swallow).in_input(), + Binding::new("mod+g", all(), C::Swallow), + Binding::new("f7", all(), C::Swallow).in_input(), + Binding::new("mod+u", all(), C::Swallow), + Binding::new("mod+j", all(), C::Swallow), + Binding::new("alt+arrowright", all(), C::Swallow).in_input(), + Binding::new("alt+arrowleft", all(), C::Pass).in_input(), + // Command palette. + Binding::new( + "mod+k", + only(&[ + S::App, + S::Settings, + S::Personas, + S::BulkEdit, + S::ContextMenu, + ]), + C::OpenPalette, + ), + Binding::new("mod+k", only(&[S::Palette]), C::ClosePalette).in_input(), + // Escape cascade: one layer per press. + Binding::new("escape", all(), C::EscapeCancelDrag).in_input(), + Binding::new("escape", only(&[S::Palette]), C::ClosePalette).in_input(), + Binding::new("escape", only(&[S::Dialog]), C::EscapeCloseDialog).in_input(), + Binding::new("escape", only(&[S::ContextMenu]), C::Pass).in_input(), + Binding::new("escape", only(&[S::BulkEdit]), C::Pass).in_input(), + Binding::new("escape", only(&[S::Settings]), C::Pass).in_input(), + Binding::new("escape", only(&[S::Onboarding, S::Locked]), C::Pass).in_input(), + Binding::new("escape", only(&[S::Personas]), C::EscapePersonas).in_input(), + Binding::new("escape", only(&[S::App]), C::EscapeApp).in_input(), + // App-level shortcuts. + Binding::new("mod+f", only(&[S::App, S::BulkEdit]), C::FocusSearch), + Binding::new("mod+f", only(&[S::Settings]), C::FocusSettingsSearch), + Binding::new("mod+f", all(), C::Swallow), + Binding::new("mod+n", only(&[S::App]), C::AddAccount), + Binding::new("mod+shift+n", only(&[S::App]), C::NewFolder), + Binding::new("mod+r", only(&[S::App]), C::Refresh), + Binding::new("f5", only(&[S::App]), C::Refresh).in_input(), + Binding::new("f5", all(), C::Swallow).in_input(), + Binding::new("mod+shift+r", all(), C::Swallow), + Binding::new("mod+e", only(&[S::App, S::BulkEdit]), C::ToggleBulkEdit), + Binding::new( + "mod+,", + only(&[S::App, S::Settings, S::Personas]), + C::ToggleSettings, + ), + Binding::new("mod+shift+p", only(&[S::App]), C::OpenPersonas), + Binding::new("mod+shift+l", only(&[S::App]), C::ToggleViewMode), + Binding::new("mod+tab", only(&[S::App]), C::CycleTab(1)).in_input(), + Binding::new("mod+shift+tab", only(&[S::App]), C::CycleTab(-1)).in_input(), + ]; + for index in 0..9 { + table.push(Binding::new( + format!("mod+digit{}", index + 1), + only(&[S::App, S::Settings, S::Personas]), + C::TabByIndex(index), + )); + } + table.extend([ + Binding::new("mod+plus", all(), C::ZoomIn), + Binding::new("mod+shift+plus", all(), C::ZoomIn), + Binding::new("mod+minus", all(), C::ZoomOut), + Binding::new("mod+digit0", all(), C::ResetZoom), + Binding::new("alt+arrowleft", only(&[S::App]), C::AltBack).in_input(), + Binding::new("backspace", only(&[S::App]), C::BackspaceBack), + // Card focus navigation. + Binding::new("arrowleft", card_nav(), C::CardMove(Direction::Left)), + Binding::new("arrowright", card_nav(), C::CardMove(Direction::Right)), + Binding::new("arrowup", card_nav(), C::CardMove(Direction::Up)), + Binding::new("arrowdown", card_nav(), C::CardMove(Direction::Down)), + Binding::new("enter", card_nav(), C::CardActivate), + Binding::new("f2", only(&[S::App]), C::RenameFocused), + Binding::new("delete", only(&[S::App]), C::ContextMenuFocused), + Binding::new("shift+f10", only(&[S::App]), C::ContextMenuFocused), + Binding::new("contextmenu", only(&[S::App]), C::ContextMenuFocused), + Binding::new("space", only(&[S::BulkEdit]), C::BulkToggleFocused), + // Bulk edit selection, never from inside a text field. + Binding::new("mod+a", only(&[S::BulkEdit]), C::BulkSelectAll).skip_input(), + Binding::new("mod+d", only(&[S::BulkEdit]), C::BulkDeselectAll).skip_input(), + ]); + table +} + +/// A plain call a binding makes into the app, no answer expected. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum KeyAction { + CloseContextMenu, + SetPaletteOpen(bool), + CloseInputDialog, + CloseConfirmDialog, + ClosePersonas, + /// `navigation.searchQuery = ""`. + ClearSearchQuery, + /// `active.blur()` on the search field. + BlurSearch, + /// `cardFocus.clear()`. + ClearCardFocus, + /// `searchInput.focus(); searchInput.select()`. + FocusSearch, + FocusSettingsSearch, + AddAccount, + NewFolder, + Refresh, + ToggleBulkEdit, + ToggleSettingsPanel, + OpenPersonas, + ToggleViewMode, + ChangeTab(String), + ZoomIn, + ZoomOut, + ResetZoom, + /// `handleNavigateBack`. + NavigateBack, + ToggleBulkEditAccount(String), + BulkEditSelectAll, + BulkEditDeselectAll, +} + +/// What the bindings read from and do to the app (`KeyboardBindingDeps`). +pub trait KeyHost { + /// `getCurrentFolderId() != null`. + fn in_folder(&self) -> bool; + /// `getEnabledPlatforms()` ids, in tab order. + fn enabled_platform_ids(&self) -> Vec; + /// `getUnavailablePlatformIds().has(id)`. + fn is_platform_unavailable(&self, platform_id: &str) -> bool; + fn active_tab(&self) -> String; + fn has_input_dialog(&self) -> bool; + /// `document.activeElement` is the grid search input. + fn search_input_focused(&self) -> bool; + /// `cardFocus.focusedItem`. + fn focused_item(&self) -> Option; + /// `cardFocus.ownsActivationKey(e.target)`: no other control holds real + /// focus. + fn card_focus_owns_activation_key(&self, event: &KeyEvent) -> bool; + fn is_bulk_edit_toggle_allowed(&self) -> bool; + + /// `drag.cancelFromEscape()`: true when a drag was cancelled. + fn cancel_drag_from_escape(&mut self) -> bool; + /// `personasPanel.handleEscape()`: true when the wizard took the key. + fn personas_panel_escape(&mut self) -> bool; + /// `cardFocus.move(direction)`: true when the focus moved. + fn card_focus_move(&mut self, direction: Direction) -> bool; + /// `cardActions.activateFocusedCard()`. + fn activate_focused_card(&mut self) -> bool; + /// `cardActions.renameFocusedCard()`. + fn rename_focused_card(&mut self) -> bool; + /// `cardActions.openFocusedCardContextMenu()`. + fn open_focused_card_context_menu(&mut self) -> bool; + + fn act(&mut self, action: KeyAction); +} + +/// What a command answered. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyResult { + Handled, + /// The TS `PASS`. + Pass, +} + +fn handled_if(taken: bool) -> KeyResult { + if taken { + KeyResult::Handled + } else { + KeyResult::Pass + } +} + +/// `pickCycledTab`: the next tab for mod+tab / mod+shift+tab. Off the usable +/// list, forward enters at the first tab and backward at the last; `None` +/// with fewer than two tabs. +pub fn pick_cycled_tab(usable_ids: &[String], active_id: &str, direction: i8) -> Option { + let count = usable_ids.len(); + if count < 2 { + return None; + } + let Some(index) = usable_ids.iter().position(|id| id == active_id) else { + return Some(if direction >= 0 { + usable_ids[0].clone() + } else { + usable_ids[count - 1].clone() + }); + }; + let step = if direction >= 0 { 1 } else { count - 1 }; + Some(usable_ids[(index + step) % count].clone()) +} + +/// Runs one command against the host (the `run` closures of the TS table). +pub fn run_command(command: KeyCommand, event: &KeyEvent, host: &mut dyn KeyHost) -> KeyResult { + use KeyCommand as C; + let act = |host: &mut dyn KeyHost, action| { + host.act(action); + KeyResult::Handled + }; + match command { + C::Swallow => KeyResult::Handled, + C::Pass => KeyResult::Pass, + C::OpenPalette => { + host.act(KeyAction::CloseContextMenu); + act(host, KeyAction::SetPaletteOpen(true)) + } + C::ClosePalette => act(host, KeyAction::SetPaletteOpen(false)), + C::EscapeCancelDrag => handled_if(host.cancel_drag_from_escape()), + C::EscapeCloseDialog => { + if host.has_input_dialog() { + act(host, KeyAction::CloseInputDialog) + } else { + act(host, KeyAction::CloseConfirmDialog) + } + } + C::EscapePersonas => { + if !host.personas_panel_escape() { + host.act(KeyAction::ClosePersonas); + } + KeyResult::Handled + } + C::EscapeApp => { + if host.search_input_focused() { + host.act(KeyAction::ClearSearchQuery); + return act(host, KeyAction::BlurSearch); + } + if host.focused_item().is_some() { + return act(host, KeyAction::ClearCardFocus); + } + KeyResult::Pass + } + C::FocusSearch => act(host, KeyAction::FocusSearch), + C::FocusSettingsSearch => act(host, KeyAction::FocusSettingsSearch), + C::AddAccount => act(host, KeyAction::AddAccount), + C::NewFolder => act(host, KeyAction::NewFolder), + C::Refresh => act(host, KeyAction::Refresh), + C::ToggleBulkEdit => { + if host.is_bulk_edit_toggle_allowed() { + host.act(KeyAction::ToggleBulkEdit); + } + KeyResult::Handled + } + C::ToggleSettings => { + host.act(KeyAction::ClosePersonas); + act(host, KeyAction::ToggleSettingsPanel) + } + C::OpenPersonas => act(host, KeyAction::OpenPersonas), + C::ToggleViewMode => act(host, KeyAction::ToggleViewMode), + C::CycleTab(direction) => { + let usable: Vec = host + .enabled_platform_ids() + .into_iter() + .filter(|id| !host.is_platform_unavailable(id)) + .collect(); + if let Some(next) = pick_cycled_tab(&usable, &host.active_tab(), direction) { + host.act(KeyAction::ClosePersonas); + host.act(KeyAction::ChangeTab(next)); + } + KeyResult::Handled + } + C::TabByIndex(index) => { + let platform = host.enabled_platform_ids().get(index).cloned(); + if let Some(id) = platform.filter(|id| !host.is_platform_unavailable(id)) { + host.act(KeyAction::ClosePersonas); + host.act(KeyAction::ChangeTab(id)); + } + KeyResult::Handled + } + C::ZoomIn => act(host, KeyAction::ZoomIn), + C::ZoomOut => act(host, KeyAction::ZoomOut), + C::ResetZoom => act(host, KeyAction::ResetZoom), + C::AltBack => { + if host.in_folder() { + host.act(KeyAction::NavigateBack); + } + KeyResult::Handled + } + C::BackspaceBack => { + if !host.in_folder() { + return KeyResult::Pass; + } + act(host, KeyAction::NavigateBack) + } + C::CardMove(direction) => handled_if(host.card_focus_move(direction)), + C::CardActivate => { + handled_if(host.card_focus_owns_activation_key(event) && host.activate_focused_card()) + } + C::RenameFocused => handled_if(host.rename_focused_card()), + C::ContextMenuFocused => handled_if(host.open_focused_card_context_menu()), + C::BulkToggleFocused => { + if !host.card_focus_owns_activation_key(event) { + return KeyResult::Pass; + } + match host.focused_item() { + Some(item) if item.kind == ItemKind::Account => { + act(host, KeyAction::ToggleBulkEditAccount(item.id)) + } + _ => KeyResult::Pass, + } + } + C::BulkSelectAll => act(host, KeyAction::BulkEditSelectAll), + C::BulkDeselectAll => act(host, KeyAction::BulkEditDeselectAll), + } +} + +/// What [`KeyboardController::handle`] did with an event. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyOutcome { + /// No binding took it: let egui widgets see it. + Unhandled, + /// Binding `index` of the table took it. `consume` is the TS + /// `preventDefault` plus `stopPropagation`. + Handled { index: usize, consume: bool }, +} + +impl KeyOutcome { + pub fn is_handled(self) -> bool { + matches!(self, KeyOutcome::Handled { .. }) + } +} + +/// `createKeyboardController` without the listener. +#[derive(Debug, Clone)] +pub struct KeyboardController { + bindings: Vec<(Binding, ParsedCombo)>, +} + +impl Default for KeyboardController { + fn default() -> Self { + Self::new(app_bindings()) + } +} + +impl KeyboardController { + pub fn new(bindings: Vec) -> Self { + Self { + bindings: bindings + .into_iter() + .map(|binding| { + let combo = parse_combo(&binding.combo); + (binding, combo) + }) + .collect(), + } + } + + pub fn bindings(&self) -> impl Iterator { + self.bindings.iter().map(|(binding, _)| binding) + } + + /// `handleKeydown`: the first binding whose scope and combo match, that + /// the text-field rules allow, and that does not pass, takes the key. + pub fn handle( + &self, + event: &KeyEvent, + scope: KeyScope, + is_mac: bool, + host: &mut dyn KeyHost, + ) -> KeyOutcome { + for (index, (binding, combo)) in self.bindings.iter().enumerate() { + if !binding.scopes.contains(scope) { + continue; + } + if !combo_matches(event, combo, is_mac) { + continue; + } + let has_modifier = combo.mod_key || combo.alt; + if event.editable_target && !has_modifier && !binding.allow_in_input { + continue; + } + if event.editable_target && binding.skip_in_input { + continue; + } + if run_command(binding.command, event, host) == KeyResult::Pass { + continue; + } + return KeyOutcome::Handled { + index, + consume: binding.prevent_default, + }; + } + KeyOutcome::Unhandled + } +} diff --git a/crates/accshift-app/src/actions/mod.rs b/crates/accshift-app/src/actions/mod.rs new file mode 100644 index 0000000..31587d8 --- /dev/null +++ b/crates/accshift-app/src/actions/mod.rs @@ -0,0 +1,177 @@ +//! What happens when the user acts: dialogs, card actions, navigation, +//! personas, the command palette, the keyboard model and toasts. +//! +//! Ported from the orchestration composables of `src/lib/app/` and the +//! feature stores they drive. Each TS composable becomes a state struct with +//! methods. A method that reaches into something another module owns (the +//! account loader, the add flow, bulk edit, the shell's active tab, the +//! clipboard) does not call it: it returns [`AppEffect`]s, in the order TS +//! performs those calls, and the egui `AppModel` applies them after the frame. +//! What these modules own themselves (dialog state, the history stack, the +//! toast queue, the stores they write) changes in place. +//! +//! - [`toasts::ToastQueue`] ports `features/notifications/store.svelte.ts` and the +//! timer of `Toast.svelte`. +//! - [`keyboard`] ports `shared/keyboard/{controller,types}.ts` and +//! `app/keyboardBindings.ts`: the binding table as data, combo parsing, +//! scope resolution and dispatch through a [`keyboard::KeyHost`]. +//! - [`palette`] ports `features/commandPalette/{fuzzy,registry}.ts` and the +//! filtering of `CommandPalette.svelte`. +//! - [`dialogs::AppDialogs`] ports `app/useAppDialogs.svelte.ts`, the validation of +//! `InputDialog.svelte`. +//! - [`card_actions`] ports `app/useCardActions.ts`. +//! - [`navigation`] ports `app/useAppNavigation.svelte.ts` with the browser +//! history as a stack, `app/tabCycle.ts`, `app/useSettingsPanel.svelte.ts`, +//! `features/settings/useSettingsTabBar.svelte.ts`. +//! - [`extension::ExtensionContent`] ports `app/useExtensionContent.svelte.ts`. +//! - [`personas`] ports `app/usePersonas.svelte.ts`, `app/usePersonaSwitch.svelte.ts`. +//! - [`onboarding::OnboardingTour`] ports `app/useOnboardingTour.svelte.ts`. +//! - [`avatar_fallback`] ports `shared/avatarFallback.ts`. +//! - [`shell`] ports the handlers `src/App.svelte` defines inline over several of +//! these. + +pub mod avatar_fallback; +pub mod card_actions; +pub mod dialogs; +pub mod extension; +pub mod keyboard; +pub mod navigation; +pub mod onboarding; +pub mod palette; +pub mod personas; +pub mod shell; +pub mod toasts; + +use crate::accounts::loader::LoadOptions; +use crate::accounts::Toast; +use crate::menu::PlatformTask; +use crate::platforms::PlatformAccount; +use crate::stores::ViewMode; +use crate::text::Text; + +/// `loadAccounts(silent, showRefreshedToast, forceRefresh, checkBans, +/// deferBackground)` with the TS defaults (`deferBackground` true). +pub fn load_options( + silent: bool, + show_refreshed_toast: bool, + force_refresh: bool, + check_bans: bool, +) -> LoadOptions { + LoadOptions { + silent, + show_refreshed_toast, + force_refresh, + check_bans, + defer_background: true, + } +} + +/// One call into something another module owns, in the order TS makes it. +#[derive(Debug, Clone, PartialEq)] +pub enum AppEffect { + /// `addToast` / `showToast`. + Toast(Toast), + /// `loadAccounts(...)` on the active tab. + LoadAccounts(LoadOptions), + /// `handleRefreshClick`. + RefreshAccounts, + /// `handleAddAccountClick`. + AddAccount, + /// `handleAccountSwitch(account)`. + SwitchAccount(PlatformAccount), + /// `handleNavigateToFolder(folderId)`: + /// [`navigation::AppNavigation::navigate_to`] with history. + NavigateToFolder(Option), + /// `navigation.refreshCurrentItems()`. + RefreshCurrentItems, + /// `loader.prepareVisibleAccounts()`. + PrepareVisibleAccounts, + /// `grid.queueCalculatePadding()`. + QueueGridPadding, + /// `loader.clearForPlatformChange()`. + ClearForPlatformChange, + /// `visiblePriming.reset()`. + ResetVisiblePrimeState, + /// `shell.setActiveTab(tab)`. + SetActiveTab(String), + /// `shell.refreshSettings()`: re-read the settings store. + RefreshSettings, + /// `secureScreen.handleSettingsClosed()`. + SettingsClosed, + /// `appNavigation.closeSettingsPanel()`: + /// [`navigation::AppNavigation::close_settings_panel`]. + CloseSettingsPanel, + /// `appNavigation.toggleSettingsPanel()`. + ToggleSettingsPanel, + /// `appNavigation.handleTabChange(tab)`. + ChangeTab(String), + /// `handleNavigateBack`: [`navigation::AppNavigation::navigate_to_parent_folder`] + /// (its add flow cancel comes first in the list). + NavigateToParentFolder, + /// The removal of an account from the grid (`removeAccount` glue of + /// App.svelte: loader, folder sync, items). + RemoveAccount(String), + /// `copyToClipboard(text, label)`: write it, then + /// [`shell::copy_result_toast`] with the label resolved. + CopyToClipboard { text: String, label: Text }, + /// Write several lines to the clipboard, then + /// [`card_actions::bulk_urls_copied_toast`] (`copyBulkEditUrls`). + CopyBulkUrls(Vec), + /// `cardColorVersion += 1`: card colours changed in the stores. + CardColorsChanged, + /// `cardNoteVersion += 1`: card notes changed in the stores. + CardNotesChanged, + /// A menu task for `PlatformAdapter::run_task` on the job pool. + RunPlatformTask(PlatformTask), + /// `adapter.setAccountLabel` then `loadAccounts(true)`, a + /// `toast.renameAccountFailed` on error: run + /// [`dialogs::rename_account_blocking`] on the pool and hand the result to + /// [`dialogs::finish_rename_account`]. + RenameAccount { + platform_id: String, + account_id: String, + label: String, + }, + /// A `requestConfirm` settled: route it to whoever holds the ticket. + ConfirmSettled { + ticket: dialogs::ConfirmTicket, + allowed: bool, + }, + /// `addFlow.cancel()`. + AddFlowCancel, + /// `addFlow.cancelIfConflicting(platformId, accountId?)`. + AddFlowCancelIfConflicting { + platform_id: String, + account_id: Option, + }, + /// `bulkEdit.closeBulkEdit()`. + CloseBulkEdit, + /// `bulkEdit.toggleBulkEdit()`. + ToggleBulkEdit, + /// `bulkEdit.toggleBulkEditAccount(id)`. + BulkEditToggleAccount(String), + /// `bulkEdit.handlePaintMouseDown(event)`. + BulkEditPaintStart, + /// `bulkEdit.bulkEditSelectAll()`. + BulkEditSelectAll, + /// `bulkEdit.bulkEditDeselectAll()`. + BulkEditDeselectAll, + /// `drag.handleGridMouseDown(event)`. + DragGridMouseDown, + /// `uiScale.zoomIn()`. + ZoomIn, + /// `uiScale.zoomOut()`. + ZoomOut, + /// `uiScale.resetZoom()`. + ResetZoom, + /// `updates.applyReadyUpdate()`. + ApplyUpdate, + /// The view mode changed and was stored; the grid recomputes its padding + /// when it is the grid. + ViewModeChanged(ViewMode), + /// An entry of a caller-built menu (`openCustomContextMenu`, the persona + /// cards) was clicked: the id that caller gave it. + CustomMenu(String), + /// `invoke("telemetry_track_persona_switch", ...)`, errors ignored. + TrackPersonaSwitch { platforms: u64, succeeded: u64 }, +} diff --git a/crates/accshift-app/src/actions/navigation.rs b/crates/accshift-app/src/actions/navigation.rs new file mode 100644 index 0000000..3c666ef --- /dev/null +++ b/crates/accshift-app/src/actions/navigation.rs @@ -0,0 +1,441 @@ +//! Navigation between tabs, folders and the settings panel, ported from +//! `src/lib/app/useAppNavigation.svelte.ts`, `src/lib/app/useSettingsPanel.svelte.ts` +//! and `src/lib/features/settings/useSettingsTabBar.svelte.ts`. +//! +//! The TS code drives the WebView history (`pushState`, `replaceState`, +//! `back`, `popstate`) so the mouse back button and Alt+Left walk folders and +//! close settings. [`HistoryStack`] is that history: `back` hands the entry the +//! `popstate` listener would get, and the caller applies it at once. + +use super::{load_options, AppEffect}; +use crate::accounts::loading::is_platform_usable; +use crate::display::FolderNavigation; +use crate::platforms::{PlatformRegistry, RuntimeOs}; +use crate::settings::AppSettings; +use crate::stores::ClientStores; + +pub use super::keyboard::pick_cycled_tab; + +/// `AppHistoryEntry`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppHistoryEntry { + pub tab: String, + pub folder_id: Option, + pub show_settings: bool, +} + +impl AppHistoryEntry { + pub fn new(tab: impl Into, folder_id: Option, show_settings: bool) -> Self { + Self { + tab: tab.into(), + folder_id, + show_settings, + } + } +} + +/// The session history of the window, one entry per `pushState`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HistoryStack { + entries: Vec, + index: usize, +} + +impl HistoryStack { + /// The mount's `history.replaceState({ tab, folderId: null, + /// showSettings: false })`. + pub fn new(initial: AppHistoryEntry) -> Self { + Self { + entries: vec![initial], + index: 0, + } + } + + /// `history.state`. + pub fn state(&self) -> &AppHistoryEntry { + &self.entries[self.index] + } + + /// `pushState`: drops the forward entries. + pub fn push(&mut self, entry: AppHistoryEntry) { + self.entries.truncate(self.index + 1); + self.entries.push(entry); + self.index += 1; + } + + /// `replaceState`. + pub fn replace(&mut self, entry: AppHistoryEntry) { + self.entries[self.index] = entry; + } + + /// `history.back()`: the entry `popstate` delivers, `None` at the start. + pub fn back(&mut self) -> Option { + if self.index == 0 { + return None; + } + self.index -= 1; + Some(self.state().clone()) + } + + /// `history.forward()`. + pub fn forward(&mut self) -> Option { + if self.index + 1 >= self.entries.len() { + return None; + } + self.index += 1; + Some(self.state().clone()) + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn index(&self) -> usize { + self.index + } +} + +/// `createSettingsPanel`. egui has no lazy import, so `loadComponent` and its +/// failure toast have nothing to do. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SettingsPanel { + pub show_settings: bool, +} + +impl SettingsPanel { + /// `open`. + pub fn open(&mut self) { + self.show_settings = true; + } + + /// `close`: hides the panel and runs `onClose` (App.svelte: refresh the + /// settings, tell the secure screen). + pub fn close(&mut self) -> Vec { + self.show_settings = false; + vec![AppEffect::RefreshSettings, AppEffect::SettingsClosed] + } +} + +/// `SettingsTabDef`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SettingsTabDef { + pub id: String, + pub label_key: String, + pub accent: String, +} + +/// `createSettingsTabBar`: the selected settings tab. The scroll into view +/// is a request the UI takes once. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SettingsTabBar { + active_tab: String, + scroll_requested: bool, +} + +impl Default for SettingsTabBar { + fn default() -> Self { + Self { + active_tab: "general".to_string(), + scroll_requested: false, + } + } +} + +impl SettingsTabBar { + pub fn new() -> Self { + Self::default() + } + + pub fn active_tab(&self) -> &str { + &self.active_tab + } + + /// `select`: the caller runs `onTabSelected(tab_id)`. + pub fn select(&mut self, tab_id: &str) { + self.active_tab = tab_id.to_string(); + self.scroll_requested = true; + } + + /// `ensureActiveVisible`: a hidden tab falls back to the first visible + /// one (`general` when none). The new tab when it changed, for + /// `onTabSelected`. + pub fn ensure_active_visible(&mut self, visible_ids: &[String]) -> Option { + let mut changed = None; + if !visible_ids.contains(&self.active_tab) { + self.active_tab = visible_ids + .first() + .cloned() + .unwrap_or_else(|| "general".to_string()); + changed = Some(self.active_tab.clone()); + } + self.scroll_requested = true; + changed + } + + /// `queueScrollActive`: true once after each select or check. + pub fn take_scroll_request(&mut self) -> bool { + std::mem::take(&mut self.scroll_requested) + } +} + +/// What a navigation step reads and writes. +pub struct NavContext<'a> { + /// `shell.activeTab` before the step. + pub active_tab: &'a str, + pub runtime_os: RuntimeOs, + pub registry: &'a PlatformRegistry, + /// `shell.settings`. For [`AppNavigation::handle_platforms_changed`], + /// the settings after `refreshSettings`. + pub settings: &'a AppSettings, + /// `addFlow.flow?.platformId`. + pub add_flow_platform: Option<&'a str>, + pub navigation: &'a mut FolderNavigation, + pub settings_panel: &'a mut SettingsPanel, + pub stores: &'a ClientStores, +} + +impl NavContext<'_> { + fn usable(&self, tab: &str) -> bool { + is_platform_usable(self.registry, tab, self.runtime_os) + } + + fn current_folder(&self) -> Option { + self.navigation.current_folder_id().map(str::to_string) + } +} + +/// `createAppNavigationController`, with the history it drives. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppNavigation { + pub history: HistoryStack, +} + +impl AppNavigation { + /// The history starts on `tab` at its root, settings closed. + pub fn new(tab: &str) -> Self { + Self { + history: HistoryStack::new(AppHistoryEntry::new(tab, None, false)), + } + } + + /// `toggleSettingsPanel`. + pub fn toggle_settings_panel(&mut self, ctx: &mut NavContext<'_>) -> Vec { + if ctx.settings_panel.show_settings { + return self.close_settings_panel(ctx); + } + let entry = AppHistoryEntry::new(ctx.active_tab, ctx.current_folder(), true); + self.history.push(entry); + ctx.settings_panel.show_settings = true; + vec![AppEffect::AddFlowCancel, AppEffect::CloseBulkEdit] + } + + /// `applyAppState`: what a history entry restores. + pub fn apply_app_state( + &mut self, + entry: &AppHistoryEntry, + ctx: &mut NavContext<'_>, + ) -> Vec { + let mut effects = Vec::new(); + if let Some(flow_platform) = ctx.add_flow_platform { + if entry.show_settings + || entry.tab != flow_platform + || entry.folder_id.as_deref() != ctx.navigation.current_folder_id() + { + effects.push(AppEffect::AddFlowCancel); + } + } + let tab_changed = ctx.active_tab != entry.tab; + let settings_closing = ctx.settings_panel.show_settings && !entry.show_settings; + if tab_changed { + effects.push(AppEffect::ClearForPlatformChange); + effects.push(AppEffect::SetActiveTab(entry.tab.clone())); + } + ctx.navigation + .set_current_folder_id(entry.folder_id.clone()); + ctx.settings_panel.show_settings = entry.show_settings; + if settings_closing { + effects.push(AppEffect::RefreshSettings); + effects.push(AppEffect::SettingsClosed); + } + if tab_changed && ctx.usable(&entry.tab) { + effects.push(AppEffect::LoadAccounts(load_options( + true, false, false, false, + ))); + } else { + effects.extend([ + AppEffect::RefreshCurrentItems, + AppEffect::PrepareVisibleAccounts, + AppEffect::QueueGridPadding, + ]); + } + ctx.navigation.set_search_query(""); + effects + } + + /// `handlePopState`. + pub fn handle_pop_state( + &mut self, + entry: &AppHistoryEntry, + ctx: &mut NavContext<'_>, + ) -> Vec { + self.apply_app_state(entry, ctx) + } + + /// The mouse back button: `history.back()` then its `popstate`. + pub fn go_back(&mut self, ctx: &mut NavContext<'_>) -> Vec { + match self.history.back() { + Some(entry) => self.handle_pop_state(&entry, ctx), + None => Vec::new(), + } + } + + /// The mouse forward button. + pub fn go_forward(&mut self, ctx: &mut NavContext<'_>) -> Vec { + match self.history.forward() { + Some(entry) => self.handle_pop_state(&entry, ctx), + None => Vec::new(), + } + } + + /// `closeSettingsPanel`: back out of the settings entry when it is the + /// current one, otherwise replace it with a closed state. + pub fn close_settings_panel(&mut self, ctx: &mut NavContext<'_>) -> Vec { + if !ctx.settings_panel.show_settings { + return Vec::new(); + } + if self.history.state().show_settings { + return self.go_back(ctx); + } + let next = AppHistoryEntry::new(ctx.active_tab, ctx.current_folder(), false); + self.history.replace(next.clone()); + self.apply_app_state(&next, ctx) + } + + /// `navigateToParentFolder`. + pub fn navigate_to_parent_folder(&mut self, ctx: &mut NavContext<'_>) -> Vec { + let Some(current) = ctx.current_folder() else { + return Vec::new(); + }; + let mut effects = vec![AppEffect::AddFlowCancelIfConflicting { + platform_id: ctx.active_tab.to_string(), + account_id: None, + }]; + let parent = ctx + .stores + .folders() + .folder(¤t) + .and_then(|folder| folder.parent_id.clone()); + self.history + .replace(AppHistoryEntry::new(ctx.active_tab, parent.clone(), false)); + ctx.navigation.set_current_folder_id(parent); + ctx.settings_panel.show_settings = false; + effects.extend([ + AppEffect::RefreshCurrentItems, + AppEffect::PrepareVisibleAccounts, + ]); + ctx.navigation.set_search_query(""); + effects.push(AppEffect::QueueGridPadding); + effects + } + + /// `navigateTo(folderId, { trackHistory })`. + pub fn navigate_to( + &mut self, + folder_id: Option, + track_history: bool, + ctx: &mut NavContext<'_>, + ) -> Vec { + let effects_head = AppEffect::AddFlowCancelIfConflicting { + platform_id: ctx.active_tab.to_string(), + account_id: None, + }; + if track_history { + self.history.push(AppHistoryEntry::new( + ctx.active_tab, + folder_id.clone(), + false, + )); + } + ctx.navigation.set_current_folder_id(folder_id); + ctx.settings_panel.show_settings = false; + vec![ + effects_head, + AppEffect::RefreshCurrentItems, + AppEffect::PrepareVisibleAccounts, + AppEffect::QueueGridPadding, + ] + } + + /// `handleTabChange`. Clicking the tab on screen only leaves settings. + pub fn handle_tab_change(&mut self, tab: &str, ctx: &mut NavContext<'_>) -> Vec { + if !ctx.usable(tab) { + return Vec::new(); + } + if tab == ctx.active_tab { + if ctx.settings_panel.show_settings { + return self.close_settings_panel(ctx); + } + return Vec::new(); + } + let mut effects = vec![AppEffect::AddFlowCancel, AppEffect::CloseBulkEdit]; + self.history.push(AppHistoryEntry::new(tab, None, false)); + effects.extend([ + AppEffect::ResetVisiblePrimeState, + AppEffect::ClearForPlatformChange, + AppEffect::SetActiveTab(tab.to_string()), + ]); + ctx.navigation.set_current_folder_id(None); + ctx.settings_panel.show_settings = false; + if ctx.usable(tab) { + effects.push(AppEffect::LoadAccounts(load_options( + true, false, false, false, + ))); + } else { + effects.extend([AppEffect::RefreshCurrentItems, AppEffect::QueueGridPadding]); + } + ctx.navigation.set_search_query(""); + effects + } + + /// `handlePlatformsChanged`: after the enabled platforms changed in + /// settings. A tab that is no longer enabled or usable moves to + /// `initial_tab()` (`getInitialActiveTab` of the platform shell, over the + /// refreshed settings). + pub fn handle_platforms_changed( + &mut self, + ctx: &mut NavContext<'_>, + initial_tab: impl FnOnce() -> String, + ) -> Vec { + let mut effects = Vec::new(); + if ctx.add_flow_platform.is_some() { + effects.push(AppEffect::AddFlowCancel); + } + effects.push(AppEffect::RefreshSettings); + let mut tab = ctx.active_tab.to_string(); + let enabled = ctx.settings.enabled_platforms.contains(&tab); + if !enabled || !ctx.usable(&tab) { + effects.push(AppEffect::ResetVisiblePrimeState); + effects.push(AppEffect::ClearForPlatformChange); + tab = initial_tab(); + effects.push(AppEffect::SetActiveTab(tab.clone())); + } + ctx.navigation.set_current_folder_id(None); + self.history.replace(AppHistoryEntry::new( + tab.clone(), + None, + ctx.settings_panel.show_settings, + )); + if ctx.usable(&tab) { + effects.push(AppEffect::LoadAccounts(load_options( + false, false, false, false, + ))); + } else { + effects.push(AppEffect::RefreshCurrentItems); + } + effects + } +} diff --git a/crates/accshift-app/src/actions/onboarding.rs b/crates/accshift-app/src/actions/onboarding.rs new file mode 100644 index 0000000..b7491fa --- /dev/null +++ b/crates/accshift-app/src/actions/onboarding.rs @@ -0,0 +1,152 @@ +//! The first-run telemetry onboarding and the fake account grid its feature +//! tour draws over. Ported from `src/lib/app/useOnboardingTour.svelte.ts` +//! (state and steps; the dialog itself is drawn by the egui crate). +//! +//! A new install has no accounts, which is when the tour needs cards to point +//! at: three mock accounts replace the workspace for the duration and the +//! previous tab comes back afterwards. + +use super::AppEffect; +use crate::backend::Backend; +use crate::folders::ItemRef; +use crate::jobs::{Jobs, Pending, Poll}; +use crate::platforms::PlatformAccount; +use crate::text::Text; + +/// Ids of the fake accounts, prefixed so nothing takes one for a real id. +pub const MOCK_IDS: [&str; 3] = ["__tour_mock_1", "__tour_mock_2", "__tour_mock_3"]; +/// The tour walks through Steam's card features. +pub const TOUR_PLATFORM: &str = "steam"; + +/// A fake account: its display name stays a key, resolved at draw time. +#[derive(Debug, Clone, PartialEq)] +pub struct MockAccount { + pub account: PlatformAccount, + /// `onboarding.features.mockAccount` with `{number}`. + pub display_name: Text, +} + +/// `mockAccounts`. +pub fn mock_accounts() -> Vec { + MOCK_IDS + .iter() + .enumerate() + .map(|(index, id)| { + let number = index + 1; + MockAccount { + account: PlatformAccount { + id: (*id).to_string(), + display_name: String::new(), + username: format!("account_{number}"), + last_login_at_sec: None, + }, + display_name: Text::with( + "onboarding.features.mockAccount", + [("number", number as f64)], + ), + } + }) + .collect() +} + +/// `mockItems`. +pub fn mock_items() -> Vec { + MOCK_IDS.iter().map(|id| ItemRef::account(*id)).collect() +} + +/// `createOnboardingTour`. +#[derive(Default)] +pub struct OnboardingTour { + open: bool, + mock_active: bool, + previous_tab: Option, + state_read: Option>, +} + +impl OnboardingTour { + pub fn new() -> Self { + Self::default() + } + + pub fn open(&self) -> bool { + self.open + } + + /// True while the workspace shows the tour's fake accounts. + pub fn mock_active(&self) -> bool { + self.mock_active + } + + /// `openOnboarding`. + pub fn open_onboarding(&mut self) { + self.open = true; + } + + fn activate_mock(&mut self, active_tab: &str) -> Vec { + self.previous_tab = Some(active_tab.to_string()); + let mut effects = Vec::new(); + if active_tab != TOUR_PLATFORM { + effects.push(AppEffect::SetActiveTab(TOUR_PLATFORM.to_string())); + } + self.mock_active = true; + effects + } + + fn deactivate_mock(&mut self, active_tab: &str) -> Vec { + self.mock_active = false; + let mut effects = Vec::new(); + if let Some(previous) = self.previous_tab.take() { + if !previous.is_empty() && previous != active_tab { + effects.push(AppEffect::SetActiveTab(previous)); + } + } + effects + } + + /// `setMockActive`. + pub fn set_mock_active(&mut self, active: bool, active_tab: &str) -> Vec { + if active { + self.activate_mock(active_tab) + } else { + self.deactivate_mock(active_tab) + } + } + + /// `close`. + pub fn close(&mut self, active_tab: &str) -> Vec { + self.open = false; + self.deactivate_mock(active_tab) + } + + /// `openIfNeverCompleted`, started: reads `telemetry_get_state` on the + /// pool. A failed read shows nothing. + pub fn start_open_if_never_completed(&mut self, jobs: &Jobs) { + self.state_read = Some(jobs.run(|backend: &dyn Backend| { + backend + .telemetry_get_state() + .map(|state| !state.onboarding_completed) + .unwrap_or(false) + })); + } + + /// Applies the read once it lands: true when the onboarding opened. + pub fn poll(&mut self) -> bool { + let Some(pending) = self.state_read.as_mut() else { + return false; + }; + match pending.poll() { + Poll::Waiting => false, + Poll::Ready(show) => { + self.state_read = None; + if show { + self.open_onboarding(); + } + show + } + Poll::Gone => { + self.state_read = None; + false + } + } + } +} diff --git a/crates/accshift-app/src/actions/palette.rs b/crates/accshift-app/src/actions/palette.rs new file mode 100644 index 0000000..ba16191 --- /dev/null +++ b/crates/accshift-app/src/actions/palette.rs @@ -0,0 +1,585 @@ +//! The command palette, ported from +//! `src/lib/features/commandPalette/{fuzzy,registry}.ts` and the filtering, +//! grouping and selection of `CommandPalette.svelte`. + +use crate::display::fold_search_text; +use crate::folders::FolderInfo; +use crate::i18n::Locale; +use crate::platforms::{PlatformAccount, PlatformDef}; +use crate::stores::ViewMode; +use crate::text::Text; +use std::cmp::Ordering; +use std::collections::{BTreeSet, HashMap}; + +// ------------------------------------------------------------- fuzzy + +/// `foldText`: NFD, combining diacritics dropped, lowercased. The same fold +/// as the grid search. +pub fn fold_text(text: &str) -> String { + fold_search_text(text) +} + +/// `scoreFolded` over UTF-16 units, as JS indexes strings. +fn score_folded(query: &[u16], target: &[u16]) -> Option { + if query.is_empty() { + return Some(0); + } + let mut score: i64 = 0; + let mut ti = 0usize; + let mut last_match: i64 = -2; + for &ch in query { + let found = target + .get(ti..) + .and_then(|rest| rest.iter().position(|&unit| unit == ch)) + .map(|offset| ti + offset)?; + if found as i64 == last_match + 1 { + score += 6; + } + if found == 0 + || target[found - 1] == u16::from(b' ') + || target[found - 1] == u16::from(b'-') + { + score += 8; + } + score += 1; + // A small penalty for skipped distance keeps tight matches on top. + score -= ((found - ti) as i64).min(4); + last_match = found as i64; + ti = found + 1; + } + // Shorter targets win ties. + score += (12 - (target.len() / 4) as i64).max(0); + Some(score) +} + +fn utf16(text: &str) -> Vec { + text.encode_utf16().collect() +} + +/// `fuzzyScore`: every query character in order in the text, consecutive +/// matches and word starts scoring higher. `None` when it is not a +/// subsequence. +pub fn fuzzy_score(query: &str, text: &str) -> Option { + score_folded(&utf16(&fold_text(query)), &utf16(&fold_text(text))) +} + +/// The folded targets, memoised like `foldCache` (cleared at 4096 entries). +#[derive(Debug, Clone, Default)] +pub struct FoldCache { + entries: HashMap>, +} + +impl FoldCache { + pub const MAX: usize = 4096; + + fn get(&mut self, text: &str) -> Vec { + if let Some(hit) = self.entries.get(text) { + return hit.clone(); + } + if self.entries.len() >= Self::MAX { + self.entries.clear(); + } + let folded = utf16(&fold_text(text)); + self.entries.insert(text.to_string(), folded.clone()); + folded + } +} + +/// `fuzzyScoreAll`: the best score over a title and its keywords; a keyword +/// hit counts 2 less than the same title hit. +pub fn fuzzy_score_all( + cache: &mut FoldCache, + query: &str, + title: &str, + keywords: &[String], +) -> Option { + let q = utf16(&fold_text(query)); + let mut best = score_folded(&q, &cache.get(title)); + for keyword in keywords { + if let Some(score) = score_folded(&q, &cache.get(keyword)) { + if best.is_none_or(|current| score - 2 > current) { + best = Some(score - 2); + } + } + } + best +} + +// ---------------------------------------------------------- registry + +/// `PaletteSection`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PaletteSection { + Accounts, + Actions, + Navigation, +} + +impl PaletteSection { + /// `SECTION_ORDER`. + pub const ORDER: [PaletteSection; 3] = [ + PaletteSection::Accounts, + PaletteSection::Actions, + PaletteSection::Navigation, + ]; + + /// `SECTION_LABEL_KEYS`. + pub fn label_key(self) -> &'static str { + match self { + PaletteSection::Accounts => "palette.sectionAccounts", + PaletteSection::Actions => "palette.sectionActions", + PaletteSection::Navigation => "palette.sectionNavigation", + } + } +} + +/// What running a command does (the TS `run` closures). +#[derive(Debug, Clone, PartialEq)] +pub enum PaletteAction { + /// Cancel a conflicting add flow, then switch. + SwitchToAccount(PlatformAccount), + AddAccount, + RefreshAccounts, + NewFolder, + ToggleViewMode, + ZoomReset, + ToggleBulkEdit, + OpenPersonas, + ApplyUpdate, + /// Close personas, change tab. + ChangeTab(String), + OpenFolder(String), + NavigateToParent, + /// Close personas, toggle settings. + ToggleSettings, +} + +/// `PaletteCommand`. +#[derive(Debug, Clone, PartialEq)] +pub struct PaletteCommand { + pub id: String, + pub section: PaletteSection, + pub title: Text, + /// Extra matching terms, in both locales. + pub keywords: Vec, + /// The shortcut shown right-aligned. + pub hint: Option, + pub accent: Option, + pub active: bool, + pub disabled: bool, + pub action: PaletteAction, +} + +impl PaletteCommand { + fn new( + id: impl Into, + section: PaletteSection, + title: Text, + action: PaletteAction, + ) -> Self { + Self { + id: id.into(), + section, + title, + keywords: Vec::new(), + hint: None, + accent: None, + active: false, + disabled: false, + action, + } + } + + fn keywords(mut self, keywords: &[&str]) -> Self { + self.keywords = keywords.iter().map(|word| word.to_string()).collect(); + self + } + + fn hint(mut self, hint: String) -> Self { + self.hint = Some(hint); + self + } +} + +/// What the registry reads (`RegistryDeps` getters). +#[derive(Debug, Clone)] +pub struct RegistryInput<'a> { + pub accounts: &'a [PlatformAccount], + pub current_account_id: Option<&'a str>, + pub enabled_platforms: &'a [&'a PlatformDef], + pub unavailable_platform_ids: &'a BTreeSet, + pub active_tab: &'a str, + pub active_tab_usable: bool, + /// The folders shown in the current view. + pub current_folders: &'a [&'a FolderInfo], + pub current_folder_id: Option<&'a str>, + pub bulk_edit_available: bool, + pub personas_enabled: bool, + /// `getUpdateCtaLabel()` is non-empty. + pub update_ready: bool, + pub view_mode: ViewMode, + pub is_mac: bool, +} + +fn mod_label(is_mac: bool) -> &'static str { + if is_mac { + "Cmd" + } else { + "Ctrl" + } +} + +fn account_commands(input: &RegistryInput<'_>) -> Vec { + input + .accounts + .iter() + .map(|account| { + let title = [&account.display_name, &account.username, &account.id] + .into_iter() + .find(|value| !value.is_empty()) + .cloned() + .unwrap_or_default(); + let mut command = PaletteCommand::new( + format!("account:{}", account.id), + PaletteSection::Accounts, + Text::raw(title), + PaletteAction::SwitchToAccount(account.clone()), + ); + command.keywords = [&account.username, &account.id] + .into_iter() + .filter(|value| !value.is_empty()) + .cloned() + .collect(); + command.active = Some(account.id.as_str()) == input.current_account_id; + command + }) + .collect() +} + +fn action_commands(input: &RegistryInput<'_>) -> Vec { + use PaletteAction as A; + use PaletteSection::Actions; + let m = mod_label(input.is_mac); + let usable = input.active_tab_usable; + let mut add = PaletteCommand::new( + "action:add-account", + Actions, + Text::key("titlebar.addAccount"), + A::AddAccount, + ) + .keywords(&[ + "add account", + "ajouter un compte", + "nouveau compte", + "new account", + ]) + .hint(format!("{m}+N")); + add.disabled = !usable; + let mut refresh = PaletteCommand::new( + "action:refresh", + Actions, + Text::key("titlebar.refresh"), + A::RefreshAccounts, + ) + .keywords(&["refresh", "reload", "rafraichir", "actualiser"]) + .hint(format!("{m}+R")); + refresh.disabled = !usable; + let view_key = if input.view_mode == ViewMode::Grid { + "view.list" + } else { + "view.grid" + }; + let mut commands = vec![ + add, + refresh, + PaletteCommand::new( + "action:new-folder", + Actions, + Text::key("context.menu.newFolder"), + A::NewFolder, + ) + .keywords(&["new folder", "nouveau dossier", "creer dossier"]) + .hint(format!("{m}+Shift+N")), + PaletteCommand::new( + "action:toggle-view", + Actions, + Text::key(view_key), + A::ToggleViewMode, + ) + .keywords(&["view", "grid", "list", "vue", "grille", "liste"]) + .hint(format!("{m}+Shift+L")), + PaletteCommand::new( + "action:zoom-reset", + Actions, + Text::key("palette.zoomReset"), + A::ZoomReset, + ) + .keywords(&["zoom", "scale", "taille", "100%"]) + .hint(format!("{m}+0")), + ]; + if input.bulk_edit_available { + commands.push( + PaletteCommand::new( + "action:bulk-edit", + Actions, + Text::key("bulkEdit.title"), + A::ToggleBulkEdit, + ) + .keywords(&["bulk edit", "selection", "edition groupee", "multi"]) + .hint(format!("{m}+E")), + ); + } + if input.personas_enabled { + commands.push( + PaletteCommand::new( + "action:personas", + Actions, + Text::key("titlebar.personas"), + A::OpenPersonas, + ) + .keywords(&["personas", "profiles", "profils"]) + .hint(format!("{m}+Shift+P")), + ); + } + if input.update_ready { + commands.push( + PaletteCommand::new( + "action:apply-update", + Actions, + Text::key("update.restartToApply"), + A::ApplyUpdate, + ) + .keywords(&["update", "mise a jour", "restart", "redemarrer"]), + ); + } + commands +} + +fn navigation_commands(input: &RegistryInput<'_>) -> Vec { + use PaletteSection::Navigation; + let m = mod_label(input.is_mac); + let mut commands: Vec = input + .enabled_platforms + .iter() + .enumerate() + .map(|(index, platform)| { + let mut command = PaletteCommand::new( + format!("nav:tab:{}", platform.id), + Navigation, + Text::with("palette.openTab", [("platform", platform.name.as_str())]), + PaletteAction::ChangeTab(platform.id.clone()), + ); + command.keywords = vec![platform.name.clone(), platform.id.clone()]; + command.hint = (index < 9).then(|| format!("{m}+{}", index + 1)); + command.accent = Some(platform.accent.clone()); + command.active = platform.id == input.active_tab; + command.disabled = input.unavailable_platform_ids.contains(&platform.id); + command + }) + .collect(); + for folder in input.current_folders { + let mut command = PaletteCommand::new( + format!("nav:folder:{}", folder.id), + Navigation, + Text::with("palette.openFolder", [("name", folder.name.as_str())]), + PaletteAction::OpenFolder(folder.id.clone()), + ); + command.keywords = vec![folder.name.clone(), "folder".into(), "dossier".into()]; + commands.push(command); + } + if input.current_folder_id.is_some() { + commands.push( + PaletteCommand::new( + "nav:parent", + Navigation, + Text::key("palette.backToParent"), + PaletteAction::NavigateToParent, + ) + .keywords(&["back", "parent", "retour", "dossier parent"]) + .hint("Alt+\u{2190}".to_string()), + ); + } + commands.push( + PaletteCommand::new( + "nav:settings", + Navigation, + Text::key("titlebar.settings"), + PaletteAction::ToggleSettings, + ) + .keywords(&[ + "settings", + "preferences", + "parametres", + "options", + "reglages", + ]) + .hint(format!("{m}+,")), + ); + commands +} + +/// `getCommands`: accounts, then actions, then navigation. +pub fn palette_commands(input: &RegistryInput<'_>) -> Vec { + let mut commands = account_commands(input); + commands.extend(action_commands(input)); + commands.extend(navigation_commands(input)); + commands +} + +// -------------------------------------------------------------- view + +/// `EMPTY_QUERY_ACCOUNT_LIMIT`. +pub const EMPTY_QUERY_ACCOUNT_LIMIT: usize = 6; + +/// A row of the list: a section header or a command (its index in +/// [`CommandPalette::rows`]' flat order and in the command list). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PaletteRow { + Header(PaletteSection), + Command { index: usize, command: usize }, +} + +/// Alphabetical tie-break. TS uses `localeCompare` in the UI locale; this +/// compares the folded titles, then the raw ones. +fn collate(a: &str, b: &str) -> Ordering { + fold_text(a).cmp(&fold_text(b)).then_with(|| a.cmp(b)) +} + +/// `CommandPalette.svelte` state: the query and the highlighted row. +#[derive(Debug, Clone, Default)] +pub struct CommandPalette { + query: String, + active_index: usize, + cache: FoldCache, +} + +impl CommandPalette { + pub fn new() -> Self { + Self::default() + } + + pub fn query(&self) -> &str { + &self.query + } + + /// Typing resets the highlight to the first row. + pub fn set_query(&mut self, query: impl Into) { + let query = query.into(); + if query != self.query { + self.query = query; + self.active_index = 0; + } + } + + pub fn active_index(&self) -> usize { + self.active_index + } + + /// `filtered`: idle, the first accounts then every other command; with a + /// query, the matches by score (accounts +4, actions +2), ties + /// alphabetical. Indices into `commands`. + pub fn filtered(&mut self, commands: &[PaletteCommand], locale: Locale) -> Vec { + let query = crate::js::trim(&self.query).to_string(); + if query.is_empty() { + let accounts = commands + .iter() + .enumerate() + .filter(|(_, command)| command.section == PaletteSection::Accounts) + .map(|(index, _)| index) + .take(EMPTY_QUERY_ACCOUNT_LIMIT); + let rest = commands + .iter() + .enumerate() + .filter(|(_, command)| command.section != PaletteSection::Accounts) + .map(|(index, _)| index); + return accounts.chain(rest).collect(); + } + let mut scored: Vec<(usize, i64, String)> = Vec::new(); + for (index, command) in commands.iter().enumerate() { + let title = command.title.resolve(locale); + let Some(score) = fuzzy_score_all(&mut self.cache, &query, &title, &command.keywords) + else { + continue; + }; + let bias = match command.section { + PaletteSection::Accounts => 4, + PaletteSection::Actions => 2, + PaletteSection::Navigation => 0, + }; + scored.push((index, score + bias, title)); + } + scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| collate(&a.2, &b.2))); + scored.into_iter().map(|(index, _, _)| index).collect() + } + + /// `rows`: section headers interleaved with their commands, in section + /// order. + pub fn rows(&mut self, commands: &[PaletteCommand], locale: Locale) -> Vec { + let filtered = self.filtered(commands, locale); + let mut rows = Vec::new(); + let mut index = 0; + for section in PaletteSection::ORDER { + let in_section: Vec = filtered + .iter() + .copied() + .filter(|&command| commands[command].section == section) + .collect(); + if in_section.is_empty() { + continue; + } + rows.push(PaletteRow::Header(section)); + for command in in_section { + rows.push(PaletteRow::Command { index, command }); + index += 1; + } + } + rows + } + + /// `flatCommands`: the commands in row order. + pub fn flat_commands(&mut self, commands: &[PaletteCommand], locale: Locale) -> Vec { + self.rows(commands, locale) + .into_iter() + .filter_map(|row| match row { + PaletteRow::Command { command, .. } => Some(command), + PaletteRow::Header(_) => None, + }) + .collect() + } + + /// `moveActive`: wraps around. + pub fn move_active(&mut self, delta: i64, count: usize) { + if count == 0 { + return; + } + let count = count as i64; + self.active_index = (((self.active_index as i64 + delta) % count + count) % count) as usize; + } + + /// Home with an empty query. + pub fn home(&mut self) { + if self.query.is_empty() { + self.active_index = 0; + } + } + + /// End with an empty query. + pub fn end(&mut self, count: usize) { + if self.query.is_empty() { + self.active_index = count.saturating_sub(1); + } + } + + /// Enter: the highlighted command's action, `None` when there is none or + /// it is disabled. The caller closes the palette first, then runs it. + pub fn enter(&mut self, commands: &[PaletteCommand], locale: Locale) -> Option { + let flat = self.flat_commands(commands, locale); + let command = &commands[*flat.get(self.active_index)?]; + run_command(command) + } +} + +/// `runCommand`: nothing for a disabled command. +pub fn run_command(command: &PaletteCommand) -> Option { + (!command.disabled).then(|| command.action.clone()) +} diff --git a/crates/accshift-app/src/actions/personas.rs b/crates/accshift-app/src/actions/personas.rs new file mode 100644 index 0000000..f1c9282 --- /dev/null +++ b/crates/accshift-app/src/actions/personas.rs @@ -0,0 +1,369 @@ +//! Personas: the saved list, the sequential switch over every assigned +//! platform, the panel and the confirmed switch with its toast. Ported from +//! `src/lib/app/usePersonas.svelte.ts` and +//! `src/lib/app/usePersonaSwitch.svelte.ts`. + +use super::dialogs::{AppDialogs, ConfirmConfig, ConfirmTicket}; +use super::AppEffect; +use crate::accounts::{Toast, ToastKind}; +use crate::backend::Backend; +use crate::i18n::TranslationValue; +use crate::jobs::{Jobs, Pending, Poll}; +use crate::personas::{Persona, PersonaInput, PersonaPatch}; +use crate::platforms::{PlatformAdapter, PlatformRegistry, RuntimeOs}; +use crate::settings::AppSettings; +use crate::stores::ClientStores; +use crate::text::Text; +use std::sync::Arc; + +/// One platform a persona switch could not land. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PersonaSwitchFailure { + pub platform_id: String, + pub error: String, +} + +/// `PersonaSwitchResult`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PersonaSwitchResult { + pub succeeded: Vec, + pub failed: Vec, +} + +/// One assignment with its adapter, resolved on the UI thread +/// (`ensurePlatformLoaded`). +#[derive(Clone)] +pub struct SwitchStep { + pub platform_id: String, + pub account_id: String, + pub adapter: Option>, +} + +/// The steps of a persona, in assignment order. +pub fn switch_steps(registry: &PlatformRegistry, persona: &Persona) -> Vec { + persona + .assignments + .iter() + .map(|assignment| SwitchStep { + platform_id: assignment.platform_id.clone(), + account_id: assignment.account_id.clone(), + adapter: registry.adapter(&assignment.platform_id), + }) + .collect() +} + +/// The loop of `switchToPersona`, blocking, for the job pool: one platform at +/// a time through its adapter (Steam switches by account name, not id). A +/// failure does not stop the others; a PIN lock stops everything, and `None` +/// when nothing had landed yet. +pub fn switch_to_persona_blocking( + backend: &dyn Backend, + steps: &[SwitchStep], + settings: &AppSettings, +) -> Option { + let mut result = PersonaSwitchResult::default(); + for step in steps { + let outcome = match &step.adapter { + None => Err(format!( + "Error: Platform adapter unavailable: {}", + step.platform_id + )), + Some(adapter) => switch_one(backend, adapter, step, settings), + }; + match outcome { + Ok(()) => result.succeeded.push(step.platform_id.clone()), + Err(error) => { + let pin_locked = error.starts_with("pin_locked:"); + if pin_locked && result.succeeded.is_empty() { + return None; + } + result.failed.push(PersonaSwitchFailure { + platform_id: step.platform_id.clone(), + error, + }); + if pin_locked { + break; + } + } + } + } + Some(result) +} + +fn switch_one( + backend: &dyn Backend, + adapter: &PlatformAdapter, + step: &SwitchStep, + settings: &AppSettings, +) -> Result<(), String> { + let accounts = adapter + .load_accounts(backend) + .map_err(|error| error.message)?; + let account = accounts + .iter() + .find(|account| account.id == step.account_id) + .ok_or_else(|| { + format!( + "Error: Persona account unavailable: {}/{}", + step.platform_id, step.account_id + ) + })?; + adapter + .switch_account(backend, account, settings) + .map_err(|error| error.message) +} + +/// `createPersonaController`. +pub struct PersonaController { + personas: Vec, + switching: Option<(Persona, Pending>)>, +} + +impl PersonaController { + pub fn new(stores: &ClientStores) -> Self { + Self { + personas: stores.personas().to_vec(), + switching: None, + } + } + + pub fn personas(&self) -> &[Persona] { + &self.personas + } + + /// `switchingPersonaId`. + pub fn switching_persona_id(&self) -> Option<&str> { + self.switching + .as_ref() + .map(|(persona, _)| persona.id.as_str()) + } + + /// `switching`: updates and regular switches wait for it. + pub fn switching(&self) -> bool { + self.switching.is_some() + } + + /// `refresh`. + pub fn refresh(&mut self, stores: &ClientStores) { + self.personas = stores.personas().to_vec(); + } + + /// `create`. + pub fn create(&mut self, stores: &mut ClientStores, input: PersonaInput) -> Persona { + let persona = stores.create_persona(input); + self.refresh(stores); + persona + } + + /// `update`. + pub fn update(&mut self, stores: &mut ClientStores, id: &str, patch: &PersonaPatch) { + stores.update_persona(id, patch); + self.refresh(stores); + } + + /// `remove`. + pub fn remove(&mut self, stores: &mut ClientStores, id: &str) { + stores.delete_persona(id); + self.refresh(stores); + } + + /// `switchToPersona`, started: false when a switch already runs or a + /// regular account switch holds the clients (`isBlocked`). + pub fn start_switch( + &mut self, + jobs: &Jobs, + registry: &PlatformRegistry, + settings: &AppSettings, + persona: Persona, + account_switching: bool, + ) -> bool { + if self.switching.is_some() || account_switching { + return false; + } + let steps = switch_steps(registry, &persona); + let settings = settings.clone(); + let pending = + jobs.run(move |backend| switch_to_persona_blocking(backend, &steps, &settings)); + self.switching = Some((persona, pending)); + true + } + + /// The finished switch, once: the persona and its result (`None` when the + /// PIN lock stopped it before anything landed). Clears the switching id. + pub fn poll_switch(&mut self) -> Option<(Persona, Option)> { + let (_, pending) = self.switching.as_mut()?; + match pending.poll() { + Poll::Waiting => None, + Poll::Ready(result) => { + let (persona, _) = self.switching.take()?; + Some((persona, result)) + } + Poll::Gone => { + let (persona, _) = self.switching.take()?; + Some((persona, None)) + } + } + } +} + +/// A persona slot of the panel. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PersonaPlatform { + pub id: String, + pub name: String, + pub accent: String, +} + +/// `personaPlatforms`: implemented, usable on this OS and enabled. +pub fn persona_platforms( + registry: &PlatformRegistry, + os: RuntimeOs, + settings: &AppSettings, +) -> Vec { + registry + .defs() + .iter() + .filter(|def| { + def.implemented + && def.supported_os.contains(&os) + && settings.enabled_platforms.contains(&def.id) + }) + .map(|def| PersonaPlatform { + id: def.id.clone(), + name: def.name.clone(), + accent: def.accent.clone(), + }) + .collect() +} + +/// `createPersonaSwitch`: the panel and the confirmed switch. +#[derive(Debug, Clone, Default)] +pub struct PersonaSwitch { + pub show_personas: bool, + awaiting_confirm: Option<(ConfirmTicket, Persona)>, +} + +impl PersonaSwitch { + pub fn new() -> Self { + Self::default() + } + + /// `openPersonas`. + pub fn open_personas(&mut self, personas_enabled: bool, settings_open: bool) -> Vec { + if !personas_enabled { + return Vec::new(); + } + let mut effects = Vec::new(); + if settings_open { + effects.push(AppEffect::CloseSettingsPanel); + } + effects.push(AppEffect::CloseBulkEdit); + self.show_personas = true; + effects + } + + /// The `$effect` that closes the panel when the feature is turned off. + pub fn sync_enabled(&mut self, personas_enabled: bool) { + if !personas_enabled && self.show_personas { + self.show_personas = false; + } + } + + /// `handleSwitchPersona`, first half: asks for confirmation (several game + /// clients relaunch). Nothing while any switch runs. + pub fn handle_switch_persona( + &mut self, + dialogs: &mut AppDialogs, + persona: &Persona, + personas_switching: bool, + account_switching: bool, + ) -> Vec { + let mut effects = Vec::new(); + if personas_switching || account_switching { + return effects; + } + let ticket = dialogs.request_confirm( + ConfirmConfig { + title: Text::key("personas.switchConfirmTitle"), + message: Text::with( + "personas.switchConfirmMessage", + [ + ("name", TranslationValue::from(persona.name.clone())), + ( + "count", + TranslationValue::from(persona.assignments.len() as f64), + ), + ], + ), + confirm_label: Some(Text::key("personas.switchConfirmAction")), + confirm_color: None, + }, + &mut effects, + ); + self.awaiting_confirm = Some((ticket, persona.clone())); + effects + } + + /// Second half, on [`AppEffect::ConfirmSettled`]: the persona to hand to + /// [`PersonaController::start_switch`] when the ticket is ours and was + /// accepted. + pub fn confirm_settled(&mut self, ticket: ConfirmTicket, allowed: bool) -> Option { + let (awaited, _) = self.awaiting_confirm.as_ref()?; + if *awaited != ticket { + return None; + } + let (_, persona) = self.awaiting_confirm.take()?; + allowed.then_some(persona) + } + + /// The end of `handleSwitchPersona`: the telemetry counters, then the + /// outcome toast (all landed, none, or which platforms failed). + pub fn finish_switch( + persona: &Persona, + result: Option<&PersonaSwitchResult>, + platforms: &[PersonaPlatform], + ) -> Vec { + let Some(result) = result else { + return Vec::new(); + }; + let mut effects = vec![AppEffect::TrackPersonaSwitch { + platforms: persona.assignments.len() as u64, + succeeded: result.succeeded.len() as u64, + }]; + let name = persona.name.clone(); + let toast = if result.failed.is_empty() { + Toast::new( + Text::with("personas.switched", [("name", name)]), + ToastKind::Success, + ) + } else if result.succeeded.is_empty() { + Toast::new( + Text::with("personas.switchFailed", [("name", name)]), + ToastKind::Error, + ) + } else { + let failed = result + .failed + .iter() + .map(|failure| { + platforms + .iter() + .find(|platform| platform.id == failure.platform_id) + .map_or(failure.platform_id.clone(), |platform| { + platform.name.clone() + }) + }) + .collect::>() + .join(", "); + Toast::new( + Text::with( + "personas.switchPartial", + [("name", name), ("failed", failed)], + ), + ToastKind::Default, + ) + }; + effects.push(AppEffect::Toast(toast)); + effects + } +} diff --git a/crates/accshift-app/src/actions/shell.rs b/crates/accshift-app/src/actions/shell.rs new file mode 100644 index 0000000..05dfe14 --- /dev/null +++ b/crates/accshift-app/src/actions/shell.rs @@ -0,0 +1,355 @@ +//! The handlers `src/App.svelte` defines inline over the action modules, and +//! the small derived values its markup reads. Each item cites its App.svelte +//! lines. +//! +//! The command palette and keyboard actions are dispatched here because their +//! TS closures live in App.svelte (`createCommandRegistry` deps, lines +//! 514-557; `createKeyboardBindings` deps, lines 578-631). + +use super::dialogs::AppDialogs; +use super::keyboard::{current_key_scope, KeyAction, KeyScope}; +use super::palette::PaletteAction; +use super::personas::PersonaSwitch; +use super::AppEffect; +use crate::accounts::{Toast, ToastKind}; +use crate::menu::account::color_label_key; +use crate::platforms::PlatformDef; +use crate::settings::AppSettings; +use crate::stores::{ClientStores, ViewMode}; +use crate::text::Text; +use crate::types::Cs2BridgeAccount; + +/// `copyToClipboard` (lines 674-683): the toast after the write, +/// `toast.copied` with the label, `toast.copyFailed` when it failed. +pub fn copy_result_toast(label: &str, write_ok: bool) -> Toast { + if write_ok { + Toast::new( + Text::with("toast.copied", [("label", label)]), + ToastKind::Success, + ) + } else { + Toast::new(Text::key("toast.copyFailed"), ToastKind::Error) + } +} + +/// `getColorLabel` of the dialogs deps (line 116). +pub fn color_label(preset_id: &str) -> Text { + Text::key(color_label_key(preset_id).unwrap_or(preset_id)) +} + +/// The other view mode (`viewMode === "grid" ? "list" : "grid"`, lines 554 +/// and 609). +pub fn toggled_view_mode(mode: ViewMode) -> ViewMode { + match mode { + ViewMode::Grid => ViewMode::List, + ViewMode::List => ViewMode::Grid, + } +} + +/// `handleViewModeChange` (lines 380-384): stores the mode; the grid +/// recomputes its padding. +pub fn view_mode_change(stores: &mut ClientStores, mode: ViewMode) -> Vec { + stores.set_view_mode(mode); + let mut effects = vec![AppEffect::ViewModeChanged(mode)]; + if mode == ViewMode::Grid { + effects.push(AppEffect::QueueGridPadding); + } + effects +} + +/// `handleNavigateToFolder` (lines 390-392). +pub fn navigate_to_folder(folder_id: Option) -> Vec { + vec![AppEffect::NavigateToFolder(folder_id)] +} + +/// `handleNavigateBack` (lines 394-397). +pub fn navigate_back(active_tab: &str) -> Vec { + vec![ + AppEffect::AddFlowCancelIfConflicting { + platform_id: active_tab.to_string(), + account_id: None, + }, + AppEffect::NavigateToParentFolder, + ] +} + +/// `isBulkEditToggleAllowed` (lines 570-576), also the palette's +/// `isBulkEditAvailable` (lines 527-530). +pub fn is_bulk_edit_toggle_allowed( + active_def: Option<&PlatformDef>, + active_tab_usable: bool, + settings_open: bool, +) -> bool { + active_def.is_some_and(|def| def.capabilities.bulk_edit) && active_tab_usable && !settings_open +} + +/// `handleApplyUpdate` (lines 654-657): never while a switch is mid-flight. +pub fn apply_update(account_switching: bool, persona_switching: bool) -> Vec { + if account_switching || persona_switching { + return Vec::new(); + } + vec![AppEffect::ApplyUpdate] +} + +/// What decides the key scope (`createKeyScopeResolver` deps, lines +/// 559-568). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ScopeFlags { + pub pin_locked: bool, + pub streamer_active: bool, + pub render_suspended: bool, + pub palette_open: bool, + pub onboarding_open: bool, + /// An input or confirm dialog. + pub has_dialog: bool, + pub has_context_menu: bool, + pub bulk_edit_mode: bool, + pub settings_open: bool, + pub personas_open: bool, +} + +/// `currentKeyScope` over the App.svelte state. +pub fn key_scope(flags: &ScopeFlags) -> KeyScope { + current_key_scope(|scope| match scope { + KeyScope::Locked => flags.pin_locked || flags.streamer_active || flags.render_suspended, + KeyScope::Palette => flags.palette_open, + KeyScope::Onboarding => flags.onboarding_open, + KeyScope::Dialog => flags.has_dialog, + KeyScope::ContextMenu => flags.has_context_menu, + KeyScope::BulkEdit => flags.bulk_edit_mode, + KeyScope::Settings => flags.settings_open, + KeyScope::Personas => flags.personas_open, + KeyScope::App => false, + }) +} + +/// `panelKey` (lines 293-299): remounts the workspace panel on a switch. +pub fn panel_key(settings_open: bool, personas_open: bool, active_tab: &str) -> String { + if settings_open { + "__settings__".to_string() + } else if personas_open { + "__personas__".to_string() + } else { + active_tab.to_string() + } +} + +/// `detectedPlatformDefs` (lines 302-307): `(id, name)` of the detected +/// platforms, in registry order. +pub fn detected_platform_defs(defs: &[PlatformDef], detected: &[String]) -> Vec<(String, String)> { + defs.iter() + .filter(|def| detected.contains(&def.id)) + .map(|def| (def.id.clone(), def.name.clone())) + .collect() +} + +/// `createCs2ExtensionSections` (lines 318-320): Steam's alone. +pub fn cs2_sections_for_tab( + active_tab: &str, + data: Option<&Cs2BridgeAccount>, +) -> Vec { + if active_tab == "steam" { + crate::platforms::steam::cs2::cs2_extension_sections(data) + } else { + Vec::new() + } +} + +/// `getCs2UsernameBadge` (lines 322-324). +pub fn cs2_badge_for_tab( + active_tab: &str, + data: Option<&Cs2BridgeAccount>, +) -> Option { + if active_tab == "steam" { + crate::platforms::steam::cs2::cs2_username_badge(data) + } else { + None + } +} + +/// `showUsernamesForActiveTab` (lines 664-666). +pub fn show_usernames_for_active_tab( + active_def: Option<&PlatformDef>, + settings: &AppSettings, +) -> bool { + active_def.is_some_and(|def| def.capabilities.account_usernames) + && settings.account_display.show_usernames +} + +/// `showLastLoginForActiveTab` (lines 667-669). +pub fn show_last_login_for_active_tab(active_tab: &str, settings: &AppSettings) -> bool { + settings + .account_display + .show_last_login_per_platform + .get(active_tab) + .copied() + .unwrap_or(false) +} + +/// `lastLoginUnknownKey` (lines 670-672). +pub fn last_login_unknown_key(active_def: Option<&PlatformDef>) -> &'static str { + active_def + .and_then(|def| def.capabilities.last_login_unknown_key) + .unwrap_or("time.unknown") +} + +/// `activePlatformName` (line 685). +pub fn active_platform_name(active_def: Option<&PlatformDef>, active_tab: &str) -> String { + active_def + .map(|def| def.name.as_str()) + .filter(|name| !name.is_empty()) + .unwrap_or(active_tab) + .to_string() +} + +/// The title bar's enabled states (lines 757-765). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct TitleBarInput { + pub active_tab_usable: bool, + pub adapter_loading: bool, + pub adding: bool, + pub add_flow_running: bool, + pub personas_open: bool, + pub settings_open: bool, + pub bulk_edit_capability: bool, + pub update_cta_disabled: bool, + pub account_switching: bool, + pub persona_switching: bool, +} + +/// The title bar flags App.svelte computes in its markup. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct TitleBarFlags { + pub can_refresh: bool, + pub can_add_account: bool, + pub show_bulk_edit: bool, + pub update_cta_disabled: bool, +} + +pub fn title_bar_flags(input: &TitleBarInput) -> TitleBarFlags { + TitleBarFlags { + can_refresh: input.active_tab_usable && !input.adapter_loading && !input.personas_open, + can_add_account: input.active_tab_usable + && !input.adapter_loading + && !input.adding + && !input.add_flow_running + && !input.personas_open, + show_bulk_edit: input.bulk_edit_capability + && !input.settings_open + && !input.personas_open + && input.active_tab_usable, + update_cta_disabled: input.update_cta_disabled + || input.account_switching + || input.persona_switching, + } +} + +/// `onTabChange` of the title bar (line 759): personas close first. +pub fn title_bar_tab_change(persona_switch: &mut PersonaSwitch, tab: &str) -> Vec { + persona_switch.show_personas = false; + vec![AppEffect::ChangeTab(tab.to_string())] +} + +/// `onOpenSettings` of the title bar (line 749). +pub fn title_bar_open_settings(persona_switch: &mut PersonaSwitch) -> Vec { + persona_switch.show_personas = false; + vec![AppEffect::ToggleSettingsPanel] +} + +/// The App.svelte state the palette and key closures act on. +pub struct ShellActions<'a> { + pub dialogs: &'a mut AppDialogs, + pub persona_switch: &'a mut PersonaSwitch, + pub stores: &'a mut ClientStores, + pub active_tab: &'a str, + pub view_mode: ViewMode, + pub personas_enabled: bool, + pub settings_open: bool, +} + +impl ShellActions<'_> { + fn toggle_view_mode(&mut self) -> Vec { + view_mode_change(self.stores, toggled_view_mode(self.view_mode)) + } + + fn open_personas(&mut self) -> Vec { + self.persona_switch + .open_personas(self.personas_enabled, self.settings_open) + } + + /// A palette command's `run` (lines 534-557). + pub fn run_palette_action(&mut self, action: &PaletteAction) -> Vec { + match action { + PaletteAction::SwitchToAccount(account) => vec![ + AppEffect::AddFlowCancelIfConflicting { + platform_id: self.active_tab.to_string(), + account_id: Some(account.id.clone()), + }, + AppEffect::SwitchAccount(account.clone()), + ], + PaletteAction::AddAccount => vec![AppEffect::AddAccount], + PaletteAction::RefreshAccounts => vec![AppEffect::RefreshAccounts], + PaletteAction::NewFolder => { + self.dialogs.open_new_folder_dialog(); + Vec::new() + } + PaletteAction::OpenFolder(folder_id) => navigate_to_folder(Some(folder_id.clone())), + PaletteAction::NavigateToParent => navigate_back(self.active_tab), + PaletteAction::ChangeTab(tab) => title_bar_tab_change(self.persona_switch, tab), + PaletteAction::ToggleSettings => title_bar_open_settings(self.persona_switch), + PaletteAction::OpenPersonas => self.open_personas(), + PaletteAction::ToggleBulkEdit => vec![AppEffect::ToggleBulkEdit], + PaletteAction::ToggleViewMode => self.toggle_view_mode(), + PaletteAction::ZoomReset => vec![AppEffect::ResetZoom], + PaletteAction::ApplyUpdate => vec![AppEffect::ApplyUpdate], + } + } + + /// A key binding's action (lines 578-631). `None` for what the egui + /// shell owns itself: the palette flag, the search field, its query and + /// the card focus. + pub fn run_key_action(&mut self, action: &KeyAction) -> Option> { + let effects = match action { + KeyAction::CloseContextMenu => { + self.dialogs.close_context_menu(); + Vec::new() + } + KeyAction::CloseInputDialog => { + self.dialogs.close_input_dialog(); + Vec::new() + } + KeyAction::CloseConfirmDialog => self.dialogs.close_confirm_dialog(), + KeyAction::ClosePersonas => { + self.persona_switch.show_personas = false; + Vec::new() + } + KeyAction::AddAccount => vec![AppEffect::AddAccount], + KeyAction::NewFolder => { + self.dialogs.open_new_folder_dialog(); + Vec::new() + } + KeyAction::Refresh => vec![AppEffect::RefreshAccounts], + KeyAction::ToggleBulkEdit => vec![AppEffect::ToggleBulkEdit], + KeyAction::ToggleSettingsPanel => vec![AppEffect::ToggleSettingsPanel], + KeyAction::OpenPersonas => self.open_personas(), + KeyAction::ToggleViewMode => self.toggle_view_mode(), + KeyAction::ChangeTab(tab) => vec![AppEffect::ChangeTab(tab.clone())], + KeyAction::ZoomIn => vec![AppEffect::ZoomIn], + KeyAction::ZoomOut => vec![AppEffect::ZoomOut], + KeyAction::ResetZoom => vec![AppEffect::ResetZoom], + KeyAction::NavigateBack => navigate_back(self.active_tab), + KeyAction::ToggleBulkEditAccount(id) => { + vec![AppEffect::BulkEditToggleAccount(id.clone())] + } + KeyAction::BulkEditSelectAll => vec![AppEffect::BulkEditSelectAll], + KeyAction::BulkEditDeselectAll => vec![AppEffect::BulkEditDeselectAll], + KeyAction::SetPaletteOpen(_) + | KeyAction::ClearSearchQuery + | KeyAction::BlurSearch + | KeyAction::ClearCardFocus + | KeyAction::FocusSearch + | KeyAction::FocusSettingsSearch => return None, + }; + Some(effects) + } +} diff --git a/crates/accshift-app/src/actions/toasts.rs b/crates/accshift-app/src/actions/toasts.rs new file mode 100644 index 0000000..30ada29 --- /dev/null +++ b/crates/accshift-app/src/actions/toasts.rs @@ -0,0 +1,257 @@ +//! The toast queue, ported from `src/lib/features/notifications/store.svelte.ts` +//! plus the countdown of `src/lib/features/notifications/Toast.svelte`. +//! +//! TS keeps the countdown in each toast component (`setTimeout`, paused while +//! hovered, restarted when the store bumps `resetKey`). Here the queue owns +//! it as a deadline per toast: the UI reports hovers, calls [`ToastQueue::tick`] +//! each frame and repaints at [`ToastQueue::next_deadline`]. + +use crate::accounts::{Toast, ToastKind}; +use crate::text::Text; +use std::time::{Duration, Instant}; + +/// `MAX_TOASTS`. +pub const MAX_TOASTS: usize = 5; +/// `DEFAULT_DURATION_MS`. +pub const DEFAULT_DURATION_MS: u64 = 3000; + +/// `ToastType`. TS has no "default" type: no `type` means `info`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ToastType { + #[default] + Info, + Success, + Error, +} + +impl From for ToastType { + fn from(kind: ToastKind) -> Self { + match kind { + ToastKind::Default | ToastKind::Info => ToastType::Info, + ToastKind::Success => ToastType::Success, + ToastKind::Error => ToastType::Error, + } + } +} + +/// `AddToastOptions.durationMs`: left out, a number, or `null`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DurationRequest { + /// `undefined`: the default timer. + #[default] + Default, + /// An explicit duration. + Ms(u64), + /// `null`: until dismissed. + Sticky, +} + +/// `ToastAction`: a button on the toast. The TS closure becomes an id the +/// app maps to what the button does. +#[derive(Debug, Clone, PartialEq)] +pub struct ToastAction { + pub label: Text, + pub id: String, +} + +/// `AddToastOptions`. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct AddToastOptions { + pub kind: ToastType, + pub duration: DurationRequest, + pub action: Option, +} + +impl AddToastOptions { + pub fn kind(kind: ToastType) -> Self { + Self { + kind, + ..Self::default() + } + } +} + +/// `resolveToastDuration`: how long a toast stays, `None` meaning until the +/// user dismisses it. An error never counts down, whatever was asked. +pub fn resolve_toast_duration(kind: ToastType, requested: DurationRequest) -> Option { + if kind == ToastType::Error { + return None; + } + match requested { + DurationRequest::Default => Some(DEFAULT_DURATION_MS), + DurationRequest::Ms(ms) => Some(ms), + DurationRequest::Sticky => None, + } +} + +/// The countdown state `Toast.svelte` keeps in its closure. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +struct Countdown { + remaining_ms: u64, + started_at: Option, + hovered: bool, +} + +/// `ToastMessage`. +#[derive(Debug, Clone, PartialEq)] +pub struct ToastEntry { + pub id: u64, + pub text: Text, + pub duration_ms: Option, + pub kind: ToastType, + pub action: Option, + /// Bumped when the same text is raised again; the countdown restarts. + pub reset_key: u32, + countdown: Countdown, +} + +impl ToastEntry { + /// `hasDuration`: a positive duration. `0` never counts down, as in TS. + fn has_duration(&self) -> bool { + self.duration_ms.is_some_and(|ms| ms > 0) + } + + /// The `$effect` on `resetKey` and `durationMs`: clear, then start again + /// unless hovered. + fn restart(&mut self, now: Instant) { + self.countdown.started_at = None; + if !self.has_duration() { + return; + } + self.countdown.remaining_ms = self.duration_ms.unwrap_or(0); + if !self.countdown.hovered { + self.countdown.started_at = Some(now); + } + } + + /// When the running countdown ends, `None` while paused or untimed. + pub fn deadline(&self) -> Option { + self.countdown + .started_at + .map(|start| start + Duration::from_millis(self.countdown.remaining_ms)) + } + + pub fn hovered(&self) -> bool { + self.countdown.hovered + } +} + +/// The toast store. +#[derive(Debug, Clone, Default)] +pub struct ToastQueue { + toasts: Vec, + next_id: u64, +} + +impl ToastQueue { + pub fn new() -> Self { + Self::default() + } + + /// `getToasts`, oldest first. + pub fn toasts(&self) -> &[ToastEntry] { + &self.toasts + } + + pub fn get(&self, id: u64) -> Option<&ToastEntry> { + self.toasts.iter().find(|toast| toast.id == id) + } + + /// `addToast`. The same text already on screen takes the new type, + /// duration and action (an absent action keeps the old one) and restarts + /// its countdown instead of stacking a duplicate. Beyond + /// [`MAX_TOASTS`] the oldest go. + pub fn add(&mut self, text: Text, options: AddToastOptions, now: Instant) -> u64 { + let duration_ms = resolve_toast_duration(options.kind, options.duration); + if let Some(existing) = self.toasts.iter_mut().find(|toast| toast.text == text) { + existing.kind = options.kind; + existing.duration_ms = duration_ms; + if options.action.is_some() { + existing.action = options.action; + } + existing.reset_key += 1; + existing.restart(now); + return existing.id; + } + self.next_id += 1; + let mut entry = ToastEntry { + id: self.next_id, + text, + duration_ms, + kind: options.kind, + action: options.action, + reset_key: 0, + countdown: Countdown::default(), + }; + entry.restart(now); + self.toasts.push(entry); + if self.toasts.len() > MAX_TOASTS { + let excess = self.toasts.len() - MAX_TOASTS; + self.toasts.drain(..excess); + } + self.next_id + } + + /// A toast the account or platform code asked for. + pub fn push(&mut self, toast: Toast, now: Instant) -> u64 { + self.add(toast.text, AddToastOptions::kind(toast.kind.into()), now) + } + + /// `removeToast`. + pub fn remove(&mut self, id: u64) { + self.toasts.retain(|toast| toast.id != id); + } + + /// `handleMouseEnter` / `handleMouseLeave`. Leaving with no time left + /// dismisses the toast; true when it did. + pub fn set_hovered(&mut self, id: u64, hovered: bool, now: Instant) -> bool { + let Some(toast) = self.toasts.iter_mut().find(|toast| toast.id == id) else { + return false; + }; + if hovered { + toast.countdown.hovered = true; + if let Some(start) = toast.countdown.started_at.take() { + let elapsed = now.saturating_duration_since(start).as_millis() as u64; + toast.countdown.remaining_ms = toast.countdown.remaining_ms.saturating_sub(elapsed); + } + return false; + } + toast.countdown.hovered = false; + if !toast.has_duration() { + return false; + } + if toast.countdown.remaining_ms == 0 { + self.remove(id); + return true; + } + toast.countdown.started_at = Some(now); + false + } + + /// `handleAction`: the action id to run; the toast goes either way. + pub fn activate_action(&mut self, id: u64) -> Option { + let action = self + .get(id) + .and_then(|toast| toast.action.as_ref()) + .map(|action| action.id.clone()); + self.remove(id); + action + } + + /// Removes every toast whose countdown ended; their ids. + pub fn tick(&mut self, now: Instant) -> Vec { + let done: Vec = self + .toasts + .iter() + .filter(|toast| toast.deadline().is_some_and(|deadline| deadline <= now)) + .map(|toast| toast.id) + .collect(); + self.toasts.retain(|toast| !done.contains(&toast.id)); + done + } + + /// The next countdown end, for the repaint. + pub fn next_deadline(&self) -> Option { + self.toasts.iter().filter_map(ToastEntry::deadline).min() + } +} diff --git a/crates/accshift-app/src/lib.rs b/crates/accshift-app/src/lib.rs index 9015203..feea629 100644 --- a/crates/accshift-app/src/lib.rs +++ b/crates/accshift-app/src/lib.rs @@ -15,6 +15,8 @@ //! and the settings refresh actions, driven through [`jobs`]. //! - [`display`]: folder navigation, search and the flat list of cards a grid //! draws. +//! - [`actions`]: dialogs, card actions, navigation, personas, the command +//! palette, the keyboard model and toasts, as state plus effects. //! - [`secure`]: the PIN, the inactivity blur, the window activity and the //! secure screen; [`streamer`]: streamer mode. //! - [`bulk_edit`], [`platform_add_flow`], [`platform_shell`] and @@ -22,6 +24,7 @@ //! shell state and the CS2 bridge queue. pub mod accounts; +pub mod actions; pub mod backend; pub mod bulk_edit; pub mod cs2_bridge; diff --git a/crates/accshift-app/tests/app_actions_dialogs.rs b/crates/accshift-app/tests/app_actions_dialogs.rs new file mode 100644 index 0000000..5ee9810 --- /dev/null +++ b/crates/accshift-app/tests/app_actions_dialogs.rs @@ -0,0 +1,631 @@ +//! The app dialogs against the mock backend's demo scenario: every input +//! and confirm dialog kind is opened, edited, validated, confirmed and +//! cancelled, plus `app/useAppDialogs.test.ts` (confirm replacement) and the +//! context menus. + +mod support; + +use accshift_app::accounts::ToastKind; +use accshift_app::actions::dialogs::{ + bulk_edit_result_toast, finish_rename_account, folder_path_label, rename_account_blocking, + AppDialogs, AppMenuCommand, AppMenuEntry, ConfirmConfig, ConfirmPurpose, ContextMenuTarget, + DialogContext, InputPurpose, MenuInput, +}; +use accshift_app::actions::{load_options, AppEffect}; +use accshift_app::folders::{FolderInfo, ItemRef}; +use accshift_app::menu::{MenuCommand, PlatformTask}; +use accshift_app::mock::MockBackend; +use accshift_app::platforms::{ + PlatformAccount, PlatformAdapter, PlatformBulkEditResult, PlatformRegistry, +}; +use accshift_app::storage; +use accshift_app::stores::{ClientStores, StoreContext, MAX_NOTE_LENGTH}; +use accshift_app::text::Text; +use accshift_app::BackendError; +use std::sync::Arc; + +const SMURFS: &str = "demo-folder-smurfs"; +const STEAM: &str = "steam"; + +struct Demo { + backend: MockBackend, + stores: ClientStores, + dialogs: AppDialogs, +} + +impl Demo { + fn load() -> Self { + let backend = support::mock("demo"); + let (storage, report) = storage::initialize(&backend, None, None, true); + assert!(report.load_error.is_none(), "{:?}", report.load_error); + Self { + backend, + stores: ClientStores::new(storage, StoreContext::shipped()), + dialogs: AppDialogs::new(), + } + } + + fn ctx<'a>( + &'a mut self, + tab: &'a str, + folder: Option<&'a str>, + ) -> (&'a mut AppDialogs, DialogContext<'a>) { + ( + &mut self.dialogs, + DialogContext { + stores: &mut self.stores, + active_tab: tab, + current_folder_id: folder, + }, + ) + } + + fn submit(&mut self, tab: &str, folder: Option<&str>) -> Vec { + let (dialogs, mut ctx) = self.ctx(tab, folder); + dialogs.submit_input(&mut ctx) + } + + fn confirm(&mut self) -> Vec { + let (dialogs, mut ctx) = self.ctx(STEAM, None); + dialogs.confirm_current(&mut ctx) + } + + fn activate(&mut self, tab: &str, command: &AppMenuCommand) -> Vec { + let (dialogs, mut ctx) = self.ctx(tab, None); + dialogs.activate(command, &mut ctx) + } + + fn folder(&self, id: &str) -> FolderInfo { + self.stores.folders().folder(id).expect(id).clone() + } + + fn folder_names(&self, tab: &str) -> Vec { + self.stores + .folders() + .list_folders(tab) + .into_iter() + .map(|folder| folder.name.clone()) + .collect() + } + + fn menu(&self, adapter: Option<&PlatformAdapter>, tab: &str) -> Vec { + self.dialogs.context_menu_items(&MenuInput { + stores: &self.stores, + adapter, + active_tab: tab, + active_tab_usable: true, + current_account_id: None, + }) + } +} + +fn find<'a>(entries: &'a [AppMenuEntry], id: &str) -> &'a AppMenuEntry { + entries + .iter() + .find(|entry| entry.id() == Some(id)) + .unwrap_or_else(|| panic!("no entry {id} in {:?}", ids(entries))) +} + +fn ids(entries: &[AppMenuEntry]) -> Vec<&str> { + entries + .iter() + .map(|entry| entry.id().unwrap_or("|")) + .collect() +} + +fn gog_account() -> PlatformAccount { + PlatformAccount { + id: "g1".into(), + display_name: "Gamer".into(), + username: "g1".into(), + last_login_at_sec: None, + } +} + +// Input dialog: new folder. + +#[test] +fn new_folder_dialog_opens_validates_confirms_and_cancels() { + let mut demo = Demo::load(); + demo.dialogs.open_background_context_menu(10.0, 20.0); + let menu = demo.menu(None, STEAM); + assert_eq!(ids(&menu), ["background.newFolder"]); + let steam = PlatformAdapter::shipped(STEAM).unwrap(); + assert_eq!( + ids(&demo.menu(Some(&steam), STEAM)), + ["background.refresh", "background.newFolder"] + ); + let command = find(&menu, "background.newFolder") + .command() + .unwrap() + .clone(); + assert!(demo.activate(STEAM, &command).is_empty()); + assert!(demo.dialogs.context_menu().is_none()); + + // Open: empty, not submittable. + let dialog = demo.dialogs.input_dialog().unwrap(); + assert_eq!(dialog.title, Text::key("dialog.newFolderTitle")); + assert_eq!(dialog.purpose, InputPurpose::NewFolder); + assert!(!dialog.can_submit()); + + // Validate: blank stays refused, the dialog stays up. + demo.dialogs.input_dialog_mut().unwrap().set_value(" "); + assert!(demo.submit(STEAM, None).is_empty()); + assert!(demo.dialogs.input_dialog().is_some()); + + // Edit and confirm: trimmed, created in the current folder. + demo.dialogs + .input_dialog_mut() + .unwrap() + .set_value(" Mains "); + assert_eq!( + demo.submit(STEAM, Some(SMURFS)), + vec![AppEffect::RefreshCurrentItems] + ); + assert!(demo.dialogs.input_dialog().is_none()); + let created = demo + .stores + .folders() + .list_folders(STEAM) + .into_iter() + .find(|folder| folder.name == "Mains") + .expect("created") + .clone(); + assert_eq!(created.parent_id.as_deref(), Some(SMURFS)); + assert_eq!( + folder_path_label(&demo.stores, &created.id), + format!("{} / Mains", demo.folder(SMURFS).name) + ); + + // Cancel: nothing written. + let before = demo.folder_names(STEAM); + demo.dialogs.open_new_folder_dialog(); + demo.dialogs.input_dialog_mut().unwrap().set_value("Nope"); + demo.dialogs.close_input_dialog(); + assert!(demo.dialogs.input_dialog().is_none()); + assert_eq!(demo.folder_names(STEAM), before); +} + +// Input dialog: rename folder. + +#[test] +fn rename_folder_dialog_opens_with_the_name_and_renames_on_confirm() { + let mut demo = Demo::load(); + let folder = demo.folder(SMURFS); + demo.dialogs + .open_folder_context_menu(5.0, 5.0, folder.clone()); + let menu = demo.menu(None, STEAM); + assert_eq!( + ids(&menu), + ["folder.rename", "folder.color", "folder.delete"] + ); + let rename = find(&menu, "folder.rename").command().unwrap().clone(); + demo.activate(STEAM, &rename); + + let dialog = demo.dialogs.input_dialog().unwrap(); + assert_eq!(dialog.value(), folder.name); + assert_eq!( + dialog.purpose, + InputPurpose::RenameFolder { + folder_id: SMURFS.into() + } + ); + demo.dialogs.input_dialog_mut().unwrap().set_value(""); + assert!(demo.submit(STEAM, None).is_empty()); + + demo.dialogs.input_dialog_mut().unwrap().set_value(" Alts "); + assert_eq!( + demo.submit(STEAM, None), + vec![AppEffect::RefreshCurrentItems] + ); + assert_eq!(demo.folder(SMURFS).name, "Alts"); + + // Cancel keeps the name. + demo.dialogs.open_rename_folder_dialog(&demo.folder(SMURFS)); + demo.dialogs.input_dialog_mut().unwrap().set_value("Other"); + demo.dialogs.close_input_dialog(); + assert_eq!(demo.folder(SMURFS).name, "Alts"); +} + +// Input dialog: card note. + +#[test] +fn card_note_dialog_counts_clamps_sets_and_clears() { + let mut demo = Demo::load(); + let command = AppMenuCommand::Platform(MenuCommand::EditNote { + account_id: "a1".into(), + initial_note: String::new(), + }); + demo.activate(STEAM, &command); + let dialog = demo.dialogs.input_dialog().unwrap(); + assert!(dialog.allow_empty); + assert_eq!(dialog.maxlength, Some(MAX_NOTE_LENGTH)); + assert!(dialog.can_submit()); + assert_eq!(dialog.char_count(), None); + + // Typing past the limit is refused; the counter shows near it. + let long = "x".repeat(MAX_NOTE_LENGTH + 20); + demo.dialogs.input_dialog_mut().unwrap().set_value(long); + let dialog = demo.dialogs.input_dialog().unwrap(); + assert_eq!(dialog.value().len(), MAX_NOTE_LENGTH); + assert_eq!( + dialog.char_count(), + Some((MAX_NOTE_LENGTH, MAX_NOTE_LENGTH)) + ); + + demo.dialogs + .input_dialog_mut() + .unwrap() + .set_value("main account"); + assert_eq!(demo.submit(STEAM, None), vec![AppEffect::CardNotesChanged]); + assert_eq!(demo.stores.account_card_note("a1"), "main account"); + + // A blank note clears it. + demo.activate( + STEAM, + &AppMenuCommand::Platform(MenuCommand::EditNote { + account_id: "a1".into(), + initial_note: "main account".into(), + }), + ); + assert_eq!(demo.dialogs.input_dialog().unwrap().value(), "main account"); + demo.dialogs.input_dialog_mut().unwrap().set_value(" "); + assert_eq!(demo.submit(STEAM, None), vec![AppEffect::CardNotesChanged]); + assert_eq!(demo.stores.account_card_note("a1"), ""); + + // Cancel writes nothing. + demo.activate(STEAM, &command); + demo.dialogs.input_dialog_mut().unwrap().set_value("draft"); + demo.dialogs.close_input_dialog(); + assert_eq!(demo.stores.account_card_note("a1"), ""); +} + +// Input dialog: a platform menu's rename, run on the mock. + +#[test] +fn platform_input_dialog_runs_the_adapter_task_on_confirm() { + let mut demo = Demo::load(); + let gog = PlatformAdapter::shipped("gog").unwrap(); + demo.dialogs + .open_account_context_menu(1.0, 1.0, gog_account()); + let menu = demo.menu(Some(&gog), "gog"); + let rename = find(&menu, "platform.rename.g1").command().unwrap().clone(); + demo.activate("gog", &rename); + let dialog = demo.dialogs.input_dialog().unwrap(); + assert!(matches!(dialog.purpose, InputPurpose::PlatformInput(_))); + assert_eq!(dialog.value(), "Gamer"); + + demo.dialogs + .input_dialog_mut() + .unwrap() + .set_value("Renamed"); + let effects = demo.submit("gog", None); + let task = PlatformTask::SetAccountLabel { + platform_id: "gog".into(), + account_id: "g1".into(), + label: "Renamed".into(), + }; + assert_eq!(effects, vec![AppEffect::RunPlatformTask(task.clone())]); + let settings = demo.stores.settings().clone(); + let outcome = gog.run_task(&task, &demo.backend, &settings); + assert!(!outcome.effects.is_empty()); + + // Cancel runs nothing. + demo.activate("gog", &rename); + demo.dialogs.close_input_dialog(); + assert!(demo.dialogs.input_dialog().is_none()); +} + +// Input dialog: F2 rename and the rename after an add. + +#[test] +fn rename_account_dialogs_label_through_the_adapter() { + let mut demo = Demo::load(); + let gog = Arc::new(PlatformAdapter::shipped("gog").unwrap()); + let steam = PlatformAdapter::shipped(STEAM).unwrap(); + // Steam cannot label accounts: no dialog. + demo.dialogs + .open_rename_account_dialog(Some(&steam), &gog_account()); + assert!(demo.dialogs.input_dialog().is_none()); + + demo.dialogs + .open_rename_account_dialog(Some(&gog), &gog_account()); + assert!(demo.dialogs.input_dialog().unwrap().can_submit()); + demo.dialogs.input_dialog_mut().unwrap().set_value("Main"); + let effects = demo.submit("gog", None); + assert_eq!( + effects, + vec![AppEffect::RenameAccount { + platform_id: "gog".into(), + account_id: "g1".into(), + label: "Main".into() + }] + ); + let result = rename_account_blocking(&demo.backend, &gog, "g1", "Main"); + assert!(result.is_ok(), "{result:?}"); + assert_eq!( + finish_rename_account(result), + vec![AppEffect::LoadAccounts(load_options( + true, false, false, false + ))] + ); + let failed = finish_rename_account(Err(BackendError::new("nope"))); + let [AppEffect::Toast(toast)] = failed.as_slice() else { + panic!("{failed:?}"); + }; + assert_eq!( + toast.text, + Text::with("toast.renameAccountFailed", [("error", "nope")]) + ); + assert_eq!(toast.kind, ToastKind::Error); + + // After an add: prefilled with the detected name, blank keeps it. + let registry = PlatformRegistry::shipped(); + demo.dialogs + .prompt_rename_new_account(®istry, STEAM, "s1", Some("x")); + assert!(demo.dialogs.input_dialog().is_none()); + demo.dialogs + .prompt_rename_new_account(®istry, "gog", "g2", Some(" Detected ")); + assert_eq!(demo.dialogs.input_dialog().unwrap().value(), "Detected"); + demo.dialogs.input_dialog_mut().unwrap().set_value(""); + assert!(demo.submit("gog", None).is_empty()); + assert!(demo.dialogs.input_dialog().is_none()); + + demo.dialogs + .prompt_rename_new_account(®istry, "gog", "g2", None); + demo.dialogs.close_input_dialog(); + assert!(demo.dialogs.input_dialog().is_none()); +} + +// Confirm dialog: delete folder. + +#[test] +fn delete_folder_confirm_deletes_on_confirm_and_keeps_on_cancel() { + let mut demo = Demo::load(); + let folder = demo.folder(SMURFS); + demo.activate(STEAM, &AppMenuCommand::DeleteFolder(folder.clone())); + let dialog = demo.dialogs.confirm_dialog().unwrap(); + assert_eq!( + dialog.title, + Text::with("dialog.deleteFolderTitle", [("name", folder.name.as_str())]) + ); + assert_eq!( + dialog.confirm_label(), + Text::key("context.menu.deleteFolder") + ); + + // Cancel: the folder stays. + assert!(demo.dialogs.close_confirm_dialog().is_empty()); + assert!(demo.stores.folders().folder(SMURFS).is_some()); + + demo.activate(STEAM, &AppMenuCommand::DeleteFolder(folder)); + assert_eq!(demo.confirm(), vec![AppEffect::RefreshCurrentItems]); + assert!(demo.stores.folders().folder(SMURFS).is_none()); + assert!(demo.dialogs.confirm_dialog().is_none()); +} + +// Confirm dialog: a platform menu's confirm. + +#[test] +fn platform_confirm_runs_its_task_on_confirm_only() { + let mut demo = Demo::load(); + let gog = PlatformAdapter::shipped("gog").unwrap(); + demo.dialogs + .open_account_context_menu(1.0, 1.0, gog_account()); + let menu = demo.menu(Some(&gog), "gog"); + let forget = find(&menu, "gog.forget.g1").command().unwrap().clone(); + demo.activate("gog", &forget); + let dialog = demo.dialogs.confirm_dialog().unwrap().clone(); + let ConfirmPurpose::PlatformTask(task) = dialog.purpose else { + panic!("{:?}", dialog.purpose); + }; + assert!(demo.dialogs.close_confirm_dialog().is_empty()); + + demo.activate("gog", &forget); + assert_eq!(demo.confirm(), vec![AppEffect::RunPlatformTask(task)]); +} + +// requestConfirm (useAppDialogs.test.ts). + +#[test] +fn request_confirm_settles_false_when_another_confirm_replaces_it() { + let mut demo = Demo::load(); + let mut effects = Vec::new(); + let ticket = demo.dialogs.request_confirm( + ConfirmConfig { + title: Text::raw("switch"), + message: Text::raw("sure?"), + confirm_label: None, + confirm_color: None, + }, + &mut effects, + ); + assert!(effects.is_empty()); + assert_eq!( + demo.dialogs.confirm_dialog().unwrap().confirm_label(), + Text::key("common.confirm") + ); + let replaced = demo.activate(STEAM, &AppMenuCommand::DeleteFolder(demo.folder(SMURFS))); + assert_eq!( + replaced, + vec![AppEffect::ConfirmSettled { + ticket, + allowed: false + }] + ); +} + +#[test] +fn confirming_the_replacing_dialog_runs_it_and_never_accepts_the_old_request() { + let mut demo = Demo::load(); + let mut effects = Vec::new(); + demo.dialogs.request_confirm( + ConfirmConfig { + title: Text::raw("switch"), + message: Text::raw("sure?"), + confirm_label: None, + confirm_color: None, + }, + &mut effects, + ); + let mut settled = demo.activate(STEAM, &AppMenuCommand::DeleteFolder(demo.folder(SMURFS))); + settled.extend(demo.confirm()); + assert!(demo.stores.folders().folder(SMURFS).is_none()); + let answers: Vec = settled + .iter() + .filter_map(|effect| match effect { + AppEffect::ConfirmSettled { allowed, .. } => Some(*allowed), + _ => None, + }) + .collect(); + assert_eq!(answers, vec![false]); +} + +#[test] +fn request_confirm_resolves_true_on_its_own_dialog_and_false_on_cancel() { + let mut demo = Demo::load(); + let config = ConfirmConfig { + title: Text::raw("switch"), + message: Text::raw("sure?"), + confirm_label: Some(Text::key("personas.switchConfirmAction")), + confirm_color: None, + }; + let mut effects = Vec::new(); + let ticket = demo.dialogs.request_confirm(config.clone(), &mut effects); + assert_eq!(demo.dialogs.pending_confirm(), Some(ticket)); + assert_eq!( + demo.confirm(), + vec![AppEffect::ConfirmSettled { + ticket, + allowed: true + }] + ); + let second = demo.dialogs.request_confirm(config, &mut effects); + assert_ne!(second, ticket); + assert_eq!( + demo.dialogs.close_confirm_dialog(), + vec![AppEffect::ConfirmSettled { + ticket: second, + allowed: false + }] + ); + assert_eq!(demo.dialogs.pending_confirm(), None); +} + +// Context menus. + +#[test] +fn context_menu_commands_write_the_stores() { + let mut demo = Demo::load(); + let folder = demo.folder(SMURFS); + demo.dialogs.open_folder_context_menu(0.0, 0.0, folder); + let menu = demo.menu(None, STEAM); + let AppMenuEntry::Swatches { swatches, .. } = find(&menu, "folder.color") else { + panic!("swatches"); + }; + let swatch = swatches.last().unwrap().clone(); + assert_eq!( + demo.activate(STEAM, &swatch.command), + vec![AppEffect::CardColorsChanged] + ); + assert_eq!(demo.stores.folder_card_color(SMURFS), swatch.color); + + let account = demo + .stores + .folders() + .list_folders(STEAM) + .iter() + .find(|f| f.id == SMURFS) + .map(|f| f.id.clone()) + .unwrap(); + let mv = AppMenuCommand::Platform(MenuCommand::MoveToFolder { + account_id: "a-moved".into(), + folder_id: Some(account.clone()), + }); + assert_eq!( + demo.activate(STEAM, &mv), + vec![AppEffect::RefreshCurrentItems] + ); + assert_eq!( + demo.stores + .folders() + .find_item_folder_id(&ItemRef::account("a-moved"), STEAM), + Some(SMURFS) + ); + + let color = AppMenuCommand::Platform(MenuCommand::SetCardColor { + account_id: "a1".into(), + color: swatch.color.clone(), + }); + assert_eq!( + demo.activate(STEAM, &color), + vec![AppEffect::CardColorsChanged] + ); + assert_eq!(demo.stores.account_card_color("a1"), swatch.color); + + let copy = AppMenuCommand::Platform(MenuCommand::Copy { + value: "76561198000000001".into(), + label: Text::key("platform.steamId"), + }); + assert_eq!( + demo.activate(STEAM, ©), + vec![AppEffect::CopyToClipboard { + text: "76561198000000001".into(), + label: Text::key("platform.steamId") + }] + ); + assert_eq!( + demo.activate(STEAM, &AppMenuCommand::RefreshAccounts), + vec![AppEffect::LoadAccounts(load_options( + false, true, false, true + ))] + ); + + demo.dialogs.open_custom_context_menu( + 0.0, + 0.0, + vec![AppMenuEntry::item( + "persona.edit", + Text::key("personas.edit"), + AppMenuCommand::Custom("persona.edit".into()), + )], + ); + assert!(matches!( + demo.dialogs.context_menu().unwrap().target, + ContextMenuTarget::Custom(_) + )); + let menu = demo.menu(None, STEAM); + let custom = find(&menu, "persona.edit").command().unwrap().clone(); + assert_eq!( + demo.activate(STEAM, &custom), + vec![AppEffect::CustomMenu("persona.edit".into())] + ); +} + +#[test] +fn bulk_edit_result_picks_success_partial_or_failure() { + let ok = bulk_edit_result_toast(&PlatformBulkEditResult { + succeeded: 3, + failed: vec![], + }); + assert_eq!( + ok.text, + Text::with("bulkEdit.toastSuccess", [("count", 3.0)]) + ); + let partial = bulk_edit_result_toast(&PlatformBulkEditResult { + succeeded: 2, + failed: vec!["x".into()], + }); + assert_eq!( + partial.text, + Text::with( + "bulkEdit.toastPartial", + [("succeeded", 2.0), ("failed", 1.0)] + ) + ); + let failed = bulk_edit_result_toast(&PlatformBulkEditResult { + succeeded: 0, + failed: vec!["x".into()], + }); + assert_eq!(failed.kind, ToastKind::Error); +} diff --git a/crates/accshift-app/tests/app_actions_keyboard.rs b/crates/accshift-app/tests/app_actions_keyboard.rs new file mode 100644 index 0000000..627108a --- /dev/null +++ b/crates/accshift-app/tests/app_actions_keyboard.rs @@ -0,0 +1,875 @@ +//! The keyboard model: `shared/keyboard/controller.test.ts`, +//! `app/keyboardBindings.test.ts` and `app/tabCycle.test.ts`, then every +//! binding of the app table through the resolver in every scope. + +use accshift_app::actions::keyboard::{ + app_bindings, current_key_scope, parse_combo, pick_cycled_tab, Binding, Direction, KeyAction, + KeyCommand, KeyEvent, KeyHost, KeyOutcome, KeyResult, KeyScope, KeyboardController, Scopes, + CARD_NAV_SCOPES, LAYER_ORDER, +}; +use accshift_app::actions::shell::{key_scope, ScopeFlags}; +use accshift_app::folders::ItemRef; +use std::collections::BTreeSet; + +/// A host that records every call, the TS `setup()` deps. +struct Host { + folder: bool, + active_tab: String, + enabled: Vec, + unavailable: BTreeSet, + input_dialog: bool, + search_focused: bool, + focused: Option, + owns_key: bool, + moves: bool, + bulk_allowed: bool, + drag: bool, + personas_escape: Vec, + activate: bool, + rename: bool, + context_menu: bool, + actions: Vec, + activated: usize, + renamed: usize, + context_menus: usize, + moved: Vec, +} + +impl Default for Host { + fn default() -> Self { + Self { + folder: false, + active_tab: "steam".into(), + enabled: vec!["steam".into(), "riot".into(), "epic".into()], + unavailable: BTreeSet::new(), + input_dialog: false, + search_focused: false, + focused: None, + owns_key: true, + moves: true, + bulk_allowed: true, + drag: false, + personas_escape: Vec::new(), + activate: true, + rename: true, + context_menu: true, + actions: Vec::new(), + activated: 0, + renamed: 0, + context_menus: 0, + moved: Vec::new(), + } + } +} + +impl KeyHost for Host { + fn in_folder(&self) -> bool { + self.folder + } + fn enabled_platform_ids(&self) -> Vec { + self.enabled.clone() + } + fn is_platform_unavailable(&self, platform_id: &str) -> bool { + self.unavailable.contains(platform_id) + } + fn active_tab(&self) -> String { + self.active_tab.clone() + } + fn has_input_dialog(&self) -> bool { + self.input_dialog + } + fn search_input_focused(&self) -> bool { + self.search_focused + } + fn focused_item(&self) -> Option { + self.focused.clone() + } + fn card_focus_owns_activation_key(&self, _event: &KeyEvent) -> bool { + self.owns_key + } + fn is_bulk_edit_toggle_allowed(&self) -> bool { + self.bulk_allowed + } + fn cancel_drag_from_escape(&mut self) -> bool { + self.drag + } + fn personas_panel_escape(&mut self) -> bool { + if self.personas_escape.is_empty() { + false + } else { + self.personas_escape.remove(0) + } + } + fn card_focus_move(&mut self, direction: Direction) -> bool { + self.moved.push(direction); + self.moves + } + fn activate_focused_card(&mut self) -> bool { + self.activated += 1; + self.activate + } + fn rename_focused_card(&mut self) -> bool { + self.renamed += 1; + self.rename + } + fn open_focused_card_context_menu(&mut self) -> bool { + self.context_menus += 1; + self.context_menu + } + fn act(&mut self, action: KeyAction) { + match &action { + KeyAction::ClearCardFocus => self.focused = None, + KeyAction::BlurSearch => self.search_focused = false, + _ => {} + } + self.actions.push(action); + } +} + +impl Host { + fn count(&self, action: &KeyAction) -> usize { + self.actions.iter().filter(|a| *a == action).count() + } + fn last_tab(&self) -> Option<&str> { + self.actions.iter().rev().find_map(|a| match a { + KeyAction::ChangeTab(tab) => Some(tab.as_str()), + _ => None, + }) + } +} + +fn press(keyboard: &KeyboardController, host: &mut Host, scope: KeyScope, event: KeyEvent) -> bool { + keyboard.handle(&event, scope, false, host).is_handled() +} + +fn key(k: &str) -> KeyEvent { + KeyEvent::new(k) +} + +fn binding(combo: &str, scopes: Scopes, command: KeyCommand) -> Binding { + Binding { + combo: combo.into(), + scopes, + allow_in_input: false, + skip_in_input: false, + prevent_default: true, + command, + } +} + +// controller.test.ts + +#[test] +fn controller_handles_and_consumes_a_binding_that_takes_the_key() { + let keyboard = KeyboardController::new(vec![binding( + "escape", + Scopes::Only(vec![KeyScope::App]), + KeyCommand::Swallow, + )]); + let outcome = keyboard.handle(&key("Escape"), KeyScope::App, false, &mut Host::default()); + assert_eq!( + outcome, + KeyOutcome::Handled { + index: 0, + consume: true + } + ); +} + +#[test] +fn controller_stops_at_the_first_binding_that_takes_the_key() { + let keyboard = KeyboardController::new(vec![ + binding( + "escape", + Scopes::Only(vec![KeyScope::App]), + KeyCommand::ClosePalette, + ), + binding( + "escape", + Scopes::Only(vec![KeyScope::App]), + KeyCommand::AddAccount, + ), + ]); + let mut host = Host::default(); + assert!(press(&keyboard, &mut host, KeyScope::App, key("Escape"))); + assert_eq!(host.actions, vec![KeyAction::SetPaletteOpen(false)]); +} + +#[test] +fn controller_falls_through_on_pass_and_leaves_the_event_alone() { + let keyboard = KeyboardController::new(vec![ + binding( + "escape", + Scopes::Only(vec![KeyScope::App]), + KeyCommand::Pass, + ), + binding( + "escape", + Scopes::Only(vec![KeyScope::App]), + KeyCommand::Pass, + ), + ]); + let outcome = keyboard.handle(&key("Escape"), KeyScope::App, false, &mut Host::default()); + assert_eq!(outcome, KeyOutcome::Unhandled); +} + +#[test] +fn controller_runs_modifier_combos_in_a_text_field_unless_the_binding_opts_out() { + let mut select_all = binding( + "mod+a", + Scopes::Only(vec![KeyScope::BulkEdit]), + KeyCommand::BulkSelectAll, + ); + select_all.skip_in_input = true; + let keyboard = KeyboardController::new(vec![ + select_all, + binding("mod+plus", Scopes::All, KeyCommand::ZoomIn), + ]); + let mut host = Host::default(); + assert!(!press( + &keyboard, + &mut host, + KeyScope::BulkEdit, + key("a").ctrl().in_input() + )); + assert!(host.actions.is_empty()); + assert!(press( + &keyboard, + &mut host, + KeyScope::BulkEdit, + key("+").ctrl().code("Equal").in_input() + )); + assert_eq!(host.actions, vec![KeyAction::ZoomIn]); +} + +#[test] +fn controller_runs_skip_in_input_bindings_outside_a_field() { + let mut select_all = binding( + "mod+a", + Scopes::Only(vec![KeyScope::BulkEdit]), + KeyCommand::BulkSelectAll, + ); + select_all.skip_in_input = true; + let keyboard = KeyboardController::new(vec![select_all]); + let mut host = Host::default(); + assert!(press( + &keyboard, + &mut host, + KeyScope::BulkEdit, + key("a").ctrl() + )); + assert_eq!(host.actions, vec![KeyAction::BulkEditSelectAll]); +} + +#[test] +fn controller_keeps_single_keys_out_of_text_fields_unless_allowed() { + let keyboard = KeyboardController::new(vec![binding("n", Scopes::All, KeyCommand::AddAccount)]); + let mut host = Host::default(); + assert!(!press( + &keyboard, + &mut host, + KeyScope::App, + key("n").in_input() + )); + assert!(press(&keyboard, &mut host, KeyScope::App, key("N"))); +} + +#[test] +fn controller_reads_mod_as_cmd_on_macos_and_refuses_the_stray_modifier() { + let keyboard = KeyboardController::new(vec![ + binding("mod+k", Scopes::All, KeyCommand::ClosePalette), + binding("escape", Scopes::All, KeyCommand::Swallow), + ]); + let mut host = Host::default(); + assert!(keyboard + .handle(&key("k").meta(), KeyScope::App, true, &mut host) + .is_handled()); + assert!(!keyboard + .handle(&key("k").ctrl(), KeyScope::App, true, &mut host) + .is_handled()); + assert!(!keyboard + .handle(&key("Escape").ctrl(), KeyScope::App, true, &mut host) + .is_handled()); + assert!(!keyboard + .handle(&key("Escape").meta(), KeyScope::App, false, &mut host) + .is_handled()); +} + +// tabCycle.test.ts + +fn tabs() -> Vec { + vec!["steam".into(), "riot".into(), "epic".into()] +} + +#[test] +fn tab_cycle_steps_forward_and_backward_with_wrap_around() { + let usable = tabs(); + assert_eq!( + pick_cycled_tab(&usable, "steam", 1).as_deref(), + Some("riot") + ); + assert_eq!( + pick_cycled_tab(&usable, "epic", 1).as_deref(), + Some("steam") + ); + assert_eq!( + pick_cycled_tab(&usable, "riot", -1).as_deref(), + Some("steam") + ); + assert_eq!( + pick_cycled_tab(&usable, "steam", -1).as_deref(), + Some("epic") + ); +} + +#[test] +fn tab_cycle_enters_from_the_matching_end_off_the_list() { + let usable = tabs(); + assert_eq!( + pick_cycled_tab(&usable, "roblox", 1).as_deref(), + Some("steam") + ); + assert_eq!( + pick_cycled_tab(&usable, "roblox", -1).as_deref(), + Some("epic") + ); +} + +#[test] +fn tab_cycle_returns_none_with_fewer_than_two_tabs() { + assert_eq!(pick_cycled_tab(&["steam".into()], "steam", 1), None); + assert_eq!(pick_cycled_tab(&[], "steam", -1), None); +} + +// keyboardBindings.test.ts + +#[test] +fn bindings_swallow_the_webview_built_ins_in_every_scope() { + let keyboard = KeyboardController::default(); + let mut host = Host::default(); + for scope in [ + KeyScope::Locked, + KeyScope::Dialog, + KeyScope::Settings, + KeyScope::App, + ] { + assert!(press(&keyboard, &mut host, scope, key("w").ctrl())); + assert!(press(&keyboard, &mut host, scope, key("p").ctrl())); + assert!(press(&keyboard, &mut host, scope, key("F3"))); + assert!(press(&keyboard, &mut host, scope, key("F5"))); + assert!(press(&keyboard, &mut host, scope, key("f").ctrl())); + assert!(press(&keyboard, &mut host, scope, key("ArrowRight").alt())); + } +} + +#[test] +fn bindings_open_the_palette_over_a_context_menu_and_close_it_from_inside() { + let keyboard = KeyboardController::default(); + let mut host = Host::default(); + assert!(press( + &keyboard, + &mut host, + KeyScope::ContextMenu, + key("k").ctrl() + )); + assert_eq!( + host.actions, + vec![KeyAction::CloseContextMenu, KeyAction::SetPaletteOpen(true)] + ); + press(&keyboard, &mut host, KeyScope::Palette, key("k").ctrl()); + assert_eq!(host.actions.last(), Some(&KeyAction::SetPaletteOpen(false))); +} + +#[test] +fn bindings_cancel_a_drag_before_any_other_escape_layer() { + let keyboard = KeyboardController::default(); + let mut host = Host { + drag: true, + ..Host::default() + }; + assert!(press( + &keyboard, + &mut host, + KeyScope::Palette, + key("Escape") + )); + assert!(host.actions.is_empty()); +} + +#[test] +fn bindings_close_exactly_one_escape_layer_and_pass_where_the_owner_handles_it() { + let keyboard = KeyboardController::default(); + let mut host = Host::default(); + assert!(press( + &keyboard, + &mut host, + KeyScope::Palette, + key("Escape") + )); + assert_eq!(host.actions.last(), Some(&KeyAction::SetPaletteOpen(false))); + + press(&keyboard, &mut host, KeyScope::Dialog, key("Escape")); + assert_eq!(host.count(&KeyAction::CloseConfirmDialog), 1); + host.input_dialog = true; + press(&keyboard, &mut host, KeyScope::Dialog, key("Escape")); + assert_eq!(host.count(&KeyAction::CloseInputDialog), 1); + assert_eq!(host.count(&KeyAction::CloseConfirmDialog), 1); + + for scope in [ + KeyScope::ContextMenu, + KeyScope::BulkEdit, + KeyScope::Settings, + KeyScope::Onboarding, + KeyScope::Locked, + ] { + assert!( + !press(&keyboard, &mut host, scope, key("Escape")), + "{}", + scope.as_str() + ); + } +} + +#[test] +fn bindings_let_the_persona_wizard_take_escape_before_closing_the_panel() { + let keyboard = KeyboardController::default(); + let mut host = Host { + personas_escape: vec![true], + ..Host::default() + }; + assert!(press( + &keyboard, + &mut host, + KeyScope::Personas, + key("Escape") + )); + assert_eq!(host.count(&KeyAction::ClosePersonas), 0); + assert!(press( + &keyboard, + &mut host, + KeyScope::Personas, + key("Escape") + )); + assert_eq!(host.count(&KeyAction::ClosePersonas), 1); +} + +#[test] +fn bindings_clear_the_search_then_the_card_focus_then_pass_escape() { + let keyboard = KeyboardController::default(); + let mut host = Host { + search_focused: true, + ..Host::default() + }; + assert!(press( + &keyboard, + &mut host, + KeyScope::App, + key("Escape").in_input() + )); + assert_eq!( + host.actions, + vec![KeyAction::ClearSearchQuery, KeyAction::BlurSearch] + ); + + host.focused = Some(ItemRef::account("a1")); + assert!(press(&keyboard, &mut host, KeyScope::App, key("Escape"))); + assert_eq!(host.focused, None); + + assert!(!press(&keyboard, &mut host, KeyScope::App, key("Escape"))); +} + +#[test] +fn bindings_route_mod_f_to_the_grid_search_or_the_settings_search() { + let keyboard = KeyboardController::default(); + let mut host = Host::default(); + press(&keyboard, &mut host, KeyScope::App, key("f").ctrl()); + assert_eq!(host.actions, vec![KeyAction::FocusSearch]); + press(&keyboard, &mut host, KeyScope::Settings, key("f").ctrl()); + assert_eq!(host.actions.last(), Some(&KeyAction::FocusSettingsSearch)); +} + +#[test] +fn bindings_map_the_app_shortcuts_to_their_actions() { + let keyboard = KeyboardController::default(); + let mut host = Host::default(); + let app = KeyScope::App; + press(&keyboard, &mut host, app, key("n").ctrl()); + assert_eq!(host.count(&KeyAction::AddAccount), 1); + press(&keyboard, &mut host, app, key("N").ctrl().shift()); + assert_eq!(host.count(&KeyAction::NewFolder), 1); + press(&keyboard, &mut host, app, key("r").ctrl()); + press(&keyboard, &mut host, app, key("F5")); + assert_eq!(host.count(&KeyAction::Refresh), 2); + press(&keyboard, &mut host, app, key("e").ctrl()); + assert_eq!(host.count(&KeyAction::ToggleBulkEdit), 1); + press(&keyboard, &mut host, app, key(",").ctrl()); + assert_eq!(host.count(&KeyAction::ClosePersonas), 1); + assert_eq!(host.count(&KeyAction::ToggleSettingsPanel), 1); + press(&keyboard, &mut host, app, key("P").ctrl().shift()); + assert_eq!(host.count(&KeyAction::OpenPersonas), 1); + press(&keyboard, &mut host, app, key("L").ctrl().shift()); + assert_eq!(host.count(&KeyAction::ToggleViewMode), 1); + press(&keyboard, &mut host, app, key("=").ctrl().code("Equal")); + press( + &keyboard, + &mut host, + app, + key("+").ctrl().shift().code("Equal"), + ); + assert_eq!(host.count(&KeyAction::ZoomIn), 2); + press(&keyboard, &mut host, app, key("-").ctrl().code("Minus")); + assert_eq!(host.count(&KeyAction::ZoomOut), 1); + press(&keyboard, &mut host, app, key("0").ctrl().code("Digit0")); + assert_eq!(host.count(&KeyAction::ResetZoom), 1); + + // Mod+E is taken but does nothing while bulk edit is unavailable. + host.bulk_allowed = false; + assert!(press(&keyboard, &mut host, app, key("e").ctrl())); + assert_eq!(host.count(&KeyAction::ToggleBulkEdit), 1); + + // F5 refreshes only in the app scope; elsewhere it is only swallowed. + assert!(press(&keyboard, &mut host, KeyScope::Settings, key("F5"))); + assert_eq!(host.count(&KeyAction::Refresh), 2); +} + +#[test] +fn bindings_switch_tabs_by_digit_and_cycle_past_unavailable_platforms() { + let keyboard = KeyboardController::default(); + let mut host = Host::default(); + let app = KeyScope::App; + press(&keyboard, &mut host, app, key("2").ctrl().code("Digit2")); + assert_eq!(host.last_tab(), Some("riot")); + assert_eq!(host.count(&KeyAction::ClosePersonas), 1); + + host.unavailable.insert("riot".into()); + assert!(press( + &keyboard, + &mut host, + app, + key("2").ctrl().code("Digit2") + )); + assert!(press( + &keyboard, + &mut host, + app, + key("9").ctrl().code("Digit9") + )); + let tab_changes = |host: &Host| { + host.actions + .iter() + .filter(|a| matches!(a, KeyAction::ChangeTab(_))) + .count() + }; + assert_eq!(tab_changes(&host), 1); + + press(&keyboard, &mut host, app, key("Tab").ctrl()); + assert_eq!(host.last_tab(), Some("epic")); + press(&keyboard, &mut host, app, key("Tab").ctrl().shift()); + assert_eq!(host.last_tab(), Some("epic")); +} + +#[test] +fn bindings_go_up_a_folder_on_backspace_and_alt_left_and_pass_at_the_root() { + let keyboard = KeyboardController::default(); + let mut host = Host::default(); + let app = KeyScope::App; + assert!(!press(&keyboard, &mut host, app, key("Backspace"))); + assert!(press(&keyboard, &mut host, app, key("ArrowLeft").alt())); + assert_eq!(host.count(&KeyAction::NavigateBack), 0); + + host.folder = true; + assert!(press(&keyboard, &mut host, app, key("Backspace"))); + assert!(press(&keyboard, &mut host, app, key("ArrowLeft").alt())); + assert_eq!(host.count(&KeyAction::NavigateBack), 2); + + assert!(!press( + &keyboard, + &mut host, + KeyScope::Settings, + key("ArrowLeft").alt() + )); +} + +#[test] +fn bindings_move_and_activate_the_card_focus_passing_when_nothing_takes_the_key() { + let keyboard = KeyboardController::default(); + let mut host = Host::default(); + let app = KeyScope::App; + assert_eq!(CARD_NAV_SCOPES, [KeyScope::App, KeyScope::BulkEdit]); + assert!(press(&keyboard, &mut host, app, key("ArrowDown"))); + host.moves = false; + assert!(!press(&keyboard, &mut host, app, key("ArrowDown"))); + + assert!(press(&keyboard, &mut host, app, key("Enter"))); + assert_eq!(host.activated, 1); + host.owns_key = false; + assert!(!press(&keyboard, &mut host, app, key("Enter"))); + assert_eq!(host.activated, 1); + + assert!(press(&keyboard, &mut host, app, key("F2"))); + assert!(press(&keyboard, &mut host, app, key("Delete"))); + assert!(press(&keyboard, &mut host, app, key("F10").shift())); + assert!(press(&keyboard, &mut host, app, key("ContextMenu"))); + assert_eq!(host.context_menus, 3); + host.rename = false; + assert!(!press(&keyboard, &mut host, app, key("F2"))); +} + +#[test] +fn bindings_toggle_the_focused_account_with_space_in_bulk_edit_only() { + let keyboard = KeyboardController::default(); + let mut host = Host::default(); + let bulk = KeyScope::BulkEdit; + assert!(!press(&keyboard, &mut host, bulk, key(" "))); + host.focused = Some(ItemRef::folder("f1")); + assert!(!press(&keyboard, &mut host, bulk, key(" "))); + host.focused = Some(ItemRef::account("a1")); + assert!(press(&keyboard, &mut host, bulk, key(" "))); + assert_eq!( + host.actions, + vec![KeyAction::ToggleBulkEditAccount("a1".into())] + ); + assert!(!press(&keyboard, &mut host, KeyScope::App, key(" "))); +} + +#[test] +fn bindings_select_all_in_bulk_edit_but_not_from_a_text_field() { + let keyboard = KeyboardController::default(); + let mut host = Host::default(); + let bulk = KeyScope::BulkEdit; + press(&keyboard, &mut host, bulk, key("a").ctrl()); + press(&keyboard, &mut host, bulk, key("d").ctrl()); + assert_eq!(host.count(&KeyAction::BulkEditSelectAll), 1); + assert_eq!(host.count(&KeyAction::BulkEditDeselectAll), 1); + assert!(!press( + &keyboard, + &mut host, + bulk, + key("a").ctrl().in_input() + )); + assert_eq!(host.count(&KeyAction::BulkEditSelectAll), 1); +} + +#[test] +fn key_scope_picks_the_topmost_layer_and_stops_reading_there() { + fn resolve(active: &[KeyScope]) -> (KeyScope, Vec) { + let mut read = Vec::new(); + let scope = current_key_scope(|scope| { + read.push(scope); + active.contains(&scope) + }); + (scope, read) + } + assert_eq!(resolve(&[]).0, KeyScope::App); + assert_eq!( + resolve(&[KeyScope::Personas, KeyScope::Settings]).0, + KeyScope::Settings + ); + assert_eq!( + resolve(&[KeyScope::BulkEdit, KeyScope::ContextMenu]).0, + KeyScope::ContextMenu + ); + assert_eq!( + resolve(&[KeyScope::Dialog, KeyScope::Onboarding]).0, + KeyScope::Onboarding + ); + assert_eq!( + resolve(&[KeyScope::Locked, KeyScope::Palette]).0, + KeyScope::Locked + ); + let (scope, read) = resolve(&[KeyScope::Dialog]); + assert_eq!(scope, KeyScope::Dialog); + assert_eq!( + read, + vec![ + KeyScope::Locked, + KeyScope::Palette, + KeyScope::Onboarding, + KeyScope::Dialog + ] + ); + assert_eq!(LAYER_ORDER.len(), 8); +} + +#[test] +fn shell_key_scope_reads_the_app_state_like_app_svelte() { + assert_eq!(key_scope(&ScopeFlags::default()), KeyScope::App); + for locked in [ + ScopeFlags { + pin_locked: true, + ..ScopeFlags::default() + }, + ScopeFlags { + streamer_active: true, + palette_open: true, + ..ScopeFlags::default() + }, + ScopeFlags { + render_suspended: true, + has_dialog: true, + ..ScopeFlags::default() + }, + ] { + assert_eq!(key_scope(&locked), KeyScope::Locked); + } + assert_eq!( + key_scope(&ScopeFlags { + bulk_edit_mode: true, + settings_open: true, + personas_open: true, + ..ScopeFlags::default() + }), + KeyScope::BulkEdit + ); +} + +// Every binding of the table, in every scope, through the resolver. + +/// A key event that matches `combo` exactly. +fn event_for(combo: &str) -> KeyEvent { + let parsed = parse_combo(combo); + let mut event = match parsed.key.as_str() { + "plus" => KeyEvent::new("=").code("Equal"), + "minus" => KeyEvent::new("-").code("Minus"), + "space" => KeyEvent::new(" "), + digit if digit.starts_with("digit") => { + let n = &digit["digit".len()..]; + KeyEvent::new(n).code(format!("Digit{n}")) + } + other => KeyEvent::new(other), + }; + event.ctrl = parsed.mod_key; + event.shift = parsed.shift; + event.alt = parsed.alt; + event +} + +/// What a command does on a host that says yes to everything. +fn expected_actions(command: KeyCommand) -> Option> { + use KeyAction as A; + use KeyCommand as C; + Some(match command { + C::Swallow => vec![], + C::Pass => return None, + C::OpenPalette => vec![A::CloseContextMenu, A::SetPaletteOpen(true)], + C::ClosePalette => vec![A::SetPaletteOpen(false)], + C::EscapeCancelDrag => vec![], + C::EscapeCloseDialog => vec![A::CloseInputDialog], + C::EscapePersonas => vec![A::ClosePersonas], + C::EscapeApp => vec![A::ClearSearchQuery, A::BlurSearch], + C::FocusSearch => vec![A::FocusSearch], + C::FocusSettingsSearch => vec![A::FocusSettingsSearch], + C::AddAccount => vec![A::AddAccount], + C::NewFolder => vec![A::NewFolder], + C::Refresh => vec![A::Refresh], + C::ToggleBulkEdit => vec![A::ToggleBulkEdit], + C::ToggleSettings => vec![A::ClosePersonas, A::ToggleSettingsPanel], + C::OpenPersonas => vec![A::OpenPersonas], + C::ToggleViewMode => vec![A::ToggleViewMode], + C::CycleTab(1) => vec![A::ClosePersonas, A::ChangeTab("riot".into())], + C::CycleTab(_) => vec![A::ClosePersonas, A::ChangeTab("epic".into())], + C::TabByIndex(index) => match ["steam", "riot", "epic"].get(index) { + Some(id) => vec![A::ClosePersonas, A::ChangeTab((*id).into())], + None => vec![], + }, + C::ZoomIn => vec![A::ZoomIn], + C::ZoomOut => vec![A::ZoomOut], + C::ResetZoom => vec![A::ResetZoom], + C::AltBack | C::BackspaceBack => vec![A::NavigateBack], + C::CardMove(_) | C::CardActivate | C::RenameFocused | C::ContextMenuFocused => vec![], + C::BulkToggleFocused => vec![A::ToggleBulkEditAccount("a1".into())], + C::BulkSelectAll => vec![A::BulkEditSelectAll], + C::BulkDeselectAll => vec![A::BulkEditDeselectAll], + }) +} + +fn yes_host(drag: bool) -> Host { + Host { + folder: true, + input_dialog: true, + search_focused: true, + focused: Some(ItemRef::account("a1")), + drag, + ..Host::default() + } +} + +#[test] +fn every_binding_resolves_in_every_scope() { + let table = app_bindings(); + let keyboard = KeyboardController::new(table.clone()); + let parsed: Vec<_> = table.iter().map(|b| parse_combo(&b.combo)).collect(); + let mut reached = BTreeSet::new(); + let mut checked = 0; + for drag in [false, true] { + for (index, binding) in table.iter().enumerate() { + let event = event_for(&binding.combo); + for scope in KeyScope::ALL { + let mut host = yes_host(drag); + let outcome = keyboard.handle(&event, scope, false, &mut host); + checked += 1; + // The first binding of the table that is in scope, matches + // the event and does not pass is the one that runs. + let expected = table.iter().enumerate().position(|(j, other)| { + other.scopes.contains(scope) && parsed[j] == parsed[index] && { + let mut probe = yes_host(drag); + accshift_app::actions::keyboard::run_command( + other.command, + &event, + &mut probe, + ) == KeyResult::Handled + } + }); + match (outcome, expected) { + (KeyOutcome::Unhandled, None) => {} + ( + KeyOutcome::Handled { + index: got, + consume, + }, + Some(want), + ) => { + assert_eq!(got, want, "{} in {}", binding.combo, scope.as_str()); + assert!(consume); + if got == index { + reached.insert(index); + let want_actions = expected_actions(binding.command); + assert_eq!( + Some(host.actions.clone()), + want_actions, + "{} in {}", + binding.combo, + scope.as_str() + ); + } + } + (outcome, expected) => panic!( + "{} in {}: got {outcome:?}, expected {expected:?}", + binding.combo, + scope.as_str() + ), + } + // A binding never fires outside its scopes. + if let KeyOutcome::Handled { index: got, .. } = outcome { + assert!(table[got].scopes.contains(scope)); + } + } + } + } + assert_eq!(checked, table.len() * KeyScope::ALL.len() * 2); + // Every binding that can take a key takes one somewhere. + for (index, binding) in table.iter().enumerate() { + if binding.command == KeyCommand::Pass { + continue; + } + assert!( + reached.contains(&index), + "binding {index} ({}) is never reached", + binding.combo + ); + } + assert!(table + .iter() + .filter(|b| b.scopes == Scopes::All) + .all(|b| b.prevent_default)); +} diff --git a/crates/accshift-app/tests/app_actions_personas.rs b/crates/accshift-app/tests/app_actions_personas.rs new file mode 100644 index 0000000..2df397b --- /dev/null +++ b/crates/accshift-app/tests/app_actions_personas.rs @@ -0,0 +1,396 @@ +//! Personas: `app/usePersonas.test.ts` and `app/usePersonaSwitch.test.ts`, +//! run on the mock backend through a scripted wrapper. + +mod support; + +use accshift_app::accounts::{Toast, ToastKind}; +use accshift_app::actions::dialogs::AppDialogs; +use accshift_app::actions::personas::{ + persona_platforms, switch_steps, switch_to_persona_blocking, PersonaController, + PersonaPlatform, PersonaSwitch, PersonaSwitchFailure, PersonaSwitchResult, +}; +use accshift_app::actions::AppEffect; +use accshift_app::jobs::Jobs; +use accshift_app::personas::{Persona, PersonaAssignment, PersonaInput, PersonaPatch}; +use accshift_app::platforms::{PlatformAccount, PlatformRegistry, RuntimeOs}; +use accshift_app::storage; +use accshift_app::stores::{ClientStores, StoreContext}; +use accshift_app::text::Text; +use accshift_app::Backend; +use serde_json::Value; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use support::scripted::Scripted; + +fn persona(assignments: &[(&str, &str)]) -> Persona { + Persona { + id: "persona-1".into(), + name: "Main".into(), + color: String::new(), + image: None, + assignments: assignments + .iter() + .map(|(platform_id, account_id)| PersonaAssignment { + platform_id: (*platform_id).into(), + account_id: (*account_id).into(), + }) + .collect(), + } +} + +fn steam_accounts(backend: &dyn Backend, registry: &PlatformRegistry) -> Vec { + registry + .adapter("steam") + .unwrap() + .load_accounts(backend) + .unwrap() +} + +fn stores_of(backend: &dyn Backend) -> ClientStores { + let (storage, _) = storage::initialize(backend, None, None, true); + ClientStores::new(storage, StoreContext::shipped()) +} + +// usePersonas.test.ts + +#[test] +fn switches_with_the_adapters_resolved_account() { + // The dev scenario's Steam accounts have login names; demo's are blank. + let backend = support::scripted("dev"); + let registry = PlatformRegistry::shipped(); + let account = steam_accounts(&backend, ®istry) + .into_iter() + .find(|account| !account.username.is_empty() && account.username != account.id) + .expect("a Steam account with a login name"); + let settings = stores_of(&backend).settings().clone(); + let steps = switch_steps(®istry, &persona(&[("steam", &account.id)])); + backend.clear_log(); + + let result = switch_to_persona_blocking(&backend, &steps, &settings); + + assert_eq!( + result, + Some(PersonaSwitchResult { + succeeded: vec!["steam".into()], + failed: vec![] + }) + ); + // Steam switches by login name, never by the steam id. + let calls = backend.calls("platform_switch_account"); + assert_eq!(calls.len(), 1); + assert!( + calls[0].contains(&format!("{:?}", account.username)), + "{calls:?}" + ); +} + +#[test] +fn fails_the_assignment_instead_of_switching_an_unresolved_id() { + let backend = support::scripted("demo"); + let registry = PlatformRegistry::shipped(); + let settings = stores_of(&backend).settings().clone(); + let steps = switch_steps(®istry, &persona(&[("steam", "steam-id")])); + backend.clear_log(); + + let result = switch_to_persona_blocking(&backend, &steps, &settings).unwrap(); + + assert!(backend.calls("platform_switch_account").is_empty()); + assert!(result.succeeded.is_empty()); + assert_eq!(result.failed[0].platform_id, "steam"); + assert!(result.failed[0].error.contains("steam/steam-id")); +} + +#[test] +fn reports_the_switch_in_flight_until_every_platform_is_done() { + let backend = Arc::new(support::scripted("demo")); + let registry = PlatformRegistry::shipped(); + let account = steam_accounts(backend.as_ref(), ®istry).remove(0); + let settings = stores_of(backend.as_ref()).settings().clone(); + let jobs = Jobs::new(backend.clone() as Arc, 1, || {}); + let mut controller = PersonaController::new(&stores_of(backend.as_ref())); + let target = persona(&[("steam", &account.id)]); + + assert!(controller.start_switch(&jobs, ®istry, &settings, target.clone(), false)); + assert!(controller.switching()); + assert_eq!(controller.switching_persona_id(), Some("persona-1")); + // A second start while one runs is refused. + assert!(!controller.start_switch(&jobs, ®istry, &settings, target, false)); + + let deadline = Instant::now() + Duration::from_secs(10); + let (done, result) = loop { + if let Some(done) = controller.poll_switch() { + break done; + } + assert!(Instant::now() < deadline, "switch never finished"); + std::thread::sleep(Duration::from_millis(5)); + }; + assert_eq!(done.id, "persona-1"); + assert_eq!(result.unwrap().succeeded, vec!["steam".to_string()]); + assert!(!controller.switching()); + assert_eq!(controller.poll_switch(), None); +} + +#[test] +fn stops_quietly_when_the_backend_is_locked_by_the_pin() { + let backend = support::scripted("demo"); + let registry = PlatformRegistry::shipped(); + let account = steam_accounts(&backend, ®istry).remove(0); + let settings = stores_of(&backend).settings().clone(); + backend.push( + "platform_switch_account", + Err("pin_locked: Accshift is locked. Enter the PIN to switch accounts."), + ); + let steps = switch_steps( + ®istry, + &persona(&[("steam", &account.id), ("riot", "riot-1")]), + ); + backend.clear_log(); + + assert_eq!( + switch_to_persona_blocking(&backend, &steps, &settings), + None + ); + assert_eq!(backend.calls("platform_switch_account").len(), 1); +} + +#[test] +fn a_pin_lock_after_a_landed_platform_reports_and_stops() { + let backend = support::scripted("demo"); + let registry = PlatformRegistry::shipped(); + let account = steam_accounts(&backend, ®istry).remove(0); + let settings = stores_of(&backend).settings().clone(); + let steps = switch_steps( + ®istry, + &persona(&[ + ("steam", &account.id), + ("riot", "riot-1"), + ("riot", "riot-2"), + ]), + ); + backend.push("platform_switch_account", Ok(Value::Null)); + backend.push("platform_switch_account", Err("pin_locked: locked")); + + let result = switch_to_persona_blocking(&backend, &steps, &settings).unwrap(); + assert_eq!(result.succeeded, vec!["steam".to_string()]); + assert_eq!( + result.failed, + vec![PersonaSwitchFailure { + platform_id: "riot".into(), + error: "pin_locked: locked".into() + }] + ); +} + +#[test] +fn refuses_to_start_while_an_account_switch_is_running() { + let backend = Arc::new(support::scripted("demo")); + let registry = PlatformRegistry::shipped(); + let settings = stores_of(backend.as_ref()).settings().clone(); + let jobs = Jobs::new(backend.clone() as Arc, 1, || {}); + let mut controller = PersonaController::new(&stores_of(backend.as_ref())); + backend.clear_log(); + + assert!(!controller.start_switch(&jobs, ®istry, &settings, persona(&[]), true)); + assert!(!controller.switching()); + assert_eq!(controller.poll_switch(), None); +} + +#[test] +fn controller_keeps_its_list_in_step_with_the_store() { + let backend = support::mock("demo"); + let mut stores = stores_of(&backend); + let mut controller = PersonaController::new(&stores); + let before = controller.personas().len(); + let created = controller.create( + &mut stores, + PersonaInput { + name: "Work".into(), + color: String::new(), + image: None, + assignments: vec![], + }, + ); + assert_eq!(controller.personas().len(), before + 1); + controller.update( + &mut stores, + &created.id, + &PersonaPatch { + name: Some("Office".into()), + ..PersonaPatch::default() + }, + ); + assert!(controller.personas().iter().any(|p| p.name == "Office")); + controller.remove(&mut stores, &created.id); + assert_eq!(controller.personas().len(), before); +} + +// usePersonaSwitch.test.ts + +#[test] +fn offers_the_implemented_enabled_platforms_of_this_os_as_slots() { + let backend = support::mock("demo"); + let registry = PlatformRegistry::shipped(); + let mut settings = stores_of(&backend).settings().clone(); + settings.enabled_platforms = registry.defs().iter().map(|def| def.id.clone()).collect(); + let all = persona_platforms(®istry, RuntimeOs::Windows, &settings); + let expected: Vec = registry + .defs() + .iter() + .filter(|def| def.implemented && def.supported_os.contains(&RuntimeOs::Windows)) + .map(|def| PersonaPlatform { + id: def.id.clone(), + name: def.name.clone(), + accent: def.accent.clone(), + }) + .collect(); + assert_eq!(all, expected); + assert!(all.iter().any(|platform| platform.id == "steam")); + + settings.enabled_platforms = vec!["riot".into(), "steam".into()]; + let ids: Vec = persona_platforms(®istry, RuntimeOs::Windows, &settings) + .into_iter() + .map(|platform| platform.id) + .collect(); + // Registry order, not settings order. + assert_eq!(ids, ["steam", "riot"]); +} + +#[test] +fn opens_the_panel_after_closing_settings_and_bulk_edit() { + let mut switch = PersonaSwitch::new(); + assert_eq!( + switch.open_personas(true, true), + vec![AppEffect::CloseSettingsPanel, AppEffect::CloseBulkEdit] + ); + assert!(switch.show_personas); + switch.sync_enabled(false); + assert!(!switch.show_personas); + assert!(switch.open_personas(false, false).is_empty()); + assert!(!switch.show_personas); +} + +#[test] +fn does_nothing_without_a_confirmation() { + let mut switch = PersonaSwitch::new(); + let mut dialogs = AppDialogs::new(); + let target = persona(&[("steam", "s1"), ("riot", "r1")]); + assert!(switch + .handle_switch_persona(&mut dialogs, &target, false, false) + .is_empty()); + let dialog = dialogs.confirm_dialog().unwrap(); + assert_eq!( + dialog.message, + Text::with( + "personas.switchConfirmMessage", + [ + ("name", accshift_app::i18n::TranslationValue::from("Main")), + ("count", accshift_app::i18n::TranslationValue::from(2.0)) + ] + ) + ); + let settled = dialogs.close_confirm_dialog(); + let [AppEffect::ConfirmSettled { ticket, allowed }] = settled.as_slice() else { + panic!("{settled:?}"); + }; + assert_eq!(switch.confirm_settled(*ticket, *allowed), None); + + // Busy: no dialog at all. + let mut dialogs = AppDialogs::new(); + switch.handle_switch_persona(&mut dialogs, &target, true, false); + switch.handle_switch_persona(&mut dialogs, &target, false, true); + assert!(dialogs.confirm_dialog().is_none()); + + // Accepted: the persona comes back for the controller. + let mut dialogs = AppDialogs::new(); + switch.handle_switch_persona(&mut dialogs, &target, false, false); + let ticket = dialogs.pending_confirm().unwrap(); + assert_eq!(switch.confirm_settled(ticket, true), Some(target)); +} + +#[test] +fn toasts_success_total_failure_and_a_partial_switch_by_platform_name() { + let platforms = vec![ + PersonaPlatform { + id: "steam".into(), + name: "Steam".into(), + accent: "#1".into(), + }, + PersonaPlatform { + id: "riot".into(), + name: "Riot".into(), + accent: "#2".into(), + }, + ]; + let target = persona(&[("steam", "s1"), ("riot", "r1")]); + let fail = |id: &str| PersonaSwitchFailure { + platform_id: id.into(), + error: "x".into(), + }; + + let ok = PersonaSwitch::finish_switch( + &target, + Some(&PersonaSwitchResult { + succeeded: vec!["steam".into(), "riot".into()], + failed: vec![], + }), + &platforms, + ); + assert_eq!( + ok, + vec![ + AppEffect::TrackPersonaSwitch { + platforms: 2, + succeeded: 2 + }, + AppEffect::Toast(Toast::new( + Text::with("personas.switched", [("name", "Main")]), + ToastKind::Success + )) + ] + ); + + let failed = PersonaSwitch::finish_switch( + &target, + Some(&PersonaSwitchResult { + succeeded: vec![], + failed: vec![fail("steam"), fail("riot")], + }), + &platforms, + ); + assert_eq!( + failed[1], + AppEffect::Toast(Toast::new( + Text::with("personas.switchFailed", [("name", "Main")]), + ToastKind::Error + )) + ); + + let partial = PersonaSwitch::finish_switch( + &target, + Some(&PersonaSwitchResult { + succeeded: vec!["steam".into()], + failed: vec![fail("riot"), fail("gog")], + }), + &platforms, + ); + assert_eq!( + partial[1], + AppEffect::Toast(Toast::new( + Text::with( + "personas.switchPartial", + [("name", "Main"), ("failed", "Riot, gog")] + ), + ToastKind::Default + )) + ); + + assert!(PersonaSwitch::finish_switch(&target, None, &platforms).is_empty()); +} + +#[test] +fn telemetry_counter_reaches_the_backend() { + let backend = support::scripted("demo"); + let _ = backend.telemetry_track_persona_switch(2, 1); + assert_eq!(backend.calls("telemetry_track_persona_switch"), ["(2, 1)"]); + let _ = Scripted::new(support::mock("demo")); +} diff --git a/crates/accshift-app/tests/app_actions_toasts_palette.rs b/crates/accshift-app/tests/app_actions_toasts_palette.rs new file mode 100644 index 0000000..fecb53e --- /dev/null +++ b/crates/accshift-app/tests/app_actions_toasts_palette.rs @@ -0,0 +1,310 @@ +//! Toasts and the command palette: `features/notifications/store.test.ts`, +//! `features/commandPalette/registry.test.ts`, plus the queue timers and the +//! palette filtering of `CommandPalette.svelte`. + +use accshift_app::accounts::{Toast, ToastKind}; +use accshift_app::actions::palette::{ + fold_text, fuzzy_score, fuzzy_score_all, palette_commands, run_command, CommandPalette, + FoldCache, PaletteAction, PaletteRow, PaletteSection, RegistryInput, EMPTY_QUERY_ACCOUNT_LIMIT, +}; +use accshift_app::actions::toasts::{ + resolve_toast_duration, AddToastOptions, DurationRequest, ToastAction, ToastQueue, ToastType, + DEFAULT_DURATION_MS, MAX_TOASTS, +}; +use accshift_app::i18n::Locale; +use accshift_app::platforms::PlatformAccount; +use accshift_app::stores::ViewMode; +use accshift_app::text::Text; +use std::collections::BTreeSet; +use std::time::{Duration, Instant}; + +// store.test.ts + +#[test] +fn toast_error_never_gets_a_timer() { + assert_eq!( + resolve_toast_duration(ToastType::Error, DurationRequest::Default), + None + ); +} + +#[test] +fn toast_error_ignores_an_explicit_duration() { + assert_eq!( + resolve_toast_duration(ToastType::Error, DurationRequest::Ms(1500)), + None + ); +} + +#[test] +fn toast_other_types_keep_the_default_timer() { + assert_eq!( + resolve_toast_duration(ToastType::Info, DurationRequest::Default), + Some(3000) + ); + assert_eq!( + resolve_toast_duration(ToastType::Success, DurationRequest::Default), + Some(DEFAULT_DURATION_MS) + ); +} + +#[test] +fn toast_honours_an_explicit_duration_on_a_non_error_toast() { + assert_eq!( + resolve_toast_duration(ToastType::Info, DurationRequest::Ms(1000)), + Some(1000) + ); + assert_eq!( + resolve_toast_duration(ToastType::Info, DurationRequest::Sticky), + None + ); +} + +#[test] +fn toast_store_keeps_an_error_toast_without_duration() { + let mut queue = ToastQueue::new(); + let id = queue.add( + Text::raw("boom"), + AddToastOptions::kind(ToastType::Error), + Instant::now(), + ); + assert_eq!(queue.get(id).unwrap().duration_ms, None); +} + +#[test] +fn toast_store_drops_the_timer_when_a_message_is_raised_again_as_an_error() { + let mut queue = ToastQueue::new(); + let now = Instant::now(); + queue.add(Text::raw("same text"), AddToastOptions::default(), now); + let id = queue.add( + Text::raw("same text"), + AddToastOptions::kind(ToastType::Error), + now, + ); + assert_eq!(queue.toasts().len(), 1); + let toast = queue.get(id).unwrap(); + assert_eq!(toast.kind, ToastType::Error); + assert_eq!(toast.duration_ms, None); + assert_eq!(toast.reset_key, 1); +} + +#[test] +fn toast_queue_keeps_the_five_newest() { + let mut queue = ToastQueue::new(); + let now = Instant::now(); + for n in 0..7 { + queue.add(Text::raw(format!("t{n}")), AddToastOptions::default(), now); + } + assert_eq!(queue.toasts().len(), MAX_TOASTS); + assert_eq!(queue.toasts()[0].text, Text::raw("t2")); + assert_eq!(queue.toasts()[4].text, Text::raw("t6")); +} + +#[test] +fn toast_queue_dedupes_on_the_key_and_params_not_the_resolved_text() { + let mut queue = ToastQueue::new(); + let now = Instant::now(); + let a = queue.add( + Text::with("toast.copied", [("label", "a")]), + AddToastOptions::default(), + now, + ); + let b = queue.add( + Text::with("toast.copied", [("label", "b")]), + AddToastOptions::default(), + now, + ); + assert_ne!(a, b); + let again = queue.push( + Toast::new( + Text::with("toast.copied", [("label", "a")]), + ToastKind::Success, + ), + now, + ); + assert_eq!(again, a); + assert_eq!(queue.get(a).unwrap().kind, ToastType::Success); +} + +#[test] +fn toast_countdown_expires_pauses_on_hover_and_resumes() { + let mut queue = ToastQueue::new(); + let start = Instant::now(); + let id = queue.add(Text::raw("x"), AddToastOptions::default(), start); + assert_eq!( + queue.next_deadline(), + Some(start + Duration::from_millis(3000)) + ); + assert!(queue.tick(start + Duration::from_millis(2999)).is_empty()); + + // Hovered at 1 s: 2 s left, frozen. + queue.set_hovered(id, true, start + Duration::from_millis(1000)); + assert_eq!(queue.next_deadline(), None); + assert!(queue.tick(start + Duration::from_secs(60)).is_empty()); + + // Left at 10 s: runs out at 12 s. + let leave = start + Duration::from_secs(10); + assert!(!queue.set_hovered(id, false, leave)); + assert_eq!(queue.next_deadline(), Some(leave + Duration::from_secs(2))); + assert_eq!(queue.tick(leave + Duration::from_secs(2)), vec![id]); + assert!(queue.toasts().is_empty()); +} + +#[test] +fn toast_leaving_with_no_time_left_dismisses_it() { + let mut queue = ToastQueue::new(); + let start = Instant::now(); + let id = queue.add(Text::raw("x"), AddToastOptions::default(), start); + queue.set_hovered(id, true, start + Duration::from_secs(5)); + assert!(queue.set_hovered(id, false, start + Duration::from_secs(6))); + assert!(queue.get(id).is_none()); +} + +#[test] +fn toast_action_runs_once_and_closes_the_toast() { + let mut queue = ToastQueue::new(); + let id = queue.add( + Text::raw("update ready"), + AddToastOptions { + kind: ToastType::Info, + duration: DurationRequest::Sticky, + action: Some(ToastAction { + label: Text::key("update.restart"), + id: "restart".into(), + }), + }, + Instant::now(), + ); + assert_eq!(queue.next_deadline(), None); + assert_eq!(queue.activate_action(id).as_deref(), Some("restart")); + assert!(queue.toasts().is_empty()); + assert_eq!(queue.activate_action(id), None); +} + +// registry.test.ts and the palette view + +fn account(id: &str, name: &str, username: &str) -> PlatformAccount { + PlatformAccount { + id: id.into(), + display_name: name.into(), + username: username.into(), + last_login_at_sec: None, + } +} + +fn input<'a>( + accounts: &'a [PlatformAccount], + current: Option<&'a str>, + unavailable: &'a BTreeSet, +) -> RegistryInput<'a> { + RegistryInput { + accounts, + current_account_id: current, + enabled_platforms: &[], + unavailable_platform_ids: unavailable, + active_tab: "steam", + active_tab_usable: true, + current_folders: &[], + current_folder_id: None, + bulk_edit_available: false, + personas_enabled: false, + update_ready: false, + view_mode: ViewMode::Grid, + is_mac: false, + } +} + +#[test] +fn registry_marks_the_current_account_active_once_through_the_badge_alone() { + let accounts = [ + account("a", "main", "main_login"), + account("b", "alt", "alt_login"), + ]; + let unavailable = BTreeSet::new(); + let all = palette_commands(&input(&accounts, Some("a"), &unavailable)); + let commands: Vec<_> = all + .iter() + .filter(|command| command.section == PaletteSection::Accounts) + .collect(); + let by_id = |id: &str| commands.iter().find(|c| c.id == id).expect(id); + assert!(by_id("account:a").active); + assert!(!by_id("account:b").active); + assert!(commands.iter().all(|command| command.hint.is_none())); + let add = all + .iter() + .find(|command| command.id == "action:add-account") + .unwrap(); + assert!(add.hint.as_deref().unwrap().ends_with("+N")); +} + +#[test] +fn fuzzy_scores_subsequences_and_prefers_tight_word_start_matches() { + assert_eq!(fuzzy_score("", "anything"), Some(0)); + assert_eq!(fuzzy_score("xyz", "steam"), None); + let tight = fuzzy_score("ref", "Refresh").unwrap(); + let loose = fuzzy_score("ref", "rare effect").unwrap(); + assert!(tight > loose, "{tight} vs {loose}"); + assert_eq!(fold_text("Élodie"), "elodie"); + assert!(fuzzy_score("elo", "Élodie").is_some()); +} + +#[test] +fn fuzzy_keyword_hits_count_two_less_than_title_hits() { + let mut cache = FoldCache::default(); + let title = fuzzy_score_all(&mut cache, "zoom", "zoom", &[]).unwrap(); + let keyword = fuzzy_score_all(&mut cache, "zoom", "reset", &["zoom".into()]).unwrap(); + assert_eq!(keyword, title - 2); + assert_eq!( + fuzzy_score_all(&mut cache, "qq", "reset", &["zoom".into()]), + None + ); +} + +#[test] +fn palette_idle_shows_six_accounts_then_the_other_commands() { + let accounts: Vec<_> = (0..9) + .map(|n| account(&format!("a{n}"), &format!("acc {n}"), "")) + .collect(); + let unavailable = BTreeSet::new(); + let commands = palette_commands(&input(&accounts, None, &unavailable)); + let mut palette = CommandPalette::new(); + let filtered = palette.filtered(&commands, Locale::En); + let shown_accounts = filtered + .iter() + .filter(|&&i| commands[i].section == PaletteSection::Accounts) + .count(); + assert_eq!(shown_accounts, EMPTY_QUERY_ACCOUNT_LIMIT); + assert_eq!( + filtered.len(), + commands.len() - (accounts.len() - EMPTY_QUERY_ACCOUNT_LIMIT) + ); + let rows = palette.rows(&commands, Locale::En); + assert_eq!(rows[0], PaletteRow::Header(PaletteSection::Accounts)); +} + +#[test] +fn palette_query_ranks_matches_moves_and_runs_the_highlighted_command() { + let accounts = [ + account("a", "main", "main_login"), + account("b", "alt", "alt_login"), + ]; + let unavailable = BTreeSet::new(); + let commands = palette_commands(&input(&accounts, Some("a"), &unavailable)); + let mut palette = CommandPalette::new(); + palette.set_query("alt"); + let flat = palette.flat_commands(&commands, Locale::En); + assert_eq!(commands[flat[0]].id, "account:b"); + assert_eq!( + palette.enter(&commands, Locale::En), + Some(PaletteAction::SwitchToAccount(accounts[1].clone())) + ); + + palette.move_active(-1, flat.len()); + assert_eq!(palette.active_index(), flat.len() - 1); + palette.set_query("al"); + assert_eq!(palette.active_index(), 0); + + let mut disabled = commands[flat[0]].clone(); + disabled.disabled = true; + assert_eq!(run_command(&disabled), None); +} diff --git a/crates/accshift-app/tests/app_actions_workspace.rs b/crates/accshift-app/tests/app_actions_workspace.rs new file mode 100644 index 0000000..a445065 --- /dev/null +++ b/crates/accshift-app/tests/app_actions_workspace.rs @@ -0,0 +1,860 @@ +//! Navigation, card actions, extension content, the fallback avatar, the +//! onboarding tour and the App.svelte glue: `app/useAppNavigation.test.ts`, +//! `app/useExtensionContent.test.ts`, `shared/avatarFallback.test.ts`, plus +//! the card and tour paths on the mock backend's demo scenario. + +mod support; + +use accshift_app::accounts::{Toast, ToastKind}; +use accshift_app::actions::avatar_fallback::{ + avatar_gradient, avatar_initials, avatar_seed, GradientCache, +}; +use accshift_app::actions::card_actions::{self, CardContext, FocusedCard}; +use accshift_app::actions::dialogs::{AppDialogs, ContextMenuTarget, InputPurpose}; +use accshift_app::actions::extension::{ + note_section, warning_section, ExtensionContent, ExtensionInputs, +}; +use accshift_app::actions::keyboard::KeyAction; +use accshift_app::actions::navigation::{ + AppHistoryEntry, AppNavigation, HistoryStack, NavContext, SettingsPanel, SettingsTabBar, +}; +use accshift_app::actions::onboarding::{mock_accounts, mock_items, OnboardingTour, MOCK_IDS}; +use accshift_app::actions::palette::PaletteAction; +use accshift_app::actions::personas::PersonaSwitch; +use accshift_app::actions::shell::{self, ShellActions, TitleBarInput}; +use accshift_app::actions::{load_options, AppEffect}; +use accshift_app::display::FolderNavigation; +use accshift_app::folders::ItemRef; +use accshift_app::i18n::Locale; +use accshift_app::jobs::Jobs; +use accshift_app::platforms::card::{CardSection, ChipTone}; +use accshift_app::platforms::warnings::{WarningChip, WarningPresentation, WarningTone}; +use accshift_app::platforms::{PlatformAccount, PlatformAdapter, PlatformRegistry, RuntimeOs}; +use accshift_app::settings::AppSettings; +use accshift_app::storage; +use accshift_app::stores::{ClientStores, StoreContext, ViewMode}; +use accshift_app::text::Text; +use accshift_app::Backend; +use serde_json::json; +use std::cell::RefCell; +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +const SMURFS: &str = "demo-folder-smurfs"; + +fn demo_stores() -> ClientStores { + let backend = support::mock("demo"); + let (storage, _) = storage::initialize(&backend, None, None, true); + ClientStores::new(storage, StoreContext::shipped()) +} + +// ------------------------------------------------------------ navigation + +struct Nav { + registry: PlatformRegistry, + settings: AppSettings, + navigation: FolderNavigation, + panel: SettingsPanel, + stores: ClientStores, + app: AppNavigation, + active_tab: String, + add_flow: Option, +} + +impl Nav { + fn new(tab: &str, show_settings: bool, enabled: &[&str]) -> Self { + let stores = demo_stores(); + let mut settings = stores.settings().clone(); + settings.enabled_platforms = enabled.iter().map(|id| id.to_string()).collect(); + Self { + registry: PlatformRegistry::shipped(), + settings, + navigation: FolderNavigation::new(), + panel: SettingsPanel { show_settings }, + stores, + app: AppNavigation::new(tab), + active_tab: tab.into(), + add_flow: None, + } + } + + fn run( + &mut self, + step: impl FnOnce(&mut AppNavigation, &mut NavContext<'_>) -> Vec, + ) -> Vec { + let mut ctx = NavContext { + active_tab: &self.active_tab, + runtime_os: RuntimeOs::Windows, + registry: &self.registry, + settings: &self.settings, + add_flow_platform: self.add_flow.as_deref(), + navigation: &mut self.navigation, + settings_panel: &mut self.panel, + stores: &self.stores, + }; + let effects = step(&mut self.app, &mut ctx); + for effect in &effects { + if let AppEffect::SetActiveTab(tab) = effect { + self.active_tab = tab.clone(); + } + } + effects + } +} + +/// The TS test's `calls`: clear, tab and load, in order. +fn calls(effects: &[AppEffect]) -> Vec { + effects + .iter() + .filter_map(|effect| match effect { + AppEffect::ClearForPlatformChange => Some("clear".to_string()), + AppEffect::SetActiveTab(tab) => Some(format!("tab:{tab}")), + AppEffect::LoadAccounts(_) => Some("load".to_string()), + _ => None, + }) + .collect() +} + +#[test] +fn tab_change_no_ops_the_re_click_without_wiping_or_reloading() { + let mut nav = Nav::new("steam", false, &["steam"]); + let effects = nav.run(|app, ctx| app.handle_tab_change("steam", ctx)); + assert!(effects.is_empty()); + assert_eq!(nav.app.history.len(), 1); +} + +#[test] +fn tab_change_still_exits_settings_on_the_current_tab() { + let mut nav = Nav::new("steam", true, &["steam"]); + let effects = nav.run(|app, ctx| app.handle_tab_change("steam", ctx)); + assert!(!nav.panel.show_settings); + assert!(calls(&effects).is_empty()); + assert!(effects.contains(&AppEffect::SettingsClosed)); +} + +#[test] +fn tab_change_switches_normally_on_a_different_tab() { + let mut nav = Nav::new("steam", false, &["steam", "riot"]); + nav.navigation.set_current_folder_id(Some(SMURFS.into())); + let effects = nav.run(|app, ctx| app.handle_tab_change("riot", ctx)); + assert_eq!(calls(&effects), ["clear", "tab:riot", "load"]); + assert_eq!(effects[0], AppEffect::AddFlowCancel); + assert!(effects.contains(&AppEffect::ResetVisiblePrimeState)); + assert_eq!(nav.navigation.current_folder_id(), None); + assert_eq!( + nav.app.history.state(), + &AppHistoryEntry::new("riot", None, false) + ); + // An unusable tab is refused outright. + assert!(nav + .run(|app, ctx| app.handle_tab_change("nope", ctx)) + .is_empty()); +} + +#[test] +fn platforms_changed_clears_the_old_load_before_moving_off_a_disabled_tab() { + let mut nav = Nav::new("steam", false, &["riot"]); + let effects = nav.run(|app, ctx| app.handle_platforms_changed(ctx, || "riot".into())); + assert_eq!(calls(&effects), ["clear", "tab:riot", "load"]); + assert!(effects.contains(&AppEffect::ResetVisiblePrimeState)); + assert_eq!(nav.app.history.state().tab, "riot"); +} + +#[test] +fn platforms_changed_keeps_the_grid_when_the_tab_stays_enabled() { + let mut nav = Nav::new("steam", false, &["steam", "riot"]); + let effects = nav.run(|app, ctx| app.handle_platforms_changed(ctx, || unreachable!())); + assert_eq!(calls(&effects), ["load"]); + assert!(effects.contains(&AppEffect::LoadAccounts(load_options( + false, false, false, false + )))); +} + +#[test] +fn settings_toggle_pushes_history_and_back_closes_it() { + let mut nav = Nav::new("steam", false, &["steam"]); + let effects = nav.run(|app, ctx| app.toggle_settings_panel(ctx)); + assert_eq!( + effects, + [AppEffect::AddFlowCancel, AppEffect::CloseBulkEdit] + ); + assert!(nav.panel.show_settings); + assert!(nav.app.history.state().show_settings); + + // Toggling again goes back through history (popstate). + let effects = nav.run(|app, ctx| app.toggle_settings_panel(ctx)); + assert!(!nav.panel.show_settings); + assert_eq!(nav.app.history.index(), 0); + assert_eq!( + effects, + [ + AppEffect::RefreshSettings, + AppEffect::SettingsClosed, + AppEffect::RefreshCurrentItems, + AppEffect::PrepareVisibleAccounts, + AppEffect::QueueGridPadding + ] + ); +} + +#[test] +fn folders_walk_in_and_back_out_through_history() { + let mut nav = Nav::new("steam", false, &["steam"]); + let effects = nav.run(|app, ctx| app.navigate_to(Some(SMURFS.into()), true, ctx)); + assert_eq!( + effects[0], + AppEffect::AddFlowCancelIfConflicting { + platform_id: "steam".into(), + account_id: None + } + ); + assert_eq!(nav.navigation.current_folder_id(), Some(SMURFS)); + assert_eq!(nav.app.history.len(), 2); + + // The mouse back button pops it. + nav.navigation.set_search_query("abc"); + nav.run(|app, ctx| app.go_back(ctx)); + assert_eq!(nav.navigation.current_folder_id(), None); + assert_eq!(nav.navigation.search_query(), ""); + nav.run(|app, ctx| app.go_forward(ctx)); + assert_eq!(nav.navigation.current_folder_id(), Some(SMURFS)); + + // Alt+Left replaces the entry with the parent. + let effects = nav.run(|app, ctx| app.navigate_to_parent_folder(ctx)); + assert!(effects.contains(&AppEffect::QueueGridPadding)); + assert_eq!(nav.navigation.current_folder_id(), None); + assert_eq!(nav.app.history.state().folder_id, None); + assert!(nav + .run(|app, ctx| app.navigate_to_parent_folder(ctx)) + .is_empty()); + + // Without history tracking nothing is pushed. + let len = nav.app.history.len(); + nav.run(|app, ctx| app.navigate_to(Some(SMURFS.into()), false, ctx)); + assert_eq!(nav.app.history.len(), len); +} + +#[test] +fn pop_state_cancels_a_conflicting_add_flow_and_reloads_a_new_tab() { + let mut nav = Nav::new("steam", false, &["steam", "riot"]); + nav.add_flow = Some("steam".into()); + let entry = AppHistoryEntry::new("riot", None, false); + let effects = nav.run(|app, ctx| app.handle_pop_state(&entry, ctx)); + assert_eq!(effects[0], AppEffect::AddFlowCancel); + assert_eq!(calls(&effects), ["clear", "tab:riot", "load"]); +} + +#[test] +fn history_stack_truncates_forward_entries_on_push() { + let mut history = HistoryStack::new(AppHistoryEntry::new("steam", None, false)); + history.push(AppHistoryEntry::new("steam", Some("a".into()), false)); + history.push(AppHistoryEntry::new("steam", Some("b".into()), false)); + assert_eq!(history.back().unwrap().folder_id.as_deref(), Some("a")); + history.push(AppHistoryEntry::new("riot", None, false)); + assert_eq!(history.len(), 3); + assert_eq!(history.forward(), None); + history.back(); + history.back(); + assert_eq!(history.back(), None); +} + +#[test] +fn settings_tab_bar_falls_back_to_a_visible_tab() { + let mut bar = SettingsTabBar::new(); + assert_eq!(bar.active_tab(), "general"); + bar.select("steam"); + assert!(bar.take_scroll_request()); + assert!(!bar.take_scroll_request()); + let visible = vec!["general".to_string(), "riot".to_string()]; + assert_eq!( + bar.ensure_active_visible(&visible).as_deref(), + Some("general") + ); + assert_eq!(bar.ensure_active_visible(&visible), None); + // Like the TS, an empty list re-selects `general` every time. + assert_eq!(bar.ensure_active_visible(&[]).as_deref(), Some("general")); + bar.select("x"); + assert_eq!(bar.ensure_active_visible(&[]).as_deref(), Some("general")); + let mut panel = SettingsPanel::default(); + panel.open(); + assert_eq!( + panel.close(), + [AppEffect::RefreshSettings, AppEffect::SettingsClosed] + ); +} + +// ---------------------------------------------------------- card actions + +fn account(id: &str, name: &str) -> PlatformAccount { + PlatformAccount { + id: id.into(), + display_name: name.into(), + username: id.into(), + last_login_at_sec: None, + } +} + +fn cards<'a>( + rendered: &'a [PlatformAccount], + adapter: Option<&'a PlatformAdapter>, +) -> CardContext<'a> { + CardContext { + active_tab: "steam", + is_searching: false, + adapter, + bulk_edit_mode: false, + pending_setup_account_id: None, + rendered_accounts: rendered, + } +} + +#[test] +fn card_clicks_switch_toggle_or_wait_for_the_add_flow() { + let rendered = [account("a1", "Main")]; + let mut ctx = cards(&rendered, None); + let cancel = AppEffect::AddFlowCancelIfConflicting { + platform_id: "steam".into(), + account_id: Some("a1".into()), + }; + assert_eq!( + card_actions::account_activate(&ctx, &rendered[0]), + std::slice::from_ref(&cancel) + ); + assert_eq!( + card_actions::account_switch(&ctx, &rendered[0]), + [cancel, AppEffect::SwitchAccount(rendered[0].clone())] + ); + assert_eq!( + card_actions::workspace_mouse_down(&ctx), + [AppEffect::DragGridMouseDown] + ); + ctx.is_searching = true; + assert!(card_actions::workspace_mouse_down(&ctx).is_empty()); + + ctx.pending_setup_account_id = Some("a1"); + assert!(card_actions::account_switch(&ctx, &rendered[0]).is_empty()); + let mut dialogs = AppDialogs::new(); + assert!( + card_actions::account_context_menu(&mut dialogs, &ctx, &rendered[0], 0.0, 0.0).is_empty() + ); + assert!(dialogs.context_menu().is_none()); + + ctx.bulk_edit_mode = true; + assert!(card_actions::account_activate(&ctx, &rendered[0]).is_empty()); + assert_eq!( + card_actions::account_switch(&ctx, &rendered[0]), + [AppEffect::BulkEditToggleAccount("a1".into())] + ); + assert_eq!( + card_actions::account_context_menu(&mut dialogs, &ctx, &rendered[0], 0.0, 0.0), + [AppEffect::BulkEditToggleAccount("a1".into())] + ); + assert_eq!( + card_actions::workspace_mouse_down(&ctx), + [AppEffect::BulkEditPaintStart] + ); +} + +#[test] +fn focused_card_keys_open_rename_or_menu() { + let stores = demo_stores(); + let rendered = [account("a1", "Main")]; + let gog = PlatformAdapter::shipped("gog").unwrap(); + let ctx = cards(&rendered, None); + let mut dialogs = AppDialogs::new(); + + assert_eq!(card_actions::activate_focused_card(&ctx, None), None); + let folder = ItemRef::folder(SMURFS); + assert_eq!( + card_actions::activate_focused_card(&ctx, Some(&folder)), + Some(vec![AppEffect::NavigateToFolder(Some(SMURFS.into()))]) + ); + let item = ItemRef::account("a1"); + let effects = card_actions::activate_focused_card(&ctx, Some(&item)).unwrap(); + assert_eq!( + effects.last(), + Some(&AppEffect::SwitchAccount(rendered[0].clone())) + ); + assert_eq!( + card_actions::activate_focused_card(&ctx, Some(&ItemRef::account("gone"))), + None + ); + + // The menu opens at the card's centre, not when it is off screen. + let off_screen = FocusedCard { + item: &item, + center: None, + }; + assert!(card_actions::open_focused_card_context_menu( + &mut dialogs, + &ctx, + &stores, + Some(off_screen) + ) + .is_none()); + let shown = FocusedCard { + item: &folder, + center: Some((40.0, 60.0)), + }; + assert!( + card_actions::open_focused_card_context_menu(&mut dialogs, &ctx, &stores, Some(shown)) + .is_some() + ); + let menu = dialogs.context_menu().unwrap(); + assert_eq!((menu.x, menu.y), (40.0, 60.0)); + assert!(matches!(menu.target, ContextMenuTarget::Folder(_))); + + // F2: folders always, accounts only where labels exist. + assert!(card_actions::rename_focused_card( + &mut dialogs, + &ctx, + &stores, + Some(&folder) + )); + assert!(matches!( + dialogs.input_dialog().unwrap().purpose, + InputPurpose::RenameFolder { .. } + )); + dialogs.close_input_dialog(); + assert!(!card_actions::rename_focused_card( + &mut dialogs, + &ctx, + &stores, + Some(&item) + )); + let ctx = cards(&rendered, Some(&gog)); + assert!(card_actions::rename_focused_card( + &mut dialogs, + &ctx, + &stores, + Some(&item) + )); + assert!(matches!( + dialogs.input_dialog().unwrap().purpose, + InputPurpose::RenameAccount { .. } + )); + + let mut dialogs = AppDialogs::new(); + card_actions::background_context_menu(&mut dialogs, &ctx, 1.0, 2.0); + assert!(matches!( + dialogs.context_menu().unwrap().target, + ContextMenuTarget::Background + )); +} + +#[test] +fn bulk_bar_copies_urls_and_paints_card_colours() { + let mut stores = demo_stores(); + assert!(card_actions::copy_bulk_edit_urls(&[]).is_empty()); + let urls = vec!["https://a".to_string(), "https://b".to_string()]; + assert_eq!( + card_actions::copy_bulk_edit_urls(&urls), + [AppEffect::CopyBulkUrls(urls.clone())] + ); + assert_eq!( + card_actions::bulk_urls_copied_toast(2, true), + Toast::new( + Text::with("bulkEdit.urlsCopied", [("count", 2.0)]), + ToastKind::Success + ) + ); + assert_eq!( + card_actions::bulk_urls_copied_toast(2, false).kind, + ToastKind::Error + ); + + let ids = vec!["a1".to_string(), "a2".to_string()]; + assert!(card_actions::apply_bulk_edit_card_color(&mut stores, &[], "#fff").is_empty()); + let color = accshift_app::stores::ACCOUNT_CARD_COLOR_PRESETS[0].color; + let effects = card_actions::apply_bulk_edit_card_color(&mut stores, &ids, color); + assert_eq!(effects[0], AppEffect::CardColorsChanged); + assert_eq!(stores.account_card_color("a2"), color); + let effects = card_actions::apply_bulk_edit_card_color(&mut stores, &ids, ""); + assert_eq!( + effects[1], + AppEffect::Toast(Toast::new( + Text::with("bulkEdit.colorCleared", [("count", 2.0)]), + ToastKind::Success + )) + ); + assert_eq!(stores.account_card_color("a1"), ""); +} + +// ---------------------------------------------------- extension content + +struct ExtState { + warnings: BTreeMap, + notes: HashMap, + setup: Option>, + inline: bool, +} + +fn content( + ext: &mut ExtensionContent, + state: &ExtState, + ids: &[String], +) -> Arc>>>> { + let setup = |id: &str| (id == "a").then(|| state.setup.clone()).flatten(); + let note = |id: &str| state.notes.get(id).cloned().unwrap_or_default(); + let extra = |_: &str| Vec::new(); + ext.content_by_id(&ExtensionInputs { + locale: Locale::En, + warning_states: &state.warnings, + visible_ids: ids, + setup_content: &setup, + account_note: ¬e, + card_note_version: 0, + show_card_notes_inline: state.inline, + extra_sections: &extra, + extra_sections_version: 0, + }) +} + +fn warning(chips: &[&str], tooltip: &str) -> WarningPresentation { + WarningPresentation { + tooltip: Text::raw(tooltip), + card_outline_tone: None, + list_has_red: false, + list_has_orange: false, + chips: chips + .iter() + .map(|text| WarningChip { + tone: WarningTone::Red, + text: Text::raw(*text), + }) + .collect(), + } +} + +#[test] +fn extension_keeps_the_other_cards_content_when_one_account_changes() { + let ids = vec!["a".to_string(), "b".to_string()]; + let mut state = ExtState { + warnings: BTreeMap::new(), + notes: HashMap::from([("a".into(), "first".into()), ("b".into(), "second".into())]), + setup: None, + inline: false, + }; + let mut ext = ExtensionContent::new(); + let before = content(&mut ext, &state, &ids); + let same = content(&mut ext, &state, &ids); + assert!(Arc::ptr_eq(&before, &same)); + + state.notes.insert("b".into(), "changed".into()); + let after = content(&mut ext, &state, &ids); + let a_before = before["a"].as_ref().unwrap(); + let a_after = after["a"].as_ref().unwrap(); + assert!(Arc::ptr_eq(a_before, a_after)); + assert!(!Arc::ptr_eq( + before["b"].as_ref().unwrap(), + after["b"].as_ref().unwrap() + )); + assert_eq!(after["b"].as_ref().unwrap()[0].lines, ["changed"]); +} + +#[test] +fn extension_sees_a_chip_text_change_with_the_same_chip_count() { + let ids = vec!["a".to_string()]; + let mut state = ExtState { + warnings: BTreeMap::from([("a".into(), warning(&["VAC"], ""))]), + notes: HashMap::new(), + setup: None, + inline: false, + }; + let mut ext = ExtensionContent::new(); + let before = content(&mut ext, &state, &ids)["a"].clone().unwrap(); + state.warnings.insert("a".into(), warning(&["Trade"], "")); + let after = content(&mut ext, &state, &ids)["a"].clone().unwrap(); + assert!(!Arc::ptr_eq(&before, &after)); +} + +#[test] +fn extension_sections_follow_setup_warnings_notes_and_inline_mode() { + let ids = vec!["a".to_string(), "b".to_string()]; + let mut state = ExtState { + warnings: BTreeMap::from([("b".into(), warning(&[], "line one\n line two \n"))]), + notes: HashMap::from([("b".into(), " my note ".into())]), + setup: Some(vec![CardSection { + title: Some(Text::raw("setup")), + ..CardSection::default() + }]), + inline: false, + }; + let mut ext = ExtensionContent::new(); + let map = content(&mut ext, &state, &ids); + assert_eq!( + map["a"].as_ref().unwrap()[0].title, + Some(Text::raw("setup")) + ); + let b = map["b"].as_ref().unwrap(); + assert_eq!(b[0].title, Some(Text::key("card.extensionWarnings"))); + assert_eq!(b[0].text, Some(Text::raw("line one \u{2022} line two"))); + assert_eq!(b[1].lines, ["my note"]); + + state.inline = true; + state.setup = None; + let map = content(&mut ext, &state, &ids); + assert!(map["a"].is_none()); + assert_eq!(map["b"].as_ref().unwrap().len(), 1); + + let chips = warning_section(Some(&warning(&["VAC"], "tip")), Locale::En).unwrap(); + assert_eq!(chips.text, None); + assert!(chips.lines.is_empty()); + assert_eq!(chips.chips[0].tone, ChipTone::Red); + assert_eq!(note_section("x", true), None); + assert_eq!(note_section(" ", false), None); +} + +// ---------------------------------------------------------------- avatar + +#[test] +fn avatar_initials_follow_avatar_fallback_ts() { + assert_eq!(avatar_initials("John Doe"), "JD"); + assert_eq!(avatar_initials("Alice"), "A"); + assert_eq!(avatar_initials(""), "?"); + assert_eq!(avatar_initials(" "), "?"); + assert_eq!(avatar_initials("john doe"), "JD"); + assert_eq!(avatar_initials("John Doe"), "JD"); +} + +#[test] +fn avatar_seed_follows_avatar_fallback_ts() { + let seed = avatar_seed("Alice", "alice_user", "123"); + assert!(seed.contains("Alice") && seed.contains("123")); + assert!(avatar_seed("", "bob_user", "456").contains("bob_user")); + assert_eq!(avatar_seed("X", "Y", "Z"), avatar_seed("X", "Y", "Z")); + assert_ne!( + avatar_seed("Same", "same", "111"), + avatar_seed("Same", "same", "222") + ); + assert_eq!(avatar_seed("Ab", "", "1"), "Ab::1::bA"); + assert_eq!(avatar_seed(" ", "", "1"), "unknown::1::nwonknu"); +} + +#[test] +fn avatar_gradient_follows_avatar_fallback_ts() { + let css = avatar_gradient("test-seed").css(); + assert!(css.contains("background-color:hsl(")); + assert!(css.contains("background-image:linear-gradient(")); + assert_eq!(avatar_gradient("x"), avatar_gradient("x")); + assert_ne!(avatar_gradient("a").css(), avatar_gradient("b").css()); + assert_eq!( + avatar_gradient("a").css(), + avatar_gradient(" A ").css(), + "trimmed and lowercased first" + ); + let mut cache = GradientCache::default(); + assert_eq!(cache.get("x"), avatar_gradient("x")); + assert_eq!(cache.len(), 1); +} + +// ------------------------------------------------------------ onboarding + +#[test] +fn onboarding_tour_swaps_to_steam_and_puts_the_tab_back() { + let accounts = mock_accounts(); + assert_eq!(accounts.len(), 3); + assert_eq!(accounts[1].account.id, MOCK_IDS[1]); + assert_eq!(accounts[1].account.username, "account_2"); + assert_eq!( + accounts[1].display_name, + Text::with("onboarding.features.mockAccount", [("number", 2.0)]) + ); + assert_eq!(mock_items()[2], ItemRef::account("__tour_mock_3")); + + let mut tour = OnboardingTour::new(); + tour.open_onboarding(); + assert_eq!( + tour.set_mock_active(true, "riot"), + [AppEffect::SetActiveTab("steam".into())] + ); + assert!(tour.mock_active()); + assert_eq!( + tour.close("steam"), + [AppEffect::SetActiveTab("riot".into())] + ); + assert!(!tour.open() && !tour.mock_active()); + + // Already on Steam: no tab change either way. + assert!(tour.set_mock_active(true, "steam").is_empty()); + assert!(tour.set_mock_active(false, "steam").is_empty()); +} + +#[test] +fn onboarding_opens_on_a_first_launch_only() { + let backend = Arc::new(support::scripted("demo")); + backend.push( + "telemetry_get_state", + Ok(json!({ + "mode_a_enabled": false, + "mode_b_enabled": false, + "install_id_set": false, + "forget_pending": false, + "onboarding_completed": false + })), + ); + backend.push("telemetry_get_state", Err("no telemetry")); + let jobs = Jobs::new(backend.clone() as Arc, 1, || {}); + let poll = |tour: &mut OnboardingTour| { + let deadline = Instant::now() + Duration::from_secs(5); + let opened = RefCell::new(false); + while Instant::now() < deadline { + if tour.poll() { + *opened.borrow_mut() = true; + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + opened.into_inner() + }; + + let mut tour = OnboardingTour::new(); + tour.start_open_if_never_completed(&jobs); + assert!(poll(&mut tour)); + assert!(tour.open()); + + let mut failed = OnboardingTour::new(); + failed.start_open_if_never_completed(&jobs); + std::thread::sleep(Duration::from_millis(50)); + assert!(!poll(&mut failed)); + assert!(!failed.open()); +} + +// -------------------------------------------------------------- shell + +#[test] +fn shell_glue_matches_app_svelte() { + assert_eq!( + shell::copy_result_toast("Steam ID", true), + Toast::new( + Text::with("toast.copied", [("label", "Steam ID")]), + ToastKind::Success + ) + ); + assert_eq!(shell::copy_result_toast("x", false).kind, ToastKind::Error); + assert_eq!(shell::color_label("red"), Text::key("color.red")); + + let mut stores = demo_stores(); + assert_eq!( + shell::view_mode_change(&mut stores, ViewMode::Grid), + [ + AppEffect::ViewModeChanged(ViewMode::Grid), + AppEffect::QueueGridPadding + ] + ); + assert_eq!( + shell::view_mode_change(&mut stores, ViewMode::List), + [AppEffect::ViewModeChanged(ViewMode::List)] + ); + assert_eq!(stores.view_mode(), ViewMode::List); + assert_eq!(shell::toggled_view_mode(ViewMode::List), ViewMode::Grid); + + assert_eq!(shell::apply_update(true, false), []); + assert_eq!(shell::apply_update(false, true), []); + assert_eq!(shell::apply_update(false, false), [AppEffect::ApplyUpdate]); + assert_eq!(shell::panel_key(true, true, "steam"), "__settings__"); + assert_eq!(shell::panel_key(false, true, "steam"), "__personas__"); + assert_eq!(shell::panel_key(false, false, "steam"), "steam"); + + let registry = PlatformRegistry::shipped(); + let steam = registry.definition("steam"); + assert!(shell::is_bulk_edit_toggle_allowed(steam, true, false)); + assert!(!shell::is_bulk_edit_toggle_allowed(steam, true, true)); + assert!(!shell::is_bulk_edit_toggle_allowed(None, true, false)); + assert_eq!(shell::active_platform_name(steam, "steam"), "Steam"); + assert_eq!(shell::active_platform_name(None, "x"), "x"); + assert_eq!(shell::last_login_unknown_key(None), "time.unknown"); + assert_eq!( + shell::detected_platform_defs(registry.defs(), &["riot".into(), "steam".into()]), + [ + ("steam".to_string(), "Steam".to_string()), + ("riot".to_string(), "Riot Games".to_string()) + ] + ); + assert!(shell::cs2_sections_for_tab("riot", None).is_empty()); + assert!(shell::cs2_badge_for_tab("riot", None).is_none()); + + let flags = shell::title_bar_flags(&TitleBarInput { + active_tab_usable: true, + bulk_edit_capability: true, + persona_switching: true, + ..TitleBarInput::default() + }); + assert!(flags.can_refresh && flags.can_add_account && flags.show_bulk_edit); + assert!(flags.update_cta_disabled); + let flags = shell::title_bar_flags(&TitleBarInput { + active_tab_usable: true, + personas_open: true, + ..TitleBarInput::default() + }); + assert!(!flags.can_refresh && !flags.can_add_account && !flags.show_bulk_edit); +} + +#[test] +fn shell_routes_palette_and_key_actions() { + let mut stores = demo_stores(); + let mut dialogs = AppDialogs::new(); + let mut personas = PersonaSwitch::new(); + personas.show_personas = true; + let mut shell = ShellActions { + dialogs: &mut dialogs, + persona_switch: &mut personas, + stores: &mut stores, + active_tab: "steam", + view_mode: ViewMode::Grid, + personas_enabled: true, + settings_open: false, + }; + let main = account("a1", "Main"); + assert_eq!( + shell.run_palette_action(&PaletteAction::SwitchToAccount(main.clone())), + [ + AppEffect::AddFlowCancelIfConflicting { + platform_id: "steam".into(), + account_id: Some("a1".into()) + }, + AppEffect::SwitchAccount(main) + ] + ); + assert_eq!( + shell.run_palette_action(&PaletteAction::ChangeTab("riot".into())), + [AppEffect::ChangeTab("riot".into())] + ); + assert!(!shell.persona_switch.show_personas); + assert_eq!( + shell.run_palette_action(&PaletteAction::NavigateToParent), + shell::navigate_back("steam") + ); + assert_eq!( + shell.run_palette_action(&PaletteAction::ToggleViewMode), + [AppEffect::ViewModeChanged(ViewMode::List)] + ); + assert_eq!( + shell.run_palette_action(&PaletteAction::OpenPersonas), + [AppEffect::CloseBulkEdit] + ); + assert!(shell + .run_palette_action(&PaletteAction::NewFolder) + .is_empty()); + assert!(shell.dialogs.input_dialog().is_some()); + + assert_eq!(shell.run_key_action(&KeyAction::FocusSearch), None); + assert_eq!( + shell.run_key_action(&KeyAction::CloseInputDialog), + Some(vec![]) + ); + assert!(shell.dialogs.input_dialog().is_none()); + assert_eq!( + shell.run_key_action(&KeyAction::NavigateBack), + Some(shell::navigate_back("steam")) + ); + assert_eq!( + shell.run_key_action(&KeyAction::ToggleBulkEditAccount("a1".into())), + Some(vec![AppEffect::BulkEditToggleAccount("a1".into())]) + ); + assert_eq!( + shell::title_bar_open_settings(shell.persona_switch), + [AppEffect::ToggleSettingsPanel] + ); +} From 339ef73014bd7681982a598e22088d68d86aa0a8 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:53:27 +0200 Subject: [PATCH 026/106] fix(egui): compute text line boxes in device px like zoom-for-DSF --- crates/accshift-egui/src/ui/text.rs | 103 ++++++++++++++++++---------- 1 file changed, 66 insertions(+), 37 deletions(-) diff --git a/crates/accshift-egui/src/ui/text.rs b/crates/accshift-egui/src/ui/text.rs index bc5919e..eeaf14a 100644 --- a/crates/accshift-egui/src/ui/text.rs +++ b/crates/accshift-egui/src/ui/text.rs @@ -8,10 +8,10 @@ //! - [`TextStyle`], a CSS text description (family stack, size, weight, //! `line-height`, `letter-spacing`, `text-transform`, `white-space: nowrap`, //! `text-overflow: ellipsis`, `text-align`, colour); -//! - the line box: Chromium computes it in CSS px with the face's ascent and -//! descent rounded separately and the half-leading floored, then scales it -//! by the device pixel ratio ([`LineBox`]); every glyph is moved onto that -//! baseline, whatever face drew it; +//! - the line box: Chromium lays out in device px (zoom for DSF), with the +//! face's ascent and descent rounded separately at the device font size and +//! the half-leading floored to whole device px ([`LineBox`]); every glyph +//! is moved onto that baseline, whatever face drew it; //! - the coverage curve of Skia's grayscale glyphs ([`gamma`]), applied by the //! software backend. //! @@ -242,7 +242,12 @@ impl TextStyle { pub fn line_box(&self, ctx: &Context) -> Option { let ppem = self.size * ctx.pixels_per_point(); let resolved = resolve(ctx, &self.family, self.weight, ppem)?; - Some(LineBox::new(&resolved.metrics, self.size, self.line_height)) + Some(LineBox::new( + &resolved.metrics, + self.size, + self.line_height, + ctx.pixels_per_point(), + )) } fn transformed(&self, text: &str) -> String { @@ -270,7 +275,7 @@ impl TextStyle { } } -/// One line box, in CSS px. +/// One line box, in CSS px (device px divided by the scale). #[derive(Clone, Copy, Debug, PartialEq)] pub struct LineBox { pub height: f32, @@ -279,24 +284,27 @@ pub struct LineBox { } impl LineBox { - /// Chromium's line box: ascent and descent rounded separately at the CSS - /// font size, the line height kept to LayoutUnit precision (1/64 px), the - /// half-leading floored to whole px. Matches the 252 baselines of the - /// typography fragments at scales 1, 1.25 and 1.5. - pub fn new(m: &FaceMetrics, size: f32, line_height: LineHeight) -> Self { - let scale = size / m.units_per_em; - let ascent = (m.ascent * scale).round(); - let descent = (m.descent * scale).round(); + /// Chromium's line box at device scale `scale`: Blink lays out in device + /// px (zoom for DSF), so ascent and descent are rounded separately at the + /// device font size, the line height kept to LayoutUnit precision (1/64 + /// device px), the half-leading floored to whole device px. Matches the + /// 252 baselines of the typography fragments at scales 1, 1.25 and 1.5 + /// (WebView2 run with `--force-device-scale-factor`). + pub fn new(m: &FaceMetrics, size: f32, line_height: LineHeight, scale: f32) -> Self { + let device = size * scale; + let em = device / m.units_per_em; + let ascent = (m.ascent * em).round(); + let descent = (m.descent * em).round(); let height = match line_height { - LineHeight::Normal => ascent + descent + (m.line_gap * scale).round(), - LineHeight::Number(n) => n * size, - LineHeight::Px(px) => px, + LineHeight::Normal => ascent + descent + (m.line_gap * em).round(), + LineHeight::Number(n) => n * device, + LineHeight::Px(px) => px * scale, }; let height = (height * 64.0).floor() / 64.0; let half_leading = ((height - (ascent + descent)) / 2.0).floor(); Self { - height, - baseline: ascent + half_leading, + height: height / scale, + baseline: (ascent + half_leading) / scale, } } } @@ -347,7 +355,7 @@ pub fn layout(ctx: &Context, text: &str, style: &TextStyle, rect: Rect) -> Optio need_cjk(ctx); } let ppp = ctx.pixels_per_point(); - let line_box = LineBox::new(&resolved.metrics, style.size, style.line_height); + let line_box = LineBox::new(&resolved.metrics, style.size, style.line_height, ppp); let avail = if rect.width().is_finite() && rect.width() > 0.0 { rect.width() } else { @@ -412,7 +420,10 @@ pub fn layout(ctx: &Context, text: &str, style: &TextStyle, rect: Rect) -> Optio let x = rect.left() + offset(widths[i]); placed.pos.x = (x * ppp).round() / ppp - origin_x; } - let baseline = (rect.top() + line_box.baseline + i as f32 * line_box.height) * ppp; + // In LayoutUnits (1/64 device px), so that a line box scaled back + // from CSS px lands exactly where Blink put it. + let baseline = layout_unit(rect.top() * ppp) + + layout_unit((line_box.baseline + i as f32 * line_box.height) * ppp); let target = snap(baseline) / ppp - origin_y - placed.pos.y; let row = Arc::make_mut(&mut placed.row); // The pen in device px: egui puts every CJK glyph on a whole pixel @@ -839,6 +850,11 @@ fn snap(v: f32) -> f32 { (v + 0.5).floor() } +/// Blink's LayoutUnit precision, 1/64 device px. +fn layout_unit(v: f32) -> f32 { + (v * 64.0).round() / 64.0 +} + #[cfg(test)] mod tests { use super::*; @@ -856,25 +872,38 @@ mod tests { #[test] fn line_boxes_match_the_probe() { - // From typography-100/probe.json: (size, line-height, baseline). - for (size, lh, baseline) in [ - (8.0, LineHeight::Number(1.0), 7.0), - (9.0, LineHeight::Number(1.5), 10.0), - (9.0, LineHeight::Number(1.2), 9.0), - (9.0, LineHeight::Number(1.0), 8.0), - (10.0, LineHeight::Px(13.0), 10.0), - (11.0, LineHeight::Number(1.45), 12.0), - (12.0, LineHeight::Number(1.5), 14.0), - (12.0, LineHeight::Number(1.55), 14.0), - (12.5, LineHeight::Number(1.6), 15.0), - (14.0, LineHeight::Number(1.0), 12.0), - (16.0, LineHeight::Number(1.5), 18.0), + // From typography-{100,125,150}/probe.json, WebView2 at a real device + // scale factor: (scale, size, line-height, baseline in device px). + for (scale, size, lh, baseline) in [ + (1.0, 8.0, LineHeight::Number(1.0), 7.0), + (1.0, 9.0, LineHeight::Number(1.5), 10.0), + (1.0, 9.0, LineHeight::Number(1.2), 9.0), + (1.0, 9.0, LineHeight::Number(1.0), 8.0), + (1.0, 10.0, LineHeight::Px(13.0), 10.0), + (1.0, 11.0, LineHeight::Number(1.45), 12.0), + (1.0, 12.0, LineHeight::Number(1.5), 14.0), + (1.0, 12.0, LineHeight::Number(1.55), 14.0), + (1.0, 12.5, LineHeight::Number(1.6), 15.0), + (1.0, 14.0, LineHeight::Number(1.0), 12.0), + (1.0, 16.0, LineHeight::Number(1.5), 18.0), + (1.25, 8.0, LineHeight::Number(1.0), 9.0), + (1.25, 9.0, LineHeight::Number(1.2), 11.0), + (1.25, 10.0, LineHeight::Px(13.0), 13.0), + (1.25, 11.0, LineHeight::Number(1.45), 15.0), + (1.25, 12.0, LineHeight::Number(1.0), 13.0), + (1.25, 16.0, LineHeight::Number(1.5), 23.0), + (1.5, 8.0, LineHeight::Number(1.0), 11.0), + (1.5, 9.0, LineHeight::Number(1.5), 16.0), + (1.5, 9.0, LineHeight::Number(1.2), 14.0), + (1.5, 10.0, LineHeight::Px(13.0), 15.0), + (1.5, 11.0, LineHeight::Number(1.45), 18.0), + (1.5, 12.0, LineHeight::Number(1.5), 20.0), ] { - let lb = LineBox::new(&segoe(), size, lh); - assert_eq!(lb.baseline, baseline, "{size}px {lh:?}"); + let lb = LineBox::new(&segoe(), size, lh, scale); + assert_eq!(lb.baseline * scale, baseline, "{size}px {lh:?} at {scale}"); } assert_eq!( - LineBox::new(&segoe(), 12.0, LineHeight::Normal).height, + LineBox::new(&segoe(), 12.0, LineHeight::Normal, 1.0).height, 16.0 ); } From 28aa6bcfea51491a6a99583dab9acd34476135d9 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:15:01 +0200 Subject: [PATCH 027/106] feat(egui): snap boxes, borders and title bar to Chromium's device grid --- crates/accshift-egui/parity/README.md | 26 + .../parity/scenes/titlebar-hover-125.json | 35 ++ .../scenes/titlebar-idle-linux-125.json | 9 + .../scenes/titlebar-idle-macos-125.json | 9 + .../scenes/titlebar-idle-windows-125.json | 9 + .../parity/scenes/titlebar-tabs-125.json | 27 + crates/accshift-egui/parity/snap/measure.mjs | 548 ++++++++++++++++++ .../accshift-egui/src/ui/components/icon.rs | 6 +- .../src/ui/components/title_bar.rs | 116 ++-- crates/accshift-egui/src/ui/mod.rs | 5 +- crates/accshift-egui/src/ui/paint.rs | 131 +++-- crates/accshift-egui/src/ui/snap.rs | 176 ++++++ 12 files changed, 1017 insertions(+), 80 deletions(-) create mode 100644 crates/accshift-egui/parity/scenes/titlebar-hover-125.json create mode 100644 crates/accshift-egui/parity/scenes/titlebar-idle-linux-125.json create mode 100644 crates/accshift-egui/parity/scenes/titlebar-idle-macos-125.json create mode 100644 crates/accshift-egui/parity/scenes/titlebar-idle-windows-125.json create mode 100644 crates/accshift-egui/parity/scenes/titlebar-tabs-125.json create mode 100644 crates/accshift-egui/parity/snap/measure.mjs create mode 100644 crates/accshift-egui/src/ui/snap.rs diff --git a/crates/accshift-egui/parity/README.md b/crates/accshift-egui/parity/README.md index bef69c0..d4c2c46 100644 --- a/crates/accshift-egui/parity/README.md +++ b/crates/accshift-egui/parity/README.md @@ -87,6 +87,32 @@ Before each scene, the page is reloaded into its mock state, and runs once every Both images are decoded to RGBA by ffmpeg (PNG or PAM) and composited over black. Per shot: mean absolute error over RGB (0 to 255), PSNR over RGB in dB (99 when identical), percent of pixels whose largest channel delta exceeds 8 and 24, the worst 16x16 tile with its mean error, a heat map (`-heat.png`, largest channel delta times 4 on a black, red, yellow, white ramp) and a side-by-side (`-side.png`, reference on top). Batch mode writes `report.json` and `report.md`, one row per shot. +`--region x,y,w,h` limits every number and both images to one rectangle, in CSS px of the viewport. Batch mode scales it by each scene's `viewport.scale` (read from `scenes/.json`, a scene without a file is skipped), a single pair by `--scale ` (default 1); edges are rounded, so `0,0,860,36` is 54 device rows at 1.5. The worst tile is reported in image pixels of the full frame. The title bar alone: `node parity/diff.mjs --batch --out

--region 0,0,860,36`. + +`ref.mjs` rewrites `/manifest.json` with the scenes of the current run only: capturing more scenes into an existing reference folder drops the earlier ones from the manifest. Use a fresh folder per run, or delete the manifest so `diff.mjs` pairs every PNG it finds. + +## Device-pixel snapping + +Where WebView2 puts a box at a real device scale factor is measured, not assumed: `snap/measure.mjs` renders a fragment of 25 cases (backgrounds, borders, radii, shadows, translations, `will-change`, flex centring, SVG roots, `img`, `opacity`) at six fractional CSS offsets, in the real app at each scale, and reads the painted edges from the frame. + +```sh +node crates/accshift-egui/parity/snap/measure.mjs --out --exe --scales 1,1.25,1.5,1.75 +``` + +It writes `cases.json` (layout rect and painted edges per case, offset and scale), `report.md`, one `frame.png` per scale and `titlebar.json` (the title bar's boxes on the `demo` scenario). `--dsf emulate` measures the CDP emulation instead, which is what the references were before 2026-10-12. Same safety as `ref.mjs`. + +The rules below hold on every case at 1, 1.25, 1.5 and 1.75, within 0.1 device px; `src/ui/snap.rs` implements them and `ui::paint` and the components use it. Layout runs in device px with 1/64 px units, so a position is `css * scale` quantized to 1/64 before any rounding. + +| Case | Rule | +| --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| Background, rounded box, `box-shadow` base, `img`, inline `svg` root, `opacity`, flex-centred child | Each edge rounded half up on its own: `round(x)` to `round(x + w)` (`snap::rect`). | +| `border-width` | Floored to whole device px, 1 minimum when non-zero: 1 CSS px is 1 device px at 1.25, 1.5 and 1.75, 2 CSS px is 2, 3 and 3 (`snap::border_device`). | +| `box-shadow` | The snapped border box plus `offset * scale`, blurred; a 4 px offset is whole at every scale tested. | +| `transform: translate()`, `will-change: transform`, running animation | The untransformed box snapped, then the translation added exactly: a fractional translation paints anti-aliased edges (`snap::translated`). | +| `will-change: transform` without translation | Origin rounded, size rounded from the origin. | +| Contents of a scroll container with real overflow (the title bar `.tabs`, whose tooltips overflow) | Composited: the whole offset, translation included, rounded (`snap::composited`). Measured on the title bar at 1.25, 1.5 and 1.75. | +| Flex centring | Free space halved in 1/64 px units, then the child snapped as a box (`snap::centre`). | + ## Mock data `data/.json`, format 1: `spec` (label, runtime OS, switch delay, Steam path, API key flag, current account per platform), `commands` (per command, a list of `{ args, result }` or `{ args, error }`), `skipped` (commands with state that are modelled from `spec` instead: the switch and the PIN session). Platform commands are called once per platform id, per-account commands once per account. Avatar URLs are repo-relative paths under `src/lib/mock/avatars/`, cache timestamps are exactly `NOW * 1000`. The full description sits at the top of `dump-data.mjs`. diff --git a/crates/accshift-egui/parity/scenes/titlebar-hover-125.json b/crates/accshift-egui/parity/scenes/titlebar-hover-125.json new file mode 100644 index 0000000..8a1a4ac --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-hover-125.json @@ -0,0 +1,35 @@ +{ + "id": "titlebar-hover-125", + "scenario": "demo", + "os": "windows", + "viewport": { "width": 860, "height": 440, "scale": 1.25 }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { "wait": 600 }, + { "move": [23, 18] }, + { "wait": 600 }, + { "shot": "refresh" }, + { "move": [55, 18] }, + { "wait": 600 }, + { "shot": "add" }, + { "move": [87, 18] }, + { "wait": 600 }, + { "shot": "settings" }, + { "move": [119, 18] }, + { "wait": 600 }, + { "shot": "bulk-edit" }, + { "move": [447, 18] }, + { "wait": 600 }, + { "shot": "tab-riot" }, + { "move": [745, 18] }, + { "wait": 600 }, + { "shot": "minimize" }, + { "move": [791, 18] }, + { "wait": 600 }, + { "shot": "maximize" }, + { "move": [837, 18] }, + { "wait": 600 }, + { "shot": "close" } + ] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-idle-linux-125.json b/crates/accshift-egui/parity/scenes/titlebar-idle-linux-125.json new file mode 100644 index 0000000..ee543d9 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-idle-linux-125.json @@ -0,0 +1,9 @@ +{ + "id": "titlebar-idle-linux-125", + "scenario": "demo", + "os": "linux", + "viewport": { "width": 860, "height": 440, "scale": 1.25 }, + "theme": "glass-dark", + "locale": "en", + "steps": [{ "wait": 600 }, { "move": [250, 300] }, { "wait": 300 }, { "shot": "idle" }] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-idle-macos-125.json b/crates/accshift-egui/parity/scenes/titlebar-idle-macos-125.json new file mode 100644 index 0000000..c0757de --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-idle-macos-125.json @@ -0,0 +1,9 @@ +{ + "id": "titlebar-idle-macos-125", + "scenario": "demo", + "os": "macos", + "viewport": { "width": 860, "height": 440, "scale": 1.25 }, + "theme": "glass-dark", + "locale": "en", + "steps": [{ "wait": 600 }, { "move": [250, 300] }, { "wait": 300 }, { "shot": "idle" }] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-idle-windows-125.json b/crates/accshift-egui/parity/scenes/titlebar-idle-windows-125.json new file mode 100644 index 0000000..f19718b --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-idle-windows-125.json @@ -0,0 +1,9 @@ +{ + "id": "titlebar-idle-windows-125", + "scenario": "demo", + "os": "windows", + "viewport": { "width": 860, "height": 440, "scale": 1.25 }, + "theme": "glass-dark", + "locale": "en", + "steps": [{ "wait": 600 }, { "move": [250, 300] }, { "wait": 300 }, { "shot": "idle" }] +} diff --git a/crates/accshift-egui/parity/scenes/titlebar-tabs-125.json b/crates/accshift-egui/parity/scenes/titlebar-tabs-125.json new file mode 100644 index 0000000..980a220 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/titlebar-tabs-125.json @@ -0,0 +1,27 @@ +{ + "id": "titlebar-tabs-125", + "scenario": "demo", + "os": "windows", + "viewport": { "width": 860, "height": 440, "scale": 1.25 }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { "wait": 600 }, + { "click": [379, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "personas" }, + { "click": [413, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "steam" }, + { "click": [447, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "riot" }, + { "click": [481, 18] }, + { "move": [250, 18] }, + { "wait": 700 }, + { "shot": "roblox" } + ] +} diff --git a/crates/accshift-egui/parity/snap/measure.mjs b/crates/accshift-egui/parity/snap/measure.mjs new file mode 100644 index 0000000..ae43552 --- /dev/null +++ b/crates/accshift-egui/parity/snap/measure.mjs @@ -0,0 +1,548 @@ +// Device-pixel snapping probe: where WebView2 paints a box, a border, a +// shadow, an SVG root, an image, compared with where layout puts it. +// +// node crates/accshift-egui/parity/snap/measure.mjs --out [--exe ] [--scales 1,1.25,1.5,1.75] [--dsf emulate|force] +// +// One generated fragment (written to /snap.html): a grid of cases, one +// row per case, one column per fractional CSS offset (0, .25, .3, .5, .7, +// .75 added to whole-pixel cell origins), every case white on a black page. +// At each scale the fragment replaces the page body, then for every +// `[data-case]` element the script records `getBoundingClientRect()` (layout, +// CSS px) and reads the painted edges from a screenshot: along the row and +// the column through the element's centre, the coverage of a device pixel is +// its green level over the case's full level (`data-fg`, 255 by default; +// Skia blends in gamma space, so coverage is linear in the stored value). +// +// Per axis a case is measured one of three ways (`data-h`, `data-v`): +// solid a filled span: left = s + sum(1 - c) up to the centre, right = +// centre + sum(c) after it +// start a band in the first half (a left or top border): its coverage +// centroid and its width sum(c), so its outer edge is +// centroid - width / 2 +// end a band in the second half (a bottom border, an offset shadow) +// +// `--dsf force` (default) launches the app once per scale with the real +// device scale factor (launchApp `scale`, as ref.mjs does); `--dsf emulate` +// keeps one app and sets the factor through CDP emulation, which lays the +// page out at the window's own factor and scales the frame (kept to show the +// difference). +// +// It also boots the app on the `demo` scenario and records the rects of the +// title bar's boxes and SVG roots at each scale (`/titlebar.json`). +// +// Output: //frame.png, /cases.json (every measure), +// /report.md (one table per case). Same safety as ref.mjs: hidden +// window, scratch app dirs, real app dirs and `accshift://` checked. + +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { crc32, deflateSync } from "node:zlib"; +import { + DEV_URL, + PIN_CLOCK, + defaultExe, + deepLinkRegistration, + diffTrees, + launchApp, + realAppDirs, + sleep, + snapshotTree, + startVite, + windowWatch, +} from "../harness.mjs"; + +const OFFSETS = [0, 0.25, 0.3, 0.5, 0.7, 0.75]; +const CELL_W = 64; +const CELL_H = 40; +const LEFT = 8; +const TOP = 8; + +// A white PNG (avatars are ), 20x10. +function whitePng(w, h) { + const chunk = (type, data) => { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const td = Buffer.concat([Buffer.from(type, "latin1"), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(td) >>> 0); + return Buffer.concat([len, td, crc]); + }; + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(w, 0); + ihdr.writeUInt32BE(h, 4); + ihdr[8] = 8; + ihdr[9] = 2; + const raw = Buffer.alloc((w * 3 + 1) * h, 255); + for (let y = 0; y < h; y++) raw[y * (w * 3 + 1)] = 0; + const png = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk("IHDR", ihdr), + chunk("IDAT", deflateSync(raw)), + chunk("IEND", Buffer.alloc(0)), + ]); + return `data:image/png;base64,${png.toString("base64")}`; +} + +const SVG12 = + ''; + +// Each case: html(x, y, f) for a cell whose whole-pixel origin is x, y and +// whose fractional offset is f. The measured element carries data-case. +const CASES = [ + { + id: "bg", + note: "square box background", + html: (x, y, f) => + `
`, + }, + { + id: "bg-fracsize", + note: "square box, fractional size 30.4x16.4", + html: (x, y, f) => + `
`, + }, + { + id: "border-all", + note: "square box, 1px border on all sides, no fill", + h: "start", + v: "start", + html: (x, y, f) => + `
`, + }, + { + id: "border-one", + note: "square box, 1px border-left and border-bottom only", + h: "start", + v: "end", + html: (x, y, f) => + `
`, + }, + { + id: "border-2", + note: "square box, 2px border, no fill", + h: "start", + v: "start", + html: (x, y, f) => + `
`, + }, + { + id: "rounded", + note: "rounded box (radius 6) background", + html: (x, y, f) => + `
`, + }, + { + id: "rounded-border", + note: "rounded box (radius 6), 1px border, no fill", + h: "start", + v: "start", + html: (x, y, f) => + `
`, + }, + { + id: "shadow", + note: "square box, box-shadow 4px 4px 0 white, no fill (the strip right of and under the box)", + h: "end", + v: "end", + html: (x, y, f) => + `
`, + }, + { + id: "shadow-rounded", + note: "rounded box (radius 6), box-shadow 4px 4px 0 white, no fill", + h: "end", + v: "end", + html: (x, y, f) => + `
`, + }, + { + id: "translate", + note: "square box at a whole left/top, transform: translate(f, f)", + html: (x, y, f) => + `
`, + }, + { + id: "translate-rounded", + note: "rounded box (radius 4), transform: translate(f, f)", + html: (x, y, f) => + `
`, + }, + { + id: "will-change", + note: "square box, will-change: transform, at a fractional left/top", + html: (x, y, f) => + `
`, + }, + { + id: "will-change-translate", + note: "square box, will-change: transform, transform: translate(f, f)", + html: (x, y, f) => + `
`, + }, + { + id: "animated", + note: "square box, running transform animation holding translate(f, f)", + html: (x, y, f) => + `
`, + }, + { + id: "flex-center", + note: "flex row 31x17 + f at x + f, centring a 20x10 child (remainder 5.5, 3.5)", + html: (x, y, f) => + `
`, + }, + { + id: "flex-translate", + note: "like .tabs: left/top 50% of a 36px bar, translate(-50%, -50%), a 32x28 child", + html: (x, y, f) => + `
`, + }, + { + id: "overflow-translate", + note: "overflow-x: auto box translated by (f, f), its white 20x10 child measured", + html: (x, y, f) => + `
`, + }, + { + id: "hidden-translate", + note: "overflow: hidden box translated by (f, f), its white 20x10 child measured", + html: (x, y, f) => + `
`, + }, + { + id: "tabs-overflow", + note: "the title bar's .tabs (overflow-x: auto, translate(-50%, -50%)) in a 36px bar, a 33x28 child", + html: (x, y, f) => + `
`, + }, + { + id: "svg-flex", + note: "inline SVG 12x12 (display:block) centred in a 31x17 flex box at x + f", + html: (x, y, f) => + `
${SVG12.replace("`, + }, + { + id: "svg-abs", + note: "inline SVG 12x12, absolutely positioned at x + f", + html: (x, y, f) => + SVG12.replace( + " + SVG12.replace( + " + ``, + }, + { + id: "img-rounded", + note: "img 20x10, border-radius 4, at x + f", + html: (x, y, f) => + ``, + }, + { + id: "opacity", + note: "square box, opacity 0.5", + fg: 128, + html: (x, y, f) => + `
`, + }, +]; + +function fragment() { + const style = ``; + const cells = []; + CASES.forEach((c, row) => { + OFFSETS.forEach((f, col) => { + const x = LEFT + col * CELL_W; + const y = TOP + row * CELL_H + 6; + cells.push(c.html(x, y, f).replace("data-case", `data-case="${c.id}" data-f="${f}"`)); + }); + }); + return `${style}\n${cells.join("\n")}\n`; +} + +const VIEWPORT = { + width: LEFT * 2 + OFFSETS.length * CELL_W, + height: TOP * 2 + CASES.length * CELL_H, +}; + +const MEASURE = ` +(() => [...document.querySelectorAll("[data-case]")].map((e) => { + const r = e.getBoundingClientRect(); + return { id: e.dataset.case, f: Number(e.dataset.f), rect: [r.left, r.top, r.right, r.bottom] }; +}))() +`; + +// The title bar of the booted app: boxes and SVG roots. +const TITLEBAR = ` +(() => { + const pick = (sel) => [...document.querySelectorAll(sel)].map((e) => { + const r = e.getBoundingClientRect(); + return [r.left, r.top, r.right, r.bottom]; + }); + return { + titlebar: pick(".titlebar"), + tabs: pick(".tabs"), + tab: pick(".tab"), + tabSvg: pick(".tab svg"), + btn: pick(".titlebar .btn"), + btnSvg: pick(".titlebar .btn svg"), + winBtn: pick(".win-btn"), + winSvg: pick(".win-btn svg"), + }; +})() +`; + +const APPLY_DEMO = ` +(async () => { + const { SCENARIOS } = await import("/src/lib/mock/scenarios.ts"); + const base = SCENARIOS.demo().stores; + const settings = { ...(base["client.settings"] ?? {}), themeId: "glass-dark", language: "en" }; + localStorage.clear(); + sessionStorage.clear(); + localStorage.setItem("accshift.mock.overrides", JSON.stringify({ + scenario: "demo", runtimeOs: "windows", stores: { "client.settings": settings }, + })); + return true; +})() +`; + +/** Painted edges of one case along one axis. */ +function edges(img, kind, fg, axis, lo, hi, line) { + const at = (i) => { + const [x, y] = axis === "h" ? [i, line] : [line, i]; + if (x < 0 || y < 0 || x >= img.width || y >= img.height) return 0; + return Math.min(1, Math.max(0, img.data[(y * img.width + x) * 4 + 1] / fg)); + }; + // An offset band (a shadow) reaches past the box; the other kinds stay + // within a few pixels, clear of the neighbouring rows. + const s = Math.max(0, Math.floor(lo) - 4); + const e = Math.ceil(hi) + (kind === "end" ? 16 : 4); + const mid = Math.floor((lo + hi) / 2); + if (kind === "solid") { + let a = s; + for (let i = s; i < mid; i++) a += 1 - at(i); + let b = mid; + for (let i = mid; i < e; i++) b += at(i); + return { a: round2(a), b: round2(b), size: round2(b - a) }; + } + const [from, to] = kind === "start" ? [s, mid] : [mid, e]; + let sum = 0; + let moment = 0; + for (let i = from; i < to; i++) { + const c = at(i); + sum += c; + moment += (i + 0.5) * c; + } + const centre = sum ? moment / sum : NaN; + return { a: round2(centre - sum / 2), b: round2(centre + sum / 2), size: round2(sum) }; +} + +/** RGBA pixels of a PNG, through ffmpeg (diff.mjs runs its CLI on import). */ +function decodePng(file) { + const buf = readFileSync(file); + const width = buf.readUInt32BE(16); + const height = buf.readUInt32BE(20); + const data = execFileSync( + process.env.FFMPEG ?? "ffmpeg", + ["-v", "error", "-i", file, "-f", "rawvideo", "-pix_fmt", "rgba", "-"], + { windowsHide: true, maxBuffer: 1 << 28 }, + ); + return { width, height, data }; +} + +const round2 = (v) => Math.round(v * 100) / 100; + +function parseArgs(argv) { + const opts = { out: null, exe: null, scales: [1, 1.25, 1.5, 1.75], dsf: "force" }; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--out") opts.out = argv[++i]; + else if (argv[i] === "--exe") opts.exe = argv[++i]; + else if (argv[i] === "--scales") opts.scales = argv[++i].split(",").map(Number); + else if (argv[i] === "--dsf") opts.dsf = argv[++i]; + } + if (!opts.out) { + console.error( + "usage: measure.mjs --out [--exe ] [--scales 1,1.25,1.5,1.75] [--dsf emulate|force]", + ); + process.exit(2); + } + return opts; +} + +async function settle(js) { + const started = Date.now(); + while (Date.now() - started < 30_000) { + const ok = await js( + `(() => !window.__parityStale && document.readyState === "complete" && !!document.querySelector(".titlebar .tab") && document.getAnimations().filter((a) => a.playState === "running" && a.effect?.getComputedTiming().endTime !== Infinity).length === 0)()`, + ).catch(() => false); + if (ok) return true; + await sleep(100); + } + return false; +} + +async function measureScale(page, scale, emulate, html, out, measures, titlebar) { + const { send, js } = page; + // Emulated: CDP scales a DSF 1 page. Forced: the real factor, no override. + const dsf = emulate ? scale : 0; + await send("Emulation.setDeviceMetricsOverride", { + width: 860, + height: 440, + deviceScaleFactor: dsf, + mobile: false, + }); + await js(APPLY_DEMO); + await js("window.__parityStale = true"); + await send("Page.navigate", { url: DEV_URL }); + if (!(await settle(js))) console.error(`scale ${scale}: title bar never settled`); + titlebar[scale] = await js(TITLEBAR); + titlebar[scale].devicePixelRatio = await js("window.devicePixelRatio"); + + await send("Emulation.setDeviceMetricsOverride", { + ...VIEWPORT, + deviceScaleFactor: dsf, + mobile: false, + }); + await js( + `(() => { document.activeElement?.blur?.(); document.body.innerHTML = ${JSON.stringify(html)}; return true; })()`, + ); + await js( + "new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(() => r(true), 300))))", + ); + const rects = await js(MEASURE); + const { data } = await send("Page.captureScreenshot", { format: "png", fromSurface: true }); + const dir = join(out, String(scale)); + mkdirSync(dir, { recursive: true }); + const file = join(dir, "frame.png"); + writeFileSync(file, Buffer.from(data, "base64")); + const img = decodePng(file); + for (const m of rects) { + const c = CASES.find((k) => k.id === m.id); + const dev = m.rect.map((v) => v * scale); + const cy = Math.floor((dev[1] + dev[3]) / 2); + const cx = Math.floor((dev[0] + dev[2]) / 2); + measures.push({ + id: m.id, + scale, + f: m.f, + layout: dev.map(round2), + h: edges(img, c.h ?? "solid", c.fg ?? 255, "h", dev[0], dev[2], cy), + v: edges(img, c.v ?? "solid", c.fg ?? 255, "v", dev[1], dev[3], cx), + }); + } + console.log(`scale ${scale}: ${rects.length} cases measured, ${img.width}x${img.height}`); +} + +async function main() { + const opts = parseArgs(process.argv.slice(2)); + const out = resolve(opts.out); + mkdirSync(out, { recursive: true }); + const html = fragment(); + writeFileSync(join(out, "snap.html"), html); + const exe = resolve(opts.exe ?? defaultExe()); + const dirs = realAppDirs(); + const before = Object.fromEntries(dirs.map((d) => [d, snapshotTree(d)])); + const regBefore = deepLinkRegistration(); + const watch = windowWatch(); + const vite = await startVite({ logFile: join(out, "vite.log") }); + let app = null; + const measures = []; + const titlebar = {}; + const prepare = async (page) => { + await page.send("Page.enable"); + await page.send("Runtime.enable"); + await page.send("Page.addScriptToEvaluateOnNewDocument", { source: PIN_CLOCK }); + }; + try { + if (opts.dsf === "emulate") { + app = await launchApp({ + exe, + scratch: join(out, ".scratch"), + logFile: join(out, "app.log"), + onStderr: watch.onLine, + windowMode: "hidden", + }); + await prepare(app.page); + for (const scale of opts.scales) { + await measureScale(app.page, scale, true, html, out, measures, titlebar); + } + } else { + for (const scale of opts.scales) { + app = await launchApp({ + exe, + scratch: join(out, ".scratch"), + logFile: join(out, `app-${scale}.log`), + onStderr: watch.onLine, + windowMode: "hidden", + scale, + }); + await prepare(app.page); + await measureScale(app.page, scale, false, html, out, measures, titlebar); + await app.close(); + app = null; + await sleep(300); + } + } + } finally { + await app?.close(); + await vite.close(); + } + await sleep(300); + rmSync(join(out, ".scratch"), { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); + const after = Object.fromEntries(dirs.map((d) => [d, snapshotTree(d)])); + const touched = dirs.flatMap((d) => diffTrees(before[d], after[d])); + const win = watch.summary(); + writeFileSync(join(out, "cases.json"), JSON.stringify(measures, null, 1) + "\n"); + writeFileSync(join(out, "titlebar.json"), JSON.stringify(titlebar, null, 1) + "\n"); + + const md = [`# Snapping probe\n`]; + for (const c of CASES) { + md.push( + `## ${c.id}\n\n${c.note}. Device px; layout edges, then painted (h: left..right, v: top..bottom).\n`, + ); + md.push("| scale | f | layout x | painted x | layout y | painted y |"); + md.push("| --- | --- | --- | --- | --- | --- |"); + for (const m of measures.filter((m) => m.id === c.id)) { + md.push( + `| ${m.scale} | ${m.f} | ${m.layout[0]}..${m.layout[2]} | ${m.h.a}..${m.h.b} (${m.h.size}) | ${m.layout[1]}..${m.layout[3]} | ${m.v.a}..${m.v.b} (${m.v.size}) |`, + ); + } + md.push(""); + } + writeFileSync(join(out, "report.md"), md.join("\n")); + console.log( + `window: ${win.samples} samples -> hidden=${win.hidden}; real app dirs: ${touched.length ? `${touched.length} change(s)` : "untouched"}, accshift:// ${regBefore === deepLinkRegistration() ? "unchanged" : "CHANGED"}`, + ); + console.log(`${measures.length} measures -> ${join(out, "report.md")}`); + if (!win.hidden || touched.length) process.exitCode = 1; +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/crates/accshift-egui/src/ui/components/icon.rs b/crates/accshift-egui/src/ui/components/icon.rs index cb7b560..be5c476 100644 --- a/crates/accshift-egui/src/ui/components/icon.rs +++ b/crates/accshift-egui/src/ui/components/icon.rs @@ -17,6 +17,7 @@ use egui::{ use resvg::{tiny_skia, usvg}; use crate::theme::Rgba; +use crate::ui::snap; type Key = (usize, u32); @@ -31,9 +32,10 @@ fn cache(ctx: &Context) -> Cache { } /// Where Chromium paints an SVG root whose layout box starts at `min` -/// (points): the nearest device pixel, back in points. +/// (points): the nearest device pixel, rounded half up in layout units like +/// any box ([`snap::point`]), back in points. pub fn snapped_origin(min: Pos2, ppp: f32) -> Pos2 { - pos2((min.x * ppp).round() / ppp, (min.y * ppp).round() / ppp) + snap::point(min, ppp) } /// The SVG `svg` (white `currentColor`) drawn into `rect` (points) in diff --git a/crates/accshift-egui/src/ui/components/title_bar.rs b/crates/accshift-egui/src/ui/components/title_bar.rs index aca3d77..b7ebf43 100644 --- a/crates/accshift-egui/src/ui/components/title_bar.rs +++ b/crates/accshift-egui/src/ui/components/title_bar.rs @@ -10,6 +10,12 @@ //! full content height. macOS: 72 px of padding for the traffic lights, the //! actions on the right with 8 px of padding, an opaque `--bg-solid` fill, //! no caption strip. +//! +//! Placement follows [`crate::ui::snap`] at a real device scale factor: +//! boxes snap edge by edge, the flex-centred buttons and icons centre in +//! layout units first. `.tabs` is a scroll container with real overflow (its +//! tooltips hang below it), which Chromium composites: the whole strip +//! offset, `translate(-50%, -50%)` included, lands on a whole device pixel. use egui::accesskit::Role; use egui::{ @@ -27,7 +33,8 @@ use crate::app::Action; use crate::icons; use crate::theme::Rgba; use crate::ui::anim::{self, Timing, Transition}; -use crate::ui::paint::{Border, BoxShadow, BoxStyle, Corners}; +use crate::ui::paint::{Border, BoxShadow, BoxStyle, Corners, Transform}; +use crate::ui::snap; /// `.titlebar { height: 36px }`, its `border-bottom: 1px` included. pub const HEIGHT: f32 = 36.0; @@ -98,18 +105,20 @@ pub fn title_bar(ui: &mut Ui, rect: Rect, props: &TitleBarProps) -> Vec // whole ones (1 CSS px is 1 device px at 150 %), so the content box // keeps the rest. let ppp = ui.ctx().pixels_per_point(); - let border = (ppp.floor().max(1.0)) / ppp; + let rect = snap::rect(rect, ppp); + let border = snap::border_width(1.0, ppp); let content_h = rect.height() - border; let painter = ui.painter().clone(); if macos { // Opaque fill under the whole border box, then the border on top. - painter.rect_filled(rect, CornerRadius::ZERO, v.bg_solid.to_color32()); + fill(&painter, rect, 0.0, v.bg_solid); } - painter.rect_filled( + fill( + &painter, Rect::from_min_max(pos2(rect.left(), rect.bottom() - border), rect.max), - CornerRadius::ZERO, - v.bg_card.to_color32(), + 0.0, + v.bg_card, ); // The bar itself: mousedown outside a button drags the window, a double @@ -138,7 +147,7 @@ pub fn title_bar(ui: &mut Ui, rect: Rect, props: &TitleBarProps) -> Vec } else { rect.left() + 10.0 }; - let y = rect.top() + (content_h - 26.0) / 2.0; + let y = rect.top() + snap::centre(content_h, 26.0, ppp); let buttons: [(&'static [u8], &str, bool, Action); 4] = [ ( icons::REFRESH, @@ -183,7 +192,7 @@ pub fn title_bar(ui: &mut Ui, rect: Rect, props: &TitleBarProps) -> Vec let r = Rect::from_min_size( pos2( strip_left - 4.0 - 6.0 - w, - rect.top() + (content_h - 24.0) / 2.0, + rect.top() + snap::centre(content_h, 24.0, ppp), ), vec2(w, 24.0), ); @@ -231,7 +240,7 @@ pub fn title_bar(ui: &mut Ui, rect: Rect, props: &TitleBarProps) -> Vec let r = Rect::from_min_size( pos2( rect.right() - 8.0 - w, - rect.top() + (content_h - 24.0) / 2.0, + rect.top() + snap::centre(content_h, 24.0, ppp), ), vec2(w, 24.0), ); @@ -329,19 +338,15 @@ fn action_button( let opacity = anim::transition(&ctx, id.with("op"), if enabled { 1.0 } else { 0.45 }, &T120); let mut painter = ui.painter().clone(); painter.multiply_opacity(opacity); - let shown = Rect::from_center_size(rect.center(), rect.size() * scale); - painter.rect_filled( - shown, - CornerRadius::same((4.0 * scale).round() as u8), - bg.to_color32(), - ); - icon::paint( - &painter, - svg, - Rect::from_center_size(shown.center(), vec2(14.0, 14.0) * scale), - fg, - 1.0, - ); + // `transform: scale()` on the snapped box; the 14 px svg is flex-centred + // in it. + BoxStyle { + background: bg, + radius: Corners::all(4.0), + ..BoxStyle::default() + } + .paint_transformed(&painter, rect, Transform::scale(scale)); + icon::paint(&painter, svg, centred(rect, 14.0, ui), fg, scale); focus_ring(ui, rect, 4.0, props, has_focus); enabled && response.clicked() } @@ -381,14 +386,8 @@ fn caption_button( &T120, ); let painter = ui.painter(); - painter.rect_filled(rect, CornerRadius::ZERO, bg.to_color32()); - icon::paint( - painter, - svg, - Rect::from_center_size(rect.center(), vec2(12.0, 12.0)), - fg, - scale, - ); + fill(painter, rect, 0.0, bg); + icon::paint(painter, svg, centred(rect, 12.0, ui), fg, scale); focus_ring(ui, rect, 0.0, props, has_focus); (response.clicked(), response) } @@ -428,11 +427,22 @@ fn tabs(ui: &mut Ui, bar: Rect, content_h: f32, props: &TitleBarProps, actions: } let n = list.len() as f32; let total = n * 32.0 + (n - 1.0).max(0.0) * 2.0; - let x0 = bar.center().x - total / 2.0; - let y = bar.top() + content_h / 2.0 - 14.0; - let strip = Rect::from_min_size(pos2(x0, y), vec2(total, 28.0)); - let v = props.vars; let ctx = ui.ctx().clone(); + let ppp = ctx.pixels_per_point(); + // `left: 50%; top: 50%; transform: translate(-50%, -50%)` in the bar's + // padding box, then the composited layer's offset rounded. + let strip = snap::composited( + Rect::from_min_size( + pos2( + bar.left() + bar.width() / 2.0 - total / 2.0, + bar.top() + content_h / 2.0 - 14.0, + ), + vec2(total, 28.0), + ), + ppp, + ); + let (x0, y) = (strip.left(), strip.top()); + let v = props.vars; for (index, tab) in list.iter().enumerate() { let rect = Rect::from_min_size(pos2(x0 + index as f32 * 34.0, y), vec2(32.0, 28.0)); let id = Id::new(("titlebar::tab", tab.key)); @@ -476,15 +486,14 @@ fn tabs(ui: &mut Ui, bar: Rect, content_h: f32, props: &TitleBarProps, actions: anim::transition(&ctx, id.with("op"), if enabled { 1.0 } else { 0.45 }, &T120); let mut painter = ui.painter().clone(); painter.multiply_opacity(opacity); - painter.rect_filled(rect, CornerRadius::same(4), bg.to_color32()); + BoxStyle { + background: bg, + radius: Corners::all(4.0), + ..BoxStyle::default() + } + .paint(&painter, rect); match tab.svg { - Some(svg) => icon::paint( - &painter, - svg, - Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), - fg, - 1.0, - ), + Some(svg) => icon::paint(&painter, svg, centred(rect, 16.0, ui), fg, 1.0), None => { // `.tab-text`: the first two characters, 11px 600. let short: String = tab.name.chars().take(2).collect(); @@ -577,6 +586,29 @@ fn tooltip(ui: &Ui, tab: Rect, strip: Rect, label: &str, shown: bool, props: &Ti ); } +/// A plain rectangle (`background` with `border-radius`) on the device grid. +fn fill(painter: &egui::Painter, rect: Rect, radius: f32, color: Rgba) { + BoxStyle { + background: color, + radius: Corners::all(radius), + ..BoxStyle::default() + } + .paint(painter, rect); +} + +/// A `size` square flex-centred in `rect` (`align-items` and +/// `justify-content: center`), offsets in layout units; the box snaps where +/// it paints. +fn centred(rect: Rect, size: f32, ui: &Ui) -> Rect { + let ppp = ui.ctx().pixels_per_point(); + let min = rect.min + + vec2( + snap::centre(rect.width(), size, ppp), + snap::centre(rect.height(), size, ppp), + ); + Rect::from_min_size(min, vec2(size, size)) +} + /// `.update-btn` width: text plus `padding: 0 10px` and the 1 px border. fn update_width(ui: &Ui, label: &str) -> f32 { let font = FontId::new(11.0, semibold()); diff --git a/crates/accshift-egui/src/ui/mod.rs b/crates/accshift-egui/src/ui/mod.rs index d688076..063897a 100644 --- a/crates/accshift-egui/src/ui/mod.rs +++ b/crates/accshift-egui/src/ui/mod.rs @@ -1,9 +1,10 @@ //! The foundation every ported component builds on: CSS boxes painted like -//! Chromium ([`paint`]), the CSS values that describe them ([`css`]) and CSS -//! animation timing ([`anim`]). +//! Chromium ([`paint`]) on its device-pixel grid ([`snap`]), the CSS values +//! that describe them ([`css`]) and CSS animation timing ([`anim`]). pub mod anim; pub mod components; pub mod css; pub mod paint; +pub mod snap; pub mod text; diff --git a/crates/accshift-egui/src/ui/paint.rs b/crates/accshift-egui/src/ui/paint.rs index b276e5d..b6104df 100644 --- a/crates/accshift-egui/src/ui/paint.rs +++ b/crates/accshift-egui/src/ui/paint.rs @@ -8,11 +8,14 @@ //! `CornerRadius` and `Shadow` are integer and approximate. //! //! What follows Chromium: -//! - Geometry is in device pixels and stays fractional, as the WebView2 -//! reference paints rounded boxes: a 1px border at 1.25x is 1.25 device px -//! wide and an edge at 172.5 covers half a pixel. Only a border thinner than -//! one device pixel is widened to one. Radii are scaled down together when -//! adjacent ones overlap. +//! - Geometry is in device pixels on the grid of [`super::snap`], as +//! Chromium lays out at a real device scale factor: the border box has +//! each edge rounded half up (`ToPixelSnappedRect`), border widths are +//! floored to whole device pixels with a one-pixel minimum, so a 1px border +//! is one device pixel at 1.25x and 1.5x and 2px is 3 at 1.5x. A box under +//! `transform: translate()` is snapped untransformed, then moved by the +//! exact translation ([`BoxStyle::paint_transformed`]). Radii stay +//! fractional and are scaled down together when adjacent ones overlap. //! - Anti-aliasing is one device-pixel ramp centred on every edge, at any //! scale: the analytic coverage of Skia's rrect and DRRect paths. //! - `box-shadow` is a true gaussian with sigma = blur / 2 (kernel radius @@ -41,6 +44,7 @@ use egui::{ TextureOptions, Vec2, }; +use super::snap; use crate::theme::Rgba; // ---------------------------------------------------------------- values @@ -516,6 +520,35 @@ fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 { ) } +/// A 2D `transform` on a box: `translate(translate) scale(scale)` around +/// the box centre (`transform-origin: center`), in points. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Transform { + pub translate: Vec2, + pub scale: f32, +} + +impl Transform { + pub const NONE: Transform = Transform { + translate: Vec2::ZERO, + scale: 1.0, + }; + + pub const fn translate(translate: Vec2) -> Self { + Self { + translate, + scale: 1.0, + } + } + + pub const fn scale(scale: f32) -> Self { + Self { + translate: Vec2::ZERO, + scale, + } + } +} + /// Device-pixel geometry of one box. struct BoxGeometry { border_box: RRect, @@ -523,25 +556,25 @@ struct BoxGeometry { padding_box: RRect, } -fn snap_border(w: f32, ppp: f32) -> f32 { - let d = w * ppp; - if d <= 0.0 { - 0.0 - } else { - d.max(1.0) - } -} - impl BoxGeometry { - fn new(rect: Rect, radius: Corners, border: &Border, ppp: f32) -> Self { + /// The pixel-snapped border box of `rect` (points), then `transform` + /// applied exactly, as Chromium bakes the rounded paint offset into a + /// transform node and keeps the transform itself fractional. + fn new(rect: Rect, transform: Transform, radius: Corners, border: &Border, ppp: f32) -> Self { + let Transform { translate, scale } = transform; + let mut d = snap::device_rect(rect, ppp); + if scale != 1.0 { + d = Rect::from_center_size(d.center(), d.size() * scale); + } + let d = d.translate(translate * ppp); let border_box = RRect::new( - rect.min.x * ppp, - rect.min.y * ppp, - rect.max.x * ppp, - rect.max.y * ppp, - radius.to_array().map(|r| r * ppp), + d.min.x, + d.min.y, + d.max.x, + d.max.y, + radius.to_array().map(|r| r * ppp * scale), ); - let widths = border.width.map(|w| snap_border(w, ppp)); + let widths = border.width.map(|w| snap::border_device(w, ppp) * scale); Self { border_box, widths, @@ -587,19 +620,31 @@ impl Builder { base } + /// A triangle, unless two of its corners coincide. A square corner + /// repeats its contour point, and the software renderer pairs a + /// degenerate triangle with the one before it into a full rectangle, + /// which paints half the box twice. + fn triangle(&mut self, a: u32, b: u32, c: u32) { + let v = &self.mesh.vertices; + let (pa, pb, pc) = (v[a as usize].pos, v[b as usize].pos, v[c as usize].pos); + if pa != pb && pb != pc && pa != pc { + self.mesh.add_triangle(a, b, c); + } + } + /// Quads between two rings of `n` points each. fn stitch(&mut self, a: u32, b: u32, n: usize) { let n = n as u32; for i in 0..n { let j = (i + 1) % n; - self.mesh.add_triangle(a + i, a + j, b + j); - self.mesh.add_triangle(a + i, b + j, b + i); + self.triangle(a + i, a + j, b + j); + self.triangle(a + i, b + j, b + i); } } fn fan(&mut self, ring: u32, n: usize) { for i in 1..n as u32 - 1 { - self.mesh.add_triangle(ring, ring + i, ring + i + 1); + self.triangle(ring, ring + i, ring + i + 1); } } @@ -1210,14 +1255,23 @@ fn paint_shadow(painter: &Painter, geo: &BoxGeometry, s: &BoxShadow, opacity: f3 // ---------------------------------------------------------------- entry points impl BoxStyle { - /// Paint the box over `rect` (its border box, in points). + /// Paint the box over `rect` (its layout border box, in points), + /// pixel-snapped. pub fn paint(&self, painter: &Painter, rect: Rect) { + self.paint_transformed(painter, rect, Transform::NONE); + } + + /// Paint the box over `rect` (its layout border box) under `transform`: + /// `rect` is snapped, then the transform applies exactly, so a + /// fractional translation or a scale paints anti-aliased edges like + /// Chromium. Shadow blur and offsets keep their untransformed size. + pub fn paint_transformed(&self, painter: &Painter, rect: Rect, transform: Transform) { let opacity = self.opacity.clamp(0.0, 1.0); if opacity <= 0.0 { return; } let ppp = painter.ctx().pixels_per_point(); - let geo = BoxGeometry::new(rect, self.radius, &self.border, ppp); + let geo = BoxGeometry::new(rect, transform, self.radius, &self.border, ppp); for s in self.shadows.iter().rev().filter(|s| !s.inset) { paint_shadow(painter, &geo, s, opacity, ppp); } @@ -1251,7 +1305,7 @@ pub fn paint_image( tint: Color32, ) { let ppp = painter.ctx().pixels_per_point(); - let geo = BoxGeometry::new(rect, radius, &Border::NONE, ppp); + let geo = BoxGeometry::new(rect, Transform::NONE, radius, &Border::NONE, ppp); let bb = geo.border_box; let origin = vec2(bb.x0, bb.y0); let extent = vec2(bb.width(), bb.height()).max(Vec2::splat(1e-3)); @@ -1411,7 +1465,7 @@ impl RRectClipper { pub fn paint_clipped(painter: &Painter, rect: Rect, radius: Corners, shapes: Vec) { let ctx = painter.ctx(); let ppp = ctx.pixels_per_point(); - let rr = BoxGeometry::new(rect, radius, &Border::NONE, ppp).border_box; + let rr = BoxGeometry::new(rect, Transform::NONE, radius, &Border::NONE, ppp).border_box; let clip_rect = painter.clip_rect(); let clipped: Vec = shapes .into_iter() @@ -1464,12 +1518,21 @@ mod tests { } #[test] - fn border_widths_stay_fractional() { - assert_eq!(snap_border(1.0, 1.25), 1.25); - assert_eq!(snap_border(1.0, 1.5), 1.5); - assert_eq!(snap_border(2.0, 1.5), 3.0); - assert_eq!(snap_border(0.5, 1.0), 1.0); - assert_eq!(snap_border(0.0, 1.5), 0.0); + fn boxes_sit_on_the_device_grid() { + let border = Border::all(1.0, Rgba::TRANSPARENT); + let r = Rect::from_min_size(pos2(200.5, 14.5), vec2(30.0, 16.0)); + let geo = BoxGeometry::new(r, Transform::NONE, Corners::all(4.0), &border, 1.5); + let bb = geo.border_box; + assert_eq!([bb.x0, bb.y0, bb.x1, bb.y1], [301.0, 22.0, 346.0, 46.0]); + assert_eq!(geo.widths.top, 1.0); + let geo = BoxGeometry::new( + r, + Transform::translate(vec2(0.5, 0.0)), + Corners::all(4.0), + &border, + 1.5, + ); + assert_eq!(geo.border_box.x0, 301.75); } #[test] diff --git a/crates/accshift-egui/src/ui/snap.rs b/crates/accshift-egui/src/ui/snap.rs new file mode 100644 index 0000000..de65b59 --- /dev/null +++ b/crates/accshift-egui/src/ui/snap.rs @@ -0,0 +1,176 @@ +//! Device-pixel snapping the way Chromium does it at a real device scale +//! factor (a Windows display at 125 or 150 %). +//! +//! Chromium lays out in device pixels (zoom for DSF) with `LayoutUnit`s of +//! 1/64 px, then paints most boxes on whole device pixels. Every rule here +//! was measured on WebView2 with `parity/snap/measure.mjs` (a white case on +//! black at six fractional offsets and four scales); the rules table is in +//! `parity/README.md`. A component computes its layout in CSS px (points), +//! like the CSS, and lets this module decide where the pixels go: +//! +//! - a box (background, border, rounded or not, `box-shadow` base, `img`, +//! inline `svg` root, `opacity`): [`rect`], each edge rounded half up, so +//! the size is `round(x + w) - round(x)` (`ToPixelSnappedRect`); +//! - a border width: [`border_width`], floored to whole device pixels, one +//! pixel minimum (CSS Values 4 "snap as a border width"); +//! - an element under `transform: translate()`: [`translated`], the +//! untransformed box snapped, then the translation added as is, so a +//! fractional translation paints fractional, anti-aliased edges; +//! - the contents of a composited scroll container (`overflow: auto` with +//! real overflow): [`composited`], the whole offset, translation +//! included, rounded; +//! - flex centring: [`centre`], the free space halved in layout units, the +//! child then snapped as a box. + +use egui::{pos2, Pos2, Rect, Vec2}; + +/// Chromium's `LayoutUnit` resolution: 1/64 px. +const LU: f32 = 64.0; + +/// A device-pixel length quantized to `LayoutUnit`s (rounded to 1/64), so +/// float noise from CSS px times the scale (`11.666667 * 1.5`) lands on the +/// value layout holds (`17.5`). +pub fn layout_unit(device: f32) -> f32 { + (device * LU).round() / LU +} + +/// `LayoutUnit::Round()`: a device position rounded half up. +pub fn round_device(device: f32) -> f32 { + (layout_unit(device) + 0.5).floor() +} + +/// A position in points snapped to the nearest device pixel, in points. +pub fn coord(v: f32, ppp: f32) -> f32 { + round_device(v * ppp) / ppp +} + +/// A point snapped to the device grid, in points. +pub fn point(p: Pos2, ppp: f32) -> Pos2 { + pos2(coord(p.x, ppp), coord(p.y, ppp)) +} + +/// A box (points) pixel-snapped like `ToPixelSnappedRect`, in device +/// pixels: each edge rounded on its own, whole numbers. +pub fn device_rect(r: Rect, ppp: f32) -> Rect { + Rect::from_min_max( + pos2(round_device(r.min.x * ppp), round_device(r.min.y * ppp)), + pos2(round_device(r.max.x * ppp), round_device(r.max.y * ppp)), + ) +} + +/// [`device_rect`] in points. +pub fn rect(r: Rect, ppp: f32) -> Rect { + Rect::from_min_max(point(r.min, ppp), point(r.max, ppp)) +} + +/// A box under `transform: translate(t)` (points): the untransformed box is +/// snapped, then moved by `t` exactly. +pub fn translated(r: Rect, t: Vec2, ppp: f32) -> Rect { + rect(r, ppp).translate(t) +} + +/// Contents of a composited layer: the whole offset (layout plus any +/// translation) rounded to a device pixel, the size snapped from there. +/// Chromium composites a scroll container that really overflows when it +/// prefers compositing to LCD text (always from a scale of 1.5). Measured on +/// the title bar `.tabs` at 1.25, 1.5 and 1.75; at 1.25 the reason is not +/// pinned (likely the transparent window, which rules out LCD text). +/// Elsewhere a translated box follows [`translated`]. +pub fn composited(r: Rect, ppp: f32) -> Rect { + let min = point(r.min, ppp); + let size = r.size() * ppp; + Rect::from_min_size( + min, + egui::vec2(round_device(size.x) / ppp, round_device(size.y) / ppp), + ) +} + +/// A `border-width` (points) as Chromium lays it out, in device pixels: +/// floored to whole pixels, never under one for a non-zero width. +pub fn border_device(w: f32, ppp: f32) -> f32 { + let d = layout_unit(w * ppp); + if d <= 0.0 { + 0.0 + } else { + d.floor().max(1.0) + } +} + +/// [`border_device`] in points. +pub fn border_width(w: f32, ppp: f32) -> f32 { + border_device(w, ppp) / ppp +} + +/// Offset (points) of a child of length `inner` centred in `outer` +/// (`align-items` or `justify-content: center`), computed in layout units: +/// the free space in device pixels, halved and truncated to 1/64. The child +/// box itself is then snapped with [`rect`]. +pub fn centre(outer: f32, inner: f32, ppp: f32) -> f32 { + let free = layout_unit(outer * ppp) - layout_unit(inner * ppp); + ((free * LU / 2.0).trunc() / LU) / ppp +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::vec2; + + fn dev(r: Rect, ppp: f32) -> [f32; 4] { + [r.min.x, r.min.y, r.max.x, r.max.y].map(|v| (v * ppp * 1000.0).round() / 1000.0) + } + + #[test] + fn boxes_round_each_edge_half_up() { + // probe `bg`, 1.25x, f = 0.5: layout 250.625..288.125 painted 251..288. + let r = Rect::from_min_size(pos2(200.5, 14.5), vec2(30.0, 16.0)); + assert_eq!(dev(rect(r, 1.25), 1.25), [251.0, 18.0, 288.0, 38.0]); + // 1.5x, f = 0.5: 300.75..345.75 painted 301..346. + assert_eq!(dev(rect(r, 1.5), 1.5), [301.0, 22.0, 346.0, 46.0]); + // A fractional size: 30.4 at 1.5x from 108.375 ends at 153.96, 154. + let r = Rect::from_min_size(pos2(72.25, 54.25), vec2(30.4, 16.4)); + assert_eq!(dev(rect(r, 1.5), 1.5), [108.0, 81.0, 154.0, 106.0]); + } + + #[test] + fn float_noise_does_not_flip_a_half() { + // 11.666667 CSS px at 1.5x is 17.5 in layout, rounded up to 18. + assert_eq!(coord(35.0 / 3.0, 1.5) * 1.5, 18.0); + } + + #[test] + fn border_widths_floor_to_device_pixels() { + assert_eq!(border_device(1.0, 1.25), 1.0); + assert_eq!(border_device(1.0, 1.5), 1.0); + assert_eq!(border_device(1.0, 1.75), 1.0); + assert_eq!(border_device(2.0, 1.25), 2.0); + assert_eq!(border_device(2.0, 1.5), 3.0); + assert_eq!(border_device(2.0, 1.75), 3.0); + assert_eq!(border_device(0.5, 1.0), 1.0); + assert_eq!(border_device(0.0, 1.5), 0.0); + } + + #[test] + fn translations_stay_fractional_over_a_snapped_box() { + // probe `translate`, 1.75x, f = 0.7: the box at 264 CSS (462 device, + // 52.5 wide) snaps to 462..515, then moves by 1.225. + let r = Rect::from_min_size(pos2(264.0, 374.0), vec2(30.0, 16.0)); + let t = translated(r, vec2(0.7, 0.7), 1.75); + assert_eq!(dev(t, 1.75), [463.225, 656.225, 516.225, 684.225]); + } + + #[test] + fn composited_contents_round_the_whole_offset() { + // The title bar tabs at 1.5x: 363 CSS is 544.5 device, painted at 545. + let r = Rect::from_min_size(pos2(363.0, 3.5 + 1.0 / 6.0), vec2(134.0, 28.0)); + assert_eq!(dev(composited(r, 1.5), 1.5), [545.0, 6.0, 746.0, 48.0]); + } + + #[test] + fn centring_halves_the_free_space_in_layout_units() { + // A 26 px button in the 35.33 px content box of the bar at 1.5x: + // (53 - 39) / 2 = 7 device px. + assert_eq!(centre(36.0 - 1.0 / 1.5, 26.0, 1.5) * 1.5, 7.0); + // probe `flex-center`, 1x: 31 - 20 = 11, 5.5 then snapped to 6. + assert_eq!(coord(8.0 + centre(31.0, 20.0, 1.0), 1.0), 14.0); + } +} From f328109a8dfc490d720bf85bdcd6339fa2db30cb Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:15:37 +0200 Subject: [PATCH 028/106] docs(egui): state how closely the snap rules match the probe --- crates/accshift-egui/parity/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/accshift-egui/parity/README.md b/crates/accshift-egui/parity/README.md index d4c2c46..cfb3f5c 100644 --- a/crates/accshift-egui/parity/README.md +++ b/crates/accshift-egui/parity/README.md @@ -101,7 +101,7 @@ node crates/accshift-egui/parity/snap/measure.mjs --out --exe Date: Mon, 12 Oct 2026 12:32:45 +0200 Subject: [PATCH 029/106] feat(app): port the app lifecycle, updater, single instance and deep links --- crates/accshift-app/Cargo.lock | 605 +++++++- crates/accshift-app/Cargo.toml | 48 +- crates/accshift-app/src/lib.rs | 3 + .../accshift-app/src/lifecycle/app_shell.rs | 336 +++++ crates/accshift-app/src/lifecycle/boot.rs | 215 +++ .../accshift-app/src/lifecycle/deep_link.rs | 822 +++++++++++ crates/accshift-app/src/lifecycle/mod.rs | 246 ++++ crates/accshift-app/src/lifecycle/process.rs | 95 ++ .../src/lifecycle/single_instance/linux.rs | 77 ++ .../src/lifecycle/single_instance/macos.rs | 72 + .../src/lifecycle/single_instance/mod.rs | 232 ++++ .../src/lifecycle/single_instance/windows.rs | 270 ++++ crates/accshift-app/src/lifecycle/ui_scale.rs | 136 ++ .../accshift-app/src/lifecycle/update_flow.rs | 412 ++++++ crates/accshift-app/src/lifecycle/updater.rs | 1185 ++++++++++++++++ .../src/lifecycle/updater/error.rs | 128 ++ .../src/lifecycle/updater/unix.rs | 305 ++++ crates/accshift-app/tests/lifecycle_flow.rs | 882 ++++++++++++ crates/accshift-app/tests/lifecycle_links.rs | 808 +++++++++++ .../accshift-app/tests/lifecycle_updater.rs | 1223 +++++++++++++++++ 20 files changed, 8076 insertions(+), 24 deletions(-) create mode 100644 crates/accshift-app/src/lifecycle/app_shell.rs create mode 100644 crates/accshift-app/src/lifecycle/boot.rs create mode 100644 crates/accshift-app/src/lifecycle/deep_link.rs create mode 100644 crates/accshift-app/src/lifecycle/mod.rs create mode 100644 crates/accshift-app/src/lifecycle/process.rs create mode 100644 crates/accshift-app/src/lifecycle/single_instance/linux.rs create mode 100644 crates/accshift-app/src/lifecycle/single_instance/macos.rs create mode 100644 crates/accshift-app/src/lifecycle/single_instance/mod.rs create mode 100644 crates/accshift-app/src/lifecycle/single_instance/windows.rs create mode 100644 crates/accshift-app/src/lifecycle/ui_scale.rs create mode 100644 crates/accshift-app/src/lifecycle/update_flow.rs create mode 100644 crates/accshift-app/src/lifecycle/updater.rs create mode 100644 crates/accshift-app/src/lifecycle/updater/error.rs create mode 100644 crates/accshift-app/src/lifecycle/updater/unix.rs create mode 100644 crates/accshift-app/tests/lifecycle_flow.rs create mode 100644 crates/accshift-app/tests/lifecycle_links.rs create mode 100644 crates/accshift-app/tests/lifecycle_updater.rs diff --git a/crates/accshift-app/Cargo.lock b/crates/accshift-app/Cargo.lock index 01eedef..c3e01b0 100644 --- a/crates/accshift-app/Cargo.lock +++ b/crates/accshift-app/Cargo.lock @@ -9,17 +9,34 @@ dependencies = [ "accshift-core", "base64 0.23.1", "dirs", + "dunce", + "flate2", "image", + "infer", "jiff", + "minisign", + "minisign-verify", + "osakit", + "percent-encoding", + "plist", "reqwest", "rfd", "rusqlite", + "rust-ini", "rusty-leveldb", + "semver", "serde", "serde_json", "sha2 0.11.0", + "tar", + "tempfile", + "time", "tokio", + "url", + "windows-registry", "windows-sys 0.61.2", + "zbus 5.19.0", + "zip", ] [[package]] @@ -29,7 +46,7 @@ dependencies = [ "fs4", "keyring", "open", - "pbkdf2", + "pbkdf2 0.13.0", "reqwest", "rusqlite", "serde", @@ -70,6 +87,15 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -94,6 +120,20 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + [[package]] name = "async-io" version = "2.6.0" @@ -208,10 +248,10 @@ dependencies = [ "addr2line", "cfg-if", "libc", - "miniz_oxide", + "miniz_oxide 0.8.9", "object", "rustc-demangle", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -299,6 +339,12 @@ version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -330,6 +376,17 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + [[package]] name = "cfg-if" version = "1.0.5" @@ -384,6 +441,26 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -437,12 +514,27 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-utils" version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -462,6 +554,12 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ct-codecs" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd9f3db6f2cfef61c10613071955154ffdc9e515daebff26de4b54e35038fdd" + [[package]] name = "ctutils" version = "0.4.3" @@ -531,6 +629,23 @@ dependencies = [ "thiserror", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "digest" version = "0.10.7" @@ -607,6 +722,15 @@ dependencies = [ "libloading", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -700,12 +824,39 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.2.0" @@ -848,6 +999,12 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.16.1" @@ -1149,6 +1306,15 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + [[package]] name = "inout" version = "0.1.4" @@ -1221,7 +1387,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1307,7 +1473,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1369,6 +1535,24 @@ dependencies = [ "autocfg", ] +[[package]] +name = "minisign" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3062dbbf4d8f57899e2252804f7512a4fd89c8211c58fa6c786d38794a0c809d" +dependencies = [ + "ct-codecs", + "getrandom 0.4.3", + "rpassword", + "scrypt", +] + +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1378,6 +1562,16 @@ dependencies = [ "adler2", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.4" @@ -1454,6 +1648,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.47" @@ -1538,6 +1738,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", + "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -1552,6 +1754,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "object" version = "0.37.3" @@ -1584,6 +1798,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -1594,12 +1818,36 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "parking" version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + [[package]] name = "pbkdf2" version = "0.13.0" @@ -1639,6 +1887,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "plist" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" +dependencies = [ + "base64 0.23.1", + "indexmap", + "quick-xml 0.42.0", + "serde", + "time", +] + [[package]] name = "polling" version = "3.11.0" @@ -1683,6 +1944,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1725,6 +1992,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.12" @@ -1949,6 +2225,17 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + [[package]] name = "rsqlite-vfs" version = "0.1.1" @@ -1959,6 +2246,16 @@ dependencies = [ "thiserror", ] +[[package]] +name = "rtoolbox" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "rusqlite" version = "0.40.2" @@ -1974,6 +2271,16 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rustc-demangle" version = "0.1.28" @@ -2068,12 +2375,32 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "scoped-tls" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2 0.12.2", + "salsa20", + "sha2 0.10.9", +] + [[package]] name = "secret-service" version = "4.0.0" @@ -2090,7 +2417,7 @@ dependencies = [ "rand 0.8.8", "serde", "sha2 0.10.9", - "zbus", + "zbus 4.4.0", ] [[package]] @@ -2250,6 +2577,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" @@ -2364,6 +2697,17 @@ dependencies = [ "windows", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -2397,6 +2741,45 @@ dependencies = [ "syn 3.0.7", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -2603,6 +2986,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -2619,6 +3003,7 @@ checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "getrandom 0.4.3", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -2750,7 +3135,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -2845,9 +3230,9 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -2857,7 +3242,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ "windows-core", - "windows-link", + "windows-link 0.2.1", "windows-threading", ] @@ -2883,6 +3268,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" @@ -2896,7 +3287,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ "windows-core", - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -2905,7 +3316,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -2914,7 +3334,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2941,7 +3361,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2966,7 +3386,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3042,6 +3462,16 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "xdg-home" version = "1.3.0" @@ -3102,9 +3532,44 @@ dependencies = [ "uds_windows", "windows-sys 0.52.0", "xdg-home", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros 5.19.0", + "zbus_names 4.3.4", + "zvariant 5.15.0", ] [[package]] @@ -3117,7 +3582,22 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "zvariant_utils", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.7", + "zbus_names 4.3.4", + "zvariant 5.15.0", + "zvariant_utils 4.2.0", ] [[package]] @@ -3128,7 +3608,27 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" dependencies = [ "serde", "static_assertions", - "zvariant", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant 5.15.0", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", ] [[package]] @@ -3225,6 +3725,24 @@ dependencies = [ "syn 3.0.7", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap", + "memchr", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" @@ -3256,7 +3774,22 @@ dependencies = [ "enumflags2", "serde", "static_assertions", - "zvariant_derive", + "zvariant_derive 4.2.0", +] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive 5.15.0", + "zvariant_utils 4.2.0", ] [[package]] @@ -3269,7 +3802,20 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "zvariant_utils", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.7", + "zvariant_utils 4.2.0", ] [[package]] @@ -3282,3 +3828,16 @@ dependencies = [ "quote", "syn 2.0.119", ] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.7", + "winnow", +] diff --git a/crates/accshift-app/Cargo.toml b/crates/accshift-app/Cargo.toml index 66ed406..8bcf9b2 100644 --- a/crates/accshift-app/Cargo.toml +++ b/crates/accshift-app/Cargo.toml @@ -38,6 +38,19 @@ sha2 = "0.11" # installed database) and reads /usr/share/zoneinfo elsewhere. Same version # as the root workspace lock. jiff = "0.2.35" +# `lifecycle::updater`: the port of tauri-plugin-updater 2.11. Same crates and +# majors the plugin builds with: minisign-verify for the `.sig`, semver for +# the version compare, base64 for the key and signature strings, time for the +# RFC 3339 `pub_date`, url and percent-encoding for the endpoint templating, +# infer and tempfile for the downloaded installer. +minisign-verify = "0.2" +semver = "1" +base64 = "0.23" +time = { version = "0.3", features = ["parsing", "formatting"] } +url = { version = "2", features = ["serde"] } +percent-encoding = "2" +infer = "0.19" +tempfile = "3" [target.'cfg(windows)'.dependencies] # `get_desktop_wallpaper`: the Progman capture of @@ -46,11 +59,24 @@ jiff = "0.2.35" windows-sys = { version = "0.61", features = [ "Win32_Foundation", "Win32_Graphics_Gdi", + "Win32_Security", "Win32_Storage_Xps", + "Win32_System_DataExchange", + "Win32_System_LibraryLoader", + "Win32_System_Threading", + "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", ] } image = { version = "0.25", default-features = false, features = ["jpeg"] } -base64 = "0.23" +# `lifecycle::deep_link`: the HKCU scheme registration, on the crate +# tauri-plugin-deep-link 2.4 writes it with. +windows-registry = "0.5" +# `lifecycle::deep_link`: `dunce::simplified` of the canonical exe path, as +# the plugin writes `"" "%1"`. +dunce = "1" +# `lifecycle::updater`: the updater zip of an NSIS or MSI bundle, as the +# plugin's default `zip` feature reads it. +zip = { version = "4", default-features = false } # `legacy_web_storage`: reads the WebView2 localStorage LevelDB of v0.x # installs. Pure Rust, Snappy blocks and the `.log` replay included. No `fs` # feature: the files are copied into its in-memory env, never opened on disk. @@ -60,8 +86,28 @@ rusty-leveldb = { version = "4", default-features = false } # `legacy_web_storage`: the WebKit localStorage sqlite of WebKitGTK and # WKWebView. Same spec as the root workspace. rusqlite = { version = "0.40", features = ["bundled"] } +# `lifecycle::updater`: the `.tar.gz` bundles of AppImage and macOS. +flate2 = "1" +tar = "0.4" + +[target.'cfg(target_os = "linux")'.dependencies] +# `lifecycle::single_instance`: the D-Bus name tauri-plugin-single-instance +# 2.4 owns on Linux, same major. +zbus = "5.9" +# `lifecycle::deep_link`: the `.desktop` handler file the plugin merges. +rust-ini = "0.21" + +[target.'cfg(target_os = "macos")'.dependencies] +# `lifecycle::updater`: the admin prompt of the macOS install fallback. +osakit = { version = "0.3", features = ["full"] } +# `lifecycle::process`: the `CFBundleExecutable` a relaunch starts, as +# Tauri's `restart_macos_app` reads it. +plist = "1" [dev-dependencies] +# Signs the updater fixtures with a key generated in the test, as the +# `tauri signer` command does (tauri-cli signs with this crate). +minisign = "0.10" # The WebKit fixtures are built and read on every OS, Windows included # (tests/legacy_web_storage.rs compiles src/legacy_web_storage/webkit_sqlite.rs # by path), without growing the Windows app. diff --git a/crates/accshift-app/src/lib.rs b/crates/accshift-app/src/lib.rs index feea629..69c3174 100644 --- a/crates/accshift-app/src/lib.rs +++ b/crates/accshift-app/src/lib.rs @@ -22,6 +22,8 @@ //! - [`bulk_edit`], [`platform_add_flow`], [`platform_shell`] and //! [`cs2_bridge`]: the Steam bulk edit, the add-account flow, the platform //! shell state and the CS2 bridge queue. +//! - [`lifecycle`]: boot and shutdown order, the updater, single instance, +//! deep links and process control of the Tauri plugins. pub mod accounts; pub mod actions; @@ -36,6 +38,7 @@ pub mod i18n; pub mod jobs; pub mod js; pub mod legacy_web_storage; +pub mod lifecycle; pub mod menu; pub mod mock; pub mod personas; diff --git a/crates/accshift-app/src/lifecycle/app_shell.rs b/crates/accshift-app/src/lifecycle/app_shell.rs new file mode 100644 index 0000000..b193c1e --- /dev/null +++ b/crates/accshift-app/src/lifecycle/app_shell.rs @@ -0,0 +1,336 @@ +//! `src/lib/app/useAppLifecycle.svelte.ts`: what the shell does once the +//! storage is read (`initializeAppShell`) and when another process changed +//! the stores (`refreshExternalStorageState`), as ordered plans. The shell +//! owns the state; these functions decide. + +use crate::accounts::loading::is_platform_usable; +use crate::accounts::{Toast, ToastKind}; +use crate::platform_shell::initial_active_tab; +use crate::platforms::{PlatformRegistry, RuntimeOs}; +use crate::settings::AppSettings; +use crate::storage::{ + CLIENT_STORE_ACCOUNT_CARD_COLORS, CLIENT_STORE_ACCOUNT_CARD_NOTES, CLIENT_STORE_FOLDERS, + CLIENT_STORE_FOLDER_CARD_COLORS, CLIENT_STORE_PERSONAS, CLIENT_STORE_SETTINGS, + CLIENT_STORE_VIEW_MODE, STORAGE_TARGET_APP_CONFIG_LOCAL, STORAGE_TARGET_APP_CONFIG_PORTABLE, + STORAGE_TARGET_CUSTOM_THEMES, +}; +use crate::text::Text; +use crate::types::SettingsSnapshot; + +/// `semverCore`: the first `x.y.z` in the version, else the version. +pub fn semver_core(version: &str) -> String { + let bytes = version.as_bytes(); + for start in 0..bytes.len() { + if let Some(end) = match_triplet(bytes, start) { + return version[start..end].to_string(); + } + } + version.to_string() +} + +/// `\d+\.\d+\.\d+` anchored at `start`, greedy like the regex. +fn match_triplet(bytes: &[u8], start: usize) -> Option { + let mut index = start; + for part in 0..3 { + let digits = bytes[index..] + .iter() + .take_while(|b| b.is_ascii_digit()) + .count(); + if digits == 0 { + return None; + } + index += digits; + if part < 2 { + if bytes.get(index) != Some(&b'.') { + return None; + } + index += 1; + } + } + Some(index) +} + +/// The migration toast of `initializeAppShell`. +pub fn migration_toast(migration_result: &str) -> Option { + if migration_result == "migrated" { + Some(Toast::new( + Text::key("toast.legacyConfigMigrated"), + ToastKind::Default, + )) + } else if migration_result.starts_with("error:") { + Some(Toast::new( + Text::key("toast.legacyConfigMigrationFailed"), + ToastKind::Default, + )) + } else { + None + } +} + +/// `enableDetectedPlatformsOnFirstRun`, after `platform_detect_installed` +/// answered (an error counts as an empty list). `None` when settings were +/// already stored and nothing was asked. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FirstRunPlatforms { + /// `setDetectedPlatforms(usable)`. + pub detected: Vec, + /// The settings to save then `refreshSettings`, when any is usable. + pub enable: Option<(Vec, String)>, +} + +pub fn first_run_platforms( + detected: &[String], + registry: &PlatformRegistry, + os: RuntimeOs, +) -> FirstRunPlatforms { + let usable: Vec = detected + .iter() + .filter(|id| is_platform_usable(registry, id, os)) + .cloned() + .collect(); + let enable = usable.first().map(|first| (usable.clone(), first.clone())); + FirstRunPlatforms { + detected: usable, + enable, + } +} + +/// The `trackSettingsSnapshot` payload of `initializeAppShell`. +pub fn settings_snapshot(settings: &AppSettings) -> SettingsSnapshot { + let text = |value: serde_json::Result| { + value + .ok() + .and_then(|v| v.as_str().map(str::to_string)) + .unwrap_or_default() + }; + SettingsSnapshot { + ui_language: settings.language.code().to_string(), + enabled_platforms: settings.enabled_platforms.clone(), + personas_enabled: settings.personas_enabled, + pin_enabled: settings.pin_enabled, + cli_enabled: settings.cli_enabled, + deep_links_enabled: settings.deep_links_enabled, + streamer_mode: text(serde_json::to_value(settings.streamer_mode)), + animations: text(serde_json::to_value(settings.animations)), + } +} + +/// The last step of `initializeAppShell`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InitialLoad { + /// `loadAccounts(false, false, false, false, true)`. + LoadAccounts, + /// `navigation.refreshCurrentItems()` then `queueGridPadding()`. + RefreshItems, +} + +pub fn initial_load(registry: &PlatformRegistry, active_tab: &str, os: RuntimeOs) -> InitialLoad { + if is_platform_usable(registry, active_tab, os) { + InitialLoad::LoadAccounts + } else { + InitialLoad::RefreshItems + } +} + +/// `initializeAppShell` in order, for the shell to run step by step. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ShellInitStep { + /// The migration toast, if any. + MigrationToast, + /// `applyCustomThemePayloads(boot.customThemes)`. + ApplyCustomThemes, + RefreshSettings, + /// `setAppVersion(semverCore(getVersion()))`. + SetAppVersion, + SetRuntimeOs, + /// [`first_run_platforms`], only without stored settings. + EnableDetectedPlatformsOnFirstRun, + /// `setActiveTab(initial_active_tab)` when it differs. + PickInitialTab, + MarkBootReady, + TrackSettingsSnapshot, + /// [`initial_load`]. + InitialLoad, +} + +pub const SHELL_INIT_ORDER: [ShellInitStep; 10] = [ + ShellInitStep::MigrationToast, + ShellInitStep::ApplyCustomThemes, + ShellInitStep::RefreshSettings, + ShellInitStep::SetAppVersion, + ShellInitStep::SetRuntimeOs, + ShellInitStep::EnableDetectedPlatformsOnFirstRun, + ShellInitStep::PickInitialTab, + ShellInitStep::MarkBootReady, + ShellInitStep::TrackSettingsSnapshot, + ShellInitStep::InitialLoad, +]; + +/// `SETTINGS_TARGETS`. +pub const SETTINGS_TARGETS: [&str; 2] = [CLIENT_STORE_SETTINGS, STORAGE_TARGET_CUSTOM_THEMES]; +/// `CARD_COLOR_TARGETS`. +pub const CARD_COLOR_TARGETS: [&str; 2] = [ + CLIENT_STORE_ACCOUNT_CARD_COLORS, + CLIENT_STORE_FOLDER_CARD_COLORS, +]; +/// `GRID_TARGETS`. +pub const GRID_TARGETS: [&str; 6] = [ + CLIENT_STORE_FOLDERS, + CLIENT_STORE_ACCOUNT_CARD_NOTES, + CLIENT_STORE_ACCOUNT_CARD_COLORS, + CLIENT_STORE_FOLDER_CARD_COLORS, + CLIENT_STORE_VIEW_MODE, + CLIENT_STORE_SETTINGS, +]; +/// `APP_CONFIG_TARGETS`. +pub const APP_CONFIG_TARGETS: [&str; 2] = [ + STORAGE_TARGET_APP_CONFIG_PORTABLE, + STORAGE_TARGET_APP_CONFIG_LOCAL, +]; + +/// One step of `refreshExternalStorageState`, in the order TS runs them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RefreshStep { + /// `log_app_event(info, frontend.storage.refresh, "Detected external + /// storage changes", {changed, activeTab})`. + Log { + details: String, + }, + LoadCustomThemes, + RefreshSettings, + /// `addFlow.cancel()` (only when a flow is open). + CancelAddFlow, + ResetVisiblePrimeState, + ClearLoaderForPlatformChange, + SetActiveTab(String), + ClearCurrentFolder, + /// `replaceHistoryState({tab, folderId: null, showSettings: false})`. + ReplaceHistory(String), + SyncViewMode, + BumpCardColorVersion, + BumpCardNoteVersion, + RefreshPersonas, + RefreshCurrentItems, + PrepareVisibleAccounts, + QueueGridPadding, + /// `loadAccounts(force, false, false, warnings, false)`; `loadAccounts(true)` + /// after a tab change has `warnings` false too. + LoadAccounts { + force: bool, + warnings: bool, + }, +} + +/// `refreshExternalStorageState` after `refreshClientStorageIfChanged` +/// answered `changed`. `settings` are the settings once refreshed (the +/// stores already hold the new snapshot); `add_flow_open` is +/// `addFlow.flow != null`. +pub fn plan_external_refresh( + changed: &[String], + settings: &AppSettings, + active_tab: &str, + registry: &PlatformRegistry, + os: RuntimeOs, + add_flow_open: bool, +) -> Vec { + let mut steps = Vec::new(); + if changed.is_empty() { + return steps; + } + // `JSON.stringify({ changed, activeTab })`, keys in that order (a + // `serde_json::Map` would sort them). + let details = format!( + "{{\"changed\":{},\"activeTab\":{}}}", + serde_json::Value::from(changed.to_vec()), + serde_json::Value::from(active_tab) + ); + steps.push(RefreshStep::Log { details }); + let touched = |target: &str| changed.iter().any(|c| c == target); + let any_of = |targets: &[&str]| targets.iter().any(|t| touched(t)); + let mut tab = active_tab.to_string(); + let mut tab_changed = false; + + if touched(STORAGE_TARGET_CUSTOM_THEMES) { + steps.push(RefreshStep::LoadCustomThemes); + } + if any_of(&SETTINGS_TARGETS) { + steps.push(RefreshStep::RefreshSettings); + if !settings.enabled_platforms.contains(&tab) || !is_platform_usable(registry, &tab, os) { + if add_flow_open { + steps.push(RefreshStep::CancelAddFlow); + } + steps.push(RefreshStep::ResetVisiblePrimeState); + steps.push(RefreshStep::ClearLoaderForPlatformChange); + tab = initial_active_tab(registry, settings, os); + steps.push(RefreshStep::SetActiveTab(tab.clone())); + tab_changed = true; + steps.push(RefreshStep::ClearCurrentFolder); + steps.push(RefreshStep::ReplaceHistory(tab.clone())); + } + } + if touched(CLIENT_STORE_VIEW_MODE) { + steps.push(RefreshStep::SyncViewMode); + } + if any_of(&CARD_COLOR_TARGETS) { + steps.push(RefreshStep::BumpCardColorVersion); + } + if touched(CLIENT_STORE_ACCOUNT_CARD_NOTES) { + steps.push(RefreshStep::BumpCardNoteVersion); + } + if touched(CLIENT_STORE_PERSONAS) { + steps.push(RefreshStep::RefreshPersonas); + } + if any_of(&GRID_TARGETS) { + steps.push(RefreshStep::RefreshCurrentItems); + steps.push(RefreshStep::PrepareVisibleAccounts); + steps.push(RefreshStep::QueueGridPadding); + } + + let capabilities = registry.definition(&tab).map(|def| &def.capabilities); + if tab_changed { + if is_platform_usable(registry, &tab, os) { + steps.push(RefreshStep::LoadAccounts { + force: true, + warnings: false, + }); + } else { + steps.push(RefreshStep::RefreshCurrentItems); + steps.push(RefreshStep::QueueGridPadding); + } + } else { + let external: Vec<&str> = capabilities + .map(|c| c.external_data_stores.iter().map(|s| s.as_ref()).collect()) + .unwrap_or_default(); + if any_of(&APP_CONFIG_TARGETS) || any_of(&external) { + steps.push(RefreshStep::LoadAccounts { + force: true, + warnings: capabilities.is_some_and(|c| c.account_warnings), + }); + } + } + steps +} + +/// The `externalStorageRefreshInFlight` guard plus the focus and visibility +/// handlers. +#[derive(Debug, Default)] +pub struct ExternalRefreshGate { + in_flight: bool, +} + +impl ExternalRefreshGate { + /// `handleWindowFocus`, or `handleVisibilityChange` with the window + /// visible: true when a refresh should start now. + pub fn begin(&mut self) -> bool { + if self.in_flight { + return false; + } + self.in_flight = true; + true + } + + /// The `finally`. + pub fn end(&mut self) { + self.in_flight = false; + } +} diff --git a/crates/accshift-app/src/lifecycle/boot.rs b/crates/accshift-app/src/lifecycle/boot.rs new file mode 100644 index 0000000..3d02a59 --- /dev/null +++ b/crates/accshift-app/src/lifecycle/boot.rs @@ -0,0 +1,215 @@ +//! The boot helpers of the webview side: `src/lib/app/bootMarks.ts`, +//! `bootLocale.ts`, `bootPayload.ts`, the log queue of `src/main.ts`, +//! `src/lib/shared/appLogger.ts`, `src/lib/app/telemetryClient.ts` and the +//! close request of `src/lib/app/useDocumentListeners.ts`. + +use crate::backend::Backend; +use crate::js; +use crate::types::SettingsSnapshot; +use serde_json::Value; +use std::collections::BTreeMap; +use std::time::{Duration, Instant}; + +/// main.ts: `finishBoot("load")` this long after `load` when `boot-ready` +/// never came. +pub const FINISH_BOOT_FALLBACK: Duration = Duration::from_millis(1500); +/// main.ts: the boot log buffer is written at boot end or after this. +pub const BOOT_LOG_FLUSH: Duration = Duration::from_millis(5000); +/// boot.rs `spawn_boot_failsafe`: show the window anyway after this. +pub const BOOT_FAILSAFE: Duration = Duration::from_millis(5000); +/// The failsafe's warn line. +pub const BOOT_FAILSAFE_SOURCE: &str = "backend.boot-failsafe"; +pub const BOOT_FAILSAFE_MESSAGE: &str = + "Rust failsafe triggered after 5000ms; forcing main window visibility"; +/// main.ts `queueLog`: message and details caps, in UTF-16 units. +pub const LOG_MESSAGE_MAX: usize = 512; +pub const LOG_DETAILS_MAX: usize = 16384; + +/// `queueLog`'s truncation. +pub fn truncate_log(message: &str, details: Option<&str>) -> (String, Option) { + ( + js::utf16_prefix(message, LOG_MESSAGE_MAX), + details.map(|details| js::utf16_prefix(details, LOG_DETAILS_MAX)), + ) +} + +/// `bootMarks.ts`: milestones in milliseconds since the process origin, +/// rounded to 0.1 ms; the first write wins. +#[derive(Debug, Clone)] +pub struct BootMarks { + origin: Instant, + marks: BTreeMap, +} + +impl BootMarks { + pub fn new(origin: Instant) -> Self { + Self { + origin, + marks: BTreeMap::new(), + } + } + + fn round(ms: f64) -> f64 { + (ms * 10.0).round() / 10.0 + } + + fn elapsed_ms(&self, now: Instant) -> f64 { + now.duration_since(self.origin).as_secs_f64() * 1000.0 + } + + /// `markBoot(name)`. + pub fn mark(&mut self, name: &str, now: Instant) { + let at = Self::round(self.elapsed_ms(now)); + self.marks.entry(name.to_string()).or_insert(at); + } + + /// `sinceBoot(name)`. + pub fn since(&self, name: &str, now: Instant) -> Option { + let at = *self.marks.get(name)?; + Some(Self::round(self.elapsed_ms(now) - at)) + } + + /// `bootMarks()`: the `marks` argument of `finish_boot`. + pub fn to_value(&self) -> Value { + Value::Object( + self.marks + .iter() + .map(|(name, at)| (name.clone(), Value::from(*at))) + .collect(), + ) + } +} + +/// `BOOT_LOCALE_KEY` of bootLocale.ts. +pub const BOOT_LOCALE_KEY: &str = "accshift_boot_locale"; + +/// `rememberBootLocale`: the value to write, when it differs from the one +/// stored. The egui build embeds every dictionary, so nothing reads it back; +/// the key is kept for a Tauri build sharing the profile. +pub fn remember_boot_locale(stored: Option<&str>, locale: &str) -> Option { + (stored != Some(locale)).then(|| locale.to_string()) +} + +/// `REDACTED_KEYS` of appLogger.ts, compared lowercased. +pub const REDACTED_KEYS: [&str; 14] = [ + "username", + "requestedusername", + "autologinuser", + "currentaccountfromloginusers", + "email", + "targetemail", + "currentaccount", + "steamid", + "accountid", + "profileid", + "targetprofileid", + "currentprofileid", + "uuid", + "userid", +]; + +/// `maskValue`. +pub fn mask_value(value: &Value) -> Value { + match value { + Value::String(text) if js::utf16_len(text) > 2 => { + Value::String(format!("{}***", js::utf16_prefix(text, 2))) + } + Value::Array(items) => Value::String(format!("[{} items]", items.len())), + _ => Value::String("***".into()), + } +} + +/// `redactObject`: masks the listed keys, recursing into nested objects +/// (not into arrays). +pub fn redact_object(object: &serde_json::Map) -> serde_json::Map { + object + .iter() + .map(|(key, value)| { + let value = if REDACTED_KEYS.contains(&key.to_lowercase().as_str()) { + mask_value(value) + } else if let Value::Object(inner) = value { + Value::Object(redact_object(inner)) + } else { + value.clone() + }; + (key.clone(), value) + }) + .collect() +} + +/// `serializeLogValue` for JSON values: strings as they are, objects +/// redacted, the rest as JSON. A top-level array goes through +/// `redactObject` too, as in TS (`typeof [] === "object"`), so it comes out +/// as an object keyed by index. Object keys come out sorted (TS keeps +/// insertion order, integer-like keys first). +pub fn serialize_log_value(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + Value::Object(object) => Value::Object(redact_object(object)).to_string(), + Value::Array(items) => { + let indexed = items + .iter() + .enumerate() + .map(|(index, item)| (index.to_string(), item.clone())) + .collect(); + Value::Object(redact_object(&indexed)).to_string() + } + other => other.to_string(), + } +} + +/// `logAppEvent`: details serialized, failures swallowed. +pub fn log_app_event( + backend: &dyn Backend, + level: &str, + source: &str, + message: &str, + details: Option<&Value>, +) { + let details = details + .filter(|value| !value.is_null()) + .map(serialize_log_value); + let _ = backend.log_app_event(level, source, message, details.as_deref()); +} + +/// telemetryClient.ts `trackUpdate`, fire and forget. +pub fn track_update( + backend: &dyn Backend, + stage: &str, + target_version: Option<&str>, + error_code: Option<&str>, +) { + let _ = backend.telemetry_track_update(stage, target_version, error_code); +} + +/// telemetryClient.ts `trackSettingsSnapshot`, fire and forget. +pub fn track_settings_snapshot(backend: &dyn Backend, snapshot: &SettingsSnapshot) { + let _ = backend.telemetry_track_settings_snapshot(snapshot); +} + +/// `createCloseRequestHandler`: the first close request flushes, then the +/// window goes; later ones are ignored. +#[derive(Debug, Default)] +pub struct CloseRequest { + closing: bool, +} + +impl CloseRequest { + /// True the first time: run the shutdown. False while it runs. + pub fn request(&mut self) -> bool { + if self.closing { + return false; + } + self.closing = true; + true + } + + pub fn is_closing(&self) -> bool { + self.closing + } +} + +/// The console line a failed flush on close writes. +pub fn close_flush_error(error: &str) -> String { + format!("Failed to flush pending saves on close: {error}") +} diff --git a/crates/accshift-app/src/lifecycle/deep_link.rs b/crates/accshift-app/src/lifecycle/deep_link.rs new file mode 100644 index 0000000..2864158 --- /dev/null +++ b/crates/accshift-app/src/lifecycle/deep_link.rs @@ -0,0 +1,822 @@ +//! Deep links: the `tauri-plugin-deep-link` 2.4.10 desktop half (launch +//! arguments, scheme registration), the Rust handler of +//! `src-tauri/src/boot.rs` (`wire_deep_links`, the log shape) and the TS +//! side (`src/lib/app/deepLinkUrl.ts`, `src/lib/app/useDeepLink.svelte.ts`). +//! +//! Plugin lines are in its `src/lib.rs`. + +use crate::accounts::loading::is_platform_usable; +use crate::js; +use crate::platforms::{PlatformAccount, PlatformRegistry, RuntimeOs}; +use crate::settings::AppSettings; +use crate::text::Text; +use std::time::{Duration, Instant}; +use url::Url; + +/// `plugins.deep-link.desktop.schemes` of tauri.conf.json. +pub const SCHEMES: &[&str] = &["accshift"]; + +/// `handle_cli_arguments` (lines 196-222), Windows and Linux: the argument +/// list must be the executable plus exactly one argument, a URL whose +/// scheme is configured. That URL becomes the plugin's `current` and is +/// emitted as `deep-link://new-url`. macOS gets URLs from the OS instead. +pub fn handle_cli_arguments>(args: &[S], schemes: &[&str]) -> Option { + if !(cfg!(windows) || cfg!(target_os = "linux")) { + return None; + } + if args.len() != 2 { + return None; + } + let url = args[1].as_ref().parse::().ok()?; + schemes.contains(&url.scheme()).then_some(url) +} + +/// What a second launch does in the running app: the single-instance +/// callback of src-tauri/src/lib.rs (lines 82-97), with the deep-link +/// plugin's `handle_cli_arguments` the `deep-link` feature runs first. +#[derive(Debug, Clone, PartialEq)] +pub struct SecondLaunch { + /// The URL delivered to the deep-link listeners, if the arguments were one. + pub url: Option, + /// `backend.single-instance` info line. + pub log_source: &'static str, + pub log_message: &'static str, + /// Show and focus the main window: only once boot completed. + pub show_window: bool, +} + +pub fn second_launch>(args: &[S], boot_completed: bool) -> SecondLaunch { + SecondLaunch { + url: handle_cli_arguments(args, SCHEMES), + log_source: "backend.single-instance", + log_message: "Second instance launch redirected to the running app", + show_window: boot_completed, + } +} + +/// What `on_open_url` of boot.rs (lines 699-731) does with delivered URLs. +#[derive(Debug, Clone, PartialEq)] +pub struct OpenUrlEffects { + /// `info`, `backend.deep-link`, "Deep link received", these details. + pub log_details: String, + /// `telemetry::Event::DeepLinkUsed` (`RealBackend::track_deep_link`). + pub track: bool, + pub show_window: bool, +} + +pub fn on_open_url(urls: &[Url], boot_completed: bool) -> OpenUrlEffects { + OpenUrlEffects { + log_details: urls + .iter() + .map(deep_link_log_shape) + .collect::>() + .join(" "), + track: true, + show_window: boot_completed, + } +} + +/// boot.rs lines 147-161: scheme, action and at most 32 characters of the +/// platform; the account and any query or fragment are replaced. +pub fn deep_link_log_shape(url: &Url) -> String { + let mut segments = url.path().split('/').filter(|s| !s.is_empty()); + let mut shape = format!("{}://{}", url.scheme(), url.host_str().unwrap_or("")); + if let Some(platform) = segments.next() { + shape.push('/'); + shape.push_str(&platform.chars().take(32).collect::()); + } + if segments.next().is_some() { + shape.push_str("/"); + } + if segments.next().is_some() || url.query().is_some() || url.fragment().is_some() { + shape.push_str("<+extra>"); + } + shape +} + +/// boot.rs lines 652-654: register unless every scheme answered `true`. A +/// failed check must arrive as `false` (fail open). +pub fn schemes_need_registration(schemes: &[String], registered: &[bool]) -> bool { + schemes.len() != registered.len() || registered.iter().any(|r| !r) +} + +/// `dunce::simplified(current_exe())`, what the registry entries name. +/// tauri-utils canonicalizes the path, which adds `\\?\` on Windows; dunce +/// takes it off again. +pub fn current_exe_for_registration() -> std::io::Result { + let exe = std::fs::canonicalize(std::env::current_exe()?)?; + Ok(dunce::simplified(&exe).display().to_string()) +} + +/// One registry value write of [`registration_entries`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RegistryValue { + /// Under `HKEY_CURRENT_USER`. + pub key: String, + /// `""` is the default value. + pub name: String, + pub value: String, +} + +/// `register` on Windows (lines 259-281), as data: the four values written +/// under `HKCU\Software\Classes\{scheme}`. +pub fn registration_entries(scheme: &str, identifier: &str, exe: &str) -> Vec { + let key_base = format!("Software\\Classes\\{scheme}"); + let value = |key: String, name: &str, value: String| RegistryValue { + key, + name: name.to_string(), + value, + }; + vec![ + value(key_base.clone(), "", format!("URL:{identifier} protocol")), + value(key_base.clone(), "URL Protocol", String::new()), + value(format!("{key_base}\\DefaultIcon"), "", format!("{exe},0")), + value( + format!("{key_base}\\shell\\open\\command"), + "", + format!("\"{exe}\" \"%1\""), + ), + ] +} + +/// The command `is_registered` compares with (lines 432-448). +pub fn registered_command(exe: &str) -> String { + format!("\"{exe}\" \"%1\"") +} + +/// The registry, behind a trait so tests never touch the real one. +pub trait SchemeRegistry { + /// Create `HKCU\{key}` and set one string value. + fn set_string(&mut self, key: &str, name: &str, value: &str) -> Result<(), String>; + /// `HKEY_CLASSES_ROOT\{key}` default value; `Ok(None)` when the key does + /// not open. + fn classes_root_default(&self, key: &str) -> Result, String>; +} + +/// `register` for one scheme. +pub fn register_scheme( + registry: &mut dyn SchemeRegistry, + scheme: &str, + identifier: &str, + exe: &str, +) -> Result<(), String> { + for entry in registration_entries(scheme, identifier, exe) { + registry.set_string(&entry.key, &entry.name, &entry.value)?; + } + Ok(()) +} + +/// `register_all`: every configured scheme, stopping at the first error. +pub fn register_all( + registry: &mut dyn SchemeRegistry, + schemes: &[&str], + identifier: &str, + exe: &str, +) -> Result<(), String> { + for scheme in schemes { + register_scheme(registry, scheme, identifier, exe)?; + } + Ok(()) +} + +/// `is_registered` on Windows (lines 432-448): the merged class view's +/// `{scheme}\shell\open\command` must be exactly `"{exe}" "%1"`. +pub fn is_registered( + registry: &dyn SchemeRegistry, + scheme: &str, + exe: &str, +) -> Result { + match registry.classes_root_default(&format!("{scheme}\\shell\\open\\command"))? { + None => Ok(false), + Some(command) => Ok(command == registered_command(exe)), + } +} + +/// The real registry, on `windows-registry` like the plugin. +#[cfg(windows)] +pub struct WindowsRegistry; + +#[cfg(windows)] +impl SchemeRegistry for WindowsRegistry { + fn set_string(&mut self, key: &str, name: &str, value: &str) -> Result<(), String> { + windows_registry::CURRENT_USER + .create(key) + .and_then(|key| key.set_string(name, value)) + .map_err(|e| e.to_string()) + } + + fn classes_root_default(&self, key: &str) -> Result, String> { + let Ok(key) = windows_registry::CLASSES_ROOT.open(key) else { + return Ok(None); + }; + key.get_string("").map(Some).map_err(|e| e.to_string()) + } +} + +/// The warn line `wire_deep_links` writes when registration fails. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RegistrationFailure { + pub source: &'static str, + pub message: &'static str, + pub details: String, +} + +/// boot.rs lines 661-695 on Windows: read each scheme, register them all +/// only when one does not point at this executable. +pub fn wire_scheme_registration( + registry: &mut dyn SchemeRegistry, + schemes: &[&str], + identifier: &str, + exe: &str, +) -> Result { + let owned: Vec = schemes.iter().map(ToString::to_string).collect(); + let registered: Vec = schemes + .iter() + .map(|scheme| is_registered(registry, scheme, exe).unwrap_or(false)) + .collect(); + if !schemes_need_registration(&owned, ®istered) { + return Ok(false); + } + register_all(registry, schemes, identifier, exe) + .map(|()| true) + .map_err(|details| RegistrationFailure { + source: "backend.deep-link", + message: "Failed to register accshift:// scheme", + details, + }) +} + +/// `register` on Linux (lines 283-364): a `{bin}-handler.desktop` file in +/// `{data_dir}/applications` claiming `x-scheme-handler/{scheme}`, merged +/// into an existing file, then `update-desktop-database` and `xdg-mime +/// default`. `wire_deep_links` runs it on every launch. +#[cfg(target_os = "linux")] +pub fn register_linux(scheme: &str, product_name: &str) -> Result<(), String> { + use std::io::Write; + use std::process::Command; + let bin = std::fs::canonicalize(std::env::current_exe().map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + let file_name = format!( + "{}-handler.desktop", + bin.file_name().unwrap_or_default().to_string_lossy() + ); + let exec = std::env::var_os("APPIMAGE") + .unwrap_or_else(|| bin.clone().into_os_string()) + .to_string_lossy() + .to_string(); + let qualified_exec = format!("\"{exec}\" %u"); + let target = dirs::data_dir() + .ok_or_else(|| "unknown data directory".to_string())? + .join("applications"); + std::fs::create_dir_all(&target).map_err(|e| e.to_string())?; + let target_file = target.join(&file_name); + let mime_type = format!("x-scheme-handler/{scheme}"); + + if let Ok(mut desktop_file) = ini::Ini::load_from_file(&target_file) { + if let Some(section) = desktop_file.section_mut(Some("Desktop Entry")) { + let old_mimes = section.remove("MimeType").unwrap_or_default(); + let mut change = false; + if !old_mimes.split(';').any(|mime| mime == mime_type) { + section.append("MimeType", format!("{mime_type};{old_mimes}")); + change = true; + } else { + section.insert("MimeType".to_string(), old_mimes); + } + let old_exec = section.remove("Exec").unwrap_or_default(); + if old_exec != qualified_exec { + section.append("Exec", qualified_exec); + change = true; + } else { + section.insert("Exec".to_string(), old_exec); + } + if change { + desktop_file + .write_to_file(&target_file) + .map_err(|e| e.to_string())?; + } + } + } else { + let mut file = std::fs::File::create(&target_file).map_err(|e| e.to_string())?; + file.write_all(desktop_template(product_name, &qualified_exec, &mime_type).as_bytes()) + .map_err(|e| e.to_string())?; + } + Command::new("update-desktop-database") + .arg(&target) + .status() + .map_err(|e| e.to_string())?; + Command::new("xdg-mime") + .args(["default", &file_name, mime_type.as_str()]) + .status() + .map_err(|e| e.to_string())?; + Ok(()) +} + +/// The plugin's `template.desktop`. +pub fn desktop_template(name: &str, qualified_exec: &str, mime_type: &str) -> String { + format!( + "[Desktop Entry]\nType=Application\nName={name}\nExec={qualified_exec}\nTerminal=false\nMimeType={mime_type}\nNoDisplay=true\n" + ) +} + +/// `DeepLinkSwitchRequest`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeepLinkSwitchRequest { + pub platform_id: String, + pub account_ref: String, +} + +/// `MAX_DEEP_LINK_URL_LENGTH`, in UTF-16 units. +pub const MAX_DEEP_LINK_URL_LENGTH: usize = 2048; + +/// `parseDeepLink`: `accshift://switch//`. +pub fn parse_deep_link(raw_url: &str) -> Option { + let trimmed = js::trim(raw_url); + if js::utf16_len(trimmed) > MAX_DEEP_LINK_URL_LENGTH { + return None; + } + const PREFIX: &str = "accshift://"; + let head = trimmed.get(..PREFIX.len())?; + if !head.eq_ignore_ascii_case(PREFIX) { + return None; + } + let rest = &trimmed[PREFIX.len()..]; + let path = &rest[..rest.find(['?', '#']).unwrap_or(rest.len())]; + let segments: Vec = path + .split('/') + .filter(|segment| !segment.is_empty()) + .map(|segment| decode_uri_component(segment).unwrap_or_else(|| segment.to_string())) + .collect(); + if segments.len() == 3 && segments[0].to_lowercase() == "switch" { + let platform_id = segments[1].to_lowercase(); + let account_ref = js::trim(&segments[2]).to_string(); + if !platform_id.is_empty() && !account_ref.is_empty() { + return Some(DeepLinkSwitchRequest { + platform_id, + account_ref, + }); + } + } + None +} + +/// `decodeURIComponent`: `None` where JS throws (a `%` without two hex +/// digits, or bytes that are not UTF-8). +pub fn decode_uri_component(text: &str) -> Option { + let bytes = text.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' { + let hex = bytes.get(index + 1..index + 3)?; + let hex = std::str::from_utf8(hex).ok()?; + out.push(u8::from_str_radix(hex, 16).ok()?); + index += 3; + } else { + out.push(bytes[index]); + index += 1; + } + } + String::from_utf8(out).ok() +} + +/// `findAccount`: an id match wins; a username or display name must be +/// unique, case-insensitive and trimmed. +pub fn find_account<'a>( + accounts: &'a [PlatformAccount], + reference: &str, +) -> Option<&'a PlatformAccount> { + let needle = js::trim(reference).to_lowercase(); + let key = |value: &str| js::trim(value).to_lowercase(); + if let Some(by_id) = accounts.iter().find(|account| key(&account.id) == needle) { + return Some(by_id); + } + let by_username: Vec<_> = accounts + .iter() + .filter(|account| key(&account.username) == needle) + .collect(); + match by_username.len() { + 1 => return Some(by_username[0]), + n if n > 1 => return None, + _ => {} + } + let by_display: Vec<_> = accounts + .iter() + .filter(|account| key(&account.display_name) == needle) + .collect(); + (by_display.len() == 1).then(|| by_display[0]) +} + +/// `BOOT_TIMEOUT_MS`. +pub const BOOT_TIMEOUT: Duration = Duration::from_millis(20_000); +/// `LOAD_TIMEOUT_MS`. +pub const LOAD_TIMEOUT: Duration = Duration::from_millis(15_000); + +/// What the controller reads of the app on each [`DeepLinkController::poll`]. +pub struct DeepLinkView<'a> { + pub boot_ready: bool, + pub pin_locked: bool, + pub settings: &'a AppSettings, + pub registry: &'a PlatformRegistry, + pub runtime_os: RuntimeOs, + pub active_tab: &'a str, + pub loader_loading: bool, + /// The visible tab's loaded accounts (`getAccounts`). + pub accounts: &'a [PlatformAccount], +} + +/// The work the controller hands the shell. Each one is answered with the +/// matching [`DeepLinkAnswer`]. +#[derive(Debug, Clone, PartialEq)] +pub enum DeepLinkAction { + Toast(Text), + /// `loadPlatformAccounts(platformId)`: answer `PlatformAccounts`. + LoadPlatformAccounts(String), + /// `loadAccounts()` of the visible tab: answer `AccountsLoaded`. + LoadAccounts, + /// `confirmSwitch(account, platformName)`: answer `Confirmed`. A shell + /// without the gate answers `true` at once. + Confirm { + account: PlatformAccount, + platform_name: String, + }, + /// `changeTab(platformId)`: answer `TabChanged`. + ChangeTab(String), + /// `switchToAccount(account)`: answer `Switched`. + Switch(PlatformAccount), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum DeepLinkAnswer { + PlatformAccounts(Vec), + AccountsLoaded, + Confirmed(bool), + TabChanged, + Switched(bool), +} + +#[derive(Debug, Clone, PartialEq)] +enum Phase { + Idle, + /// `handleUrl`'s wait for boot (it goes on after the timeout). + WaitBootForUrl { + url: String, + since: Instant, + }, + /// `handleSwitch`'s own wait (it gives up after the timeout). + WaitBootForSwitch { + request: DeepLinkSwitchRequest, + since: Instant, + }, + AwaitPlatformAccounts(Switch), + WaitLoader { + switch: Switch, + since: Instant, + }, + AwaitReload(Switch), + AwaitConfirm(Switch), + AwaitTabChange(Switch), + WaitLoaderAfterTab { + switch: Switch, + since: Instant, + }, + AwaitReloadAfterTab(Switch), + AwaitSwitch(Switch), +} + +#[derive(Debug, Clone, PartialEq)] +struct Switch { + request: DeepLinkSwitchRequest, + platform_name: String, + on_other_tab: bool, + account: Option, +} + +/// `createDeepLinkController`: one link at a time, the latest one queued. +#[derive(Debug)] +pub struct DeepLinkController { + started: bool, + phase: Phase, + pending: Option, +} + +impl Default for DeepLinkController { + fn default() -> Self { + Self::new() + } +} + +impl DeepLinkController { + pub fn new() -> Self { + Self { + started: false, + phase: Phase::Idle, + pending: None, + } + } + + /// `start`: listen from now on. URLs delivered before are not replayed: + /// `onOpenUrl` only listens for `deep-link://new-url`. + pub fn start(&mut self) { + self.started = true; + } + + /// `stop`: drop the listener. A link in flight finishes. + pub fn stop(&mut self) { + self.started = false; + } + + pub fn is_busy(&self) -> bool { + self.phase != Phase::Idle + } + + /// The listener: each URL goes to `handleUrl`. + pub fn on_urls>(&mut self, urls: &[S], now: Instant) { + if !self.started { + return; + } + for url in urls { + self.handle_url(url.as_ref().to_string(), now); + } + } + + /// `handleUrl`: busy keeps only the latest link. + fn handle_url(&mut self, url: String, now: Instant) { + if self.is_busy() { + self.pending = Some(url); + return; + } + self.phase = Phase::WaitBootForUrl { url, since: now }; + } + + /// The `finally` of `handleUrl`. + fn finish(&mut self, now: Instant) { + self.phase = Phase::Idle; + if let Some(next) = self.pending.take() { + self.handle_url(next, now); + } + } + + /// Advances as far as the view allows; call once per frame while busy. + pub fn poll(&mut self, view: &DeepLinkView<'_>, now: Instant) -> Vec { + let mut actions = Vec::new(); + loop { + let before = self.phase.clone(); + self.step(view, now, &mut actions); + if self.phase == before { + return actions; + } + } + } + + fn step(&mut self, view: &DeepLinkView<'_>, now: Instant, actions: &mut Vec) { + match std::mem::replace(&mut self.phase, Phase::Idle) { + Phase::Idle => {} + Phase::WaitBootForUrl { url, since } => { + if !view.boot_ready && now.duration_since(since) <= BOOT_TIMEOUT { + self.phase = Phase::WaitBootForUrl { url, since }; + return; + } + match parse_deep_link(&url) { + None => { + if view.settings.deep_links_enabled { + actions.push(DeepLinkAction::Toast(Text::key("toast.deepLinkInvalid"))); + } + self.finish(now); + } + Some(request) => { + self.phase = Phase::WaitBootForSwitch { + request, + since: now, + }; + } + } + } + Phase::WaitBootForSwitch { request, since } => { + if !view.boot_ready { + if now.duration_since(since) > BOOT_TIMEOUT { + self.finish(now); + } else { + self.phase = Phase::WaitBootForSwitch { request, since }; + } + return; + } + self.gate(request, view, now, actions); + } + Phase::WaitLoader { switch, since } => { + if view.loader_loading && now.duration_since(since) <= LOAD_TIMEOUT { + self.phase = Phase::WaitLoader { switch, since }; + return; + } + match find_account(view.accounts, &switch.request.account_ref) { + Some(account) => self.found(switch, account.clone(), actions), + None => { + actions.push(DeepLinkAction::LoadAccounts); + self.phase = Phase::AwaitReload(switch); + } + } + } + Phase::WaitLoaderAfterTab { switch, since } => { + if view.loader_loading && now.duration_since(since) <= LOAD_TIMEOUT { + self.phase = Phase::WaitLoaderAfterTab { switch, since }; + return; + } + let id = switch + .account + .as_ref() + .map(|a| a.id.clone()) + .unwrap_or_default(); + match view.accounts.iter().find(|candidate| candidate.id == id) { + Some(account) => self.switch(switch, account.clone(), actions), + None => { + actions.push(DeepLinkAction::LoadAccounts); + self.phase = Phase::AwaitReloadAfterTab(switch); + } + } + } + waiting => self.phase = waiting, + } + } + + /// The checks at the top of `handleSwitch`. + fn gate( + &mut self, + request: DeepLinkSwitchRequest, + view: &DeepLinkView<'_>, + now: Instant, + actions: &mut Vec, + ) { + if view.pin_locked { + return self.finish(now); + } + let toast = |actions: &mut Vec, text: Text| { + actions.push(DeepLinkAction::Toast(text)); + }; + if !view.settings.deep_links_enabled { + toast(actions, Text::key("toast.deepLinkDisabled")); + return self.finish(now); + } + let Some(def) = view.registry.definition(&request.platform_id) else { + toast( + actions, + Text::with( + "toast.deepLinkUnknownPlatform", + [("platform", request.platform_id.clone())], + ), + ); + return self.finish(now); + }; + if !is_platform_usable(view.registry, &request.platform_id, view.runtime_os) { + toast( + actions, + Text::with( + "app.platformUnsupportedOs", + [("platform", def.name.clone())], + ), + ); + return self.finish(now); + } + if !view + .settings + .enabled_platforms + .contains(&request.platform_id) + { + toast( + actions, + Text::with( + "toast.deepLinkPlatformDisabled", + [("platform", def.name.clone())], + ), + ); + return self.finish(now); + } + let on_other_tab = view.active_tab != request.platform_id; + let switch = Switch { + platform_name: def.name.clone(), + on_other_tab, + account: None, + request, + }; + if on_other_tab { + actions.push(DeepLinkAction::LoadPlatformAccounts( + switch.request.platform_id.clone(), + )); + self.phase = Phase::AwaitPlatformAccounts(switch); + } else { + self.phase = Phase::WaitLoader { switch, since: now }; + } + } + + fn not_found(&mut self, switch: &Switch, actions: &mut Vec, now: Instant) { + actions.push(DeepLinkAction::Toast(Text::with( + "toast.deepLinkAccountNotFound", + [ + ("platform", switch.platform_name.clone()), + ("account", switch.request.account_ref.clone()), + ], + ))); + self.finish(now); + } + + fn found( + &mut self, + mut switch: Switch, + account: PlatformAccount, + actions: &mut Vec, + ) { + actions.push(DeepLinkAction::Confirm { + account: account.clone(), + platform_name: switch.platform_name.clone(), + }); + switch.account = Some(account); + self.phase = Phase::AwaitConfirm(switch); + } + + fn switch( + &mut self, + mut switch: Switch, + account: PlatformAccount, + actions: &mut Vec, + ) { + actions.push(DeepLinkAction::Switch(account.clone())); + switch.account = Some(account); + self.phase = Phase::AwaitSwitch(switch); + } + + /// The shell's answer to the last action. `view` is read after it. + pub fn answer( + &mut self, + answer: DeepLinkAnswer, + view: &DeepLinkView<'_>, + now: Instant, + ) -> Vec { + let mut actions = Vec::new(); + match (std::mem::replace(&mut self.phase, Phase::Idle), answer) { + (Phase::AwaitPlatformAccounts(switch), DeepLinkAnswer::PlatformAccounts(accounts)) => { + match find_account(&accounts, &switch.request.account_ref) { + Some(account) => self.found(switch, account.clone(), &mut actions), + None => self.not_found(&switch, &mut actions, now), + } + } + (Phase::AwaitReload(switch), DeepLinkAnswer::AccountsLoaded) => { + match find_account(view.accounts, &switch.request.account_ref) { + Some(account) => self.found(switch, account.clone(), &mut actions), + None => self.not_found(&switch, &mut actions, now), + } + } + (Phase::AwaitConfirm(switch), DeepLinkAnswer::Confirmed(allowed)) => { + if !allowed { + self.finish(now); + } else if switch.on_other_tab || view.active_tab != switch.request.platform_id { + actions.push(DeepLinkAction::ChangeTab( + switch.request.platform_id.clone(), + )); + self.phase = Phase::AwaitTabChange(switch); + } else if let Some(account) = switch.account.clone() { + self.switch(switch, account, &mut actions); + } else { + self.finish(now); + } + } + (Phase::AwaitTabChange(switch), DeepLinkAnswer::TabChanged) => { + if view.active_tab != switch.request.platform_id { + self.finish(now); + } else { + self.phase = Phase::WaitLoaderAfterTab { switch, since: now }; + } + } + (Phase::AwaitReloadAfterTab(switch), DeepLinkAnswer::AccountsLoaded) => { + let id = switch + .account + .as_ref() + .map(|a| a.id.clone()) + .unwrap_or_default(); + match view.accounts.iter().find(|candidate| candidate.id == id) { + Some(account) => self.switch(switch, account.clone(), &mut actions), + None => self.not_found(&switch, &mut actions, now), + } + } + (Phase::AwaitSwitch(switch), DeepLinkAnswer::Switched(switched)) => { + if switched { + let name = switch + .account + .as_ref() + .and_then(|account| { + [&account.display_name, &account.username, &account.id] + .into_iter() + .find(|value| !value.is_empty()) + .cloned() + }) + .unwrap_or_default(); + actions.push(DeepLinkAction::Toast(Text::with( + "toast.deepLinkSwitched", + [("account", name)], + ))); + } + self.finish(now); + } + (phase, _) => self.phase = phase, + } + actions.extend(self.poll(view, now)); + actions + } +} diff --git a/crates/accshift-app/src/lifecycle/mod.rs b/crates/accshift-app/src/lifecycle/mod.rs new file mode 100644 index 0000000..690d115 --- /dev/null +++ b/crates/accshift-app/src/lifecycle/mod.rs @@ -0,0 +1,246 @@ +//! The app lifecycle of the Tauri build, for the egui shell: boot and +//! shutdown order, the updater, single instance, deep links and process +//! control. +//! +//! - [`Lifecycle`]: the one entry point. [`Lifecycle::boot`] and +//! [`Lifecycle::shutdown`] run the steps of [`BOOT_ORDER`] and +//! [`SHUTDOWN_ORDER`] through a [`LifecycleHost`] the egui `AppModel` +//! implements; tests record the calls. +//! - [`updater`]: `tauri-plugin-updater` 2.11.0; [`update_flow`]: the +//! `useAppUpdater` state machine on top of it. +//! - [`single_instance`]: `tauri-plugin-single-instance` 2.4.4, same names +//! as the Tauri build so both see each other. +//! - [`deep_link`]: `tauri-plugin-deep-link` 2.4.10, `wire_deep_links`, +//! `deepLinkUrl.ts` and `useDeepLink`. +//! - [`process`]: `tauri-plugin-process` 2.3.1 (`exit`, `relaunch`). +//! - [`app_shell`]: `useAppLifecycle` (shell init, external refresh). +//! - [`boot`]: boot marks, boot locale, the log redaction, the telemetry +//! calls and the close request; [`ui_scale`]: `useUiScale` and the +//! backdrop decisions of `useThemeApplication`. + +pub mod app_shell; +pub mod boot; +pub mod deep_link; +pub mod process; +pub mod single_instance; +pub mod ui_scale; +pub mod update_flow; +pub mod updater; + +use std::time::Duration; + +/// App.svelte: the update check starts this long after mount. +pub const UPDATE_CHECK_DELAY: Duration = Duration::from_millis(3500); +/// boot.rs `install_window_event_handlers`: the geometry save gets this +/// long before the process goes on without it. +pub const GEOMETRY_SAVE_WAIT: Duration = Duration::from_secs(5); + +/// One boot step, in [`BOOT_ORDER`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BootStep { + /// [`single_instance::acquire`] with the app identifier + /// (`real::BUNDLE_IDENTIFIER`): the first plugin of src-tauri/src/lib.rs. + /// A forwarded launch exits here with code 0, before anything else. + SingleInstance, + /// [`deep_link::handle_cli_arguments`] on this launch's arguments: the + /// deep-link plugin's `init`. The URL is the plugin's `current`; the + /// listener started later does not see it (see the M5 traps). + LaunchUrl, + /// `legacy_web_storage::read` when `needs_read`: before any panic hook, + /// so a crash in the LevelDB reader cannot leave a half-written log. + LegacyStorageRead, + /// `storage::initialize` with the boot snapshot and the legacy map. + StorageInitialize, + /// The theme, locale and motion of the first frame + /// (`useThemeApplication`). + Theme, + /// `RealBackend::start_telemetry` (`TelemetryState::new`). + TelemetryStart, + /// `RealBackend::begin_session`: log rotation, "App setup started", + /// panic hook. + SessionBegin, + /// `RealBackend::spawn_maintenance` (`spawn_snapshot_upgrade`). + Maintenance, + /// [`deep_link::wire_scheme_registration`] on Windows, + /// `register_linux` on Linux: `wire_deep_links`. + SchemeRegistration, + /// Show the window after [`boot::BOOT_FAILSAFE`] if boot never finished. + BootFailsafe, + /// [`update_flow::AppUpdater::start`] after [`UPDATE_CHECK_DELAY`]. + UpdaterCheck, +} + +/// The boot order. The brief's fixed part (legacy read, storage, theme, +/// telemetry, session, maintenance, updater) keeps its order; the plugin +/// steps sit where Tauri runs them: single instance and the launch URL +/// before anything (plugin `init`), scheme registration and the failsafe +/// after the window exists (`setup`). +pub const BOOT_ORDER: [BootStep; 11] = [ + BootStep::SingleInstance, + BootStep::LaunchUrl, + BootStep::LegacyStorageRead, + BootStep::StorageInitialize, + BootStep::Theme, + BootStep::TelemetryStart, + BootStep::SessionBegin, + BootStep::Maintenance, + BootStep::SchemeRegistration, + BootStep::BootFailsafe, + BootStep::UpdaterCheck, +]; + +/// One shutdown step, in [`SHUTDOWN_ORDER`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShutdownStep { + /// `spawn_window_geometry_save`. + StartGeometrySave, + HideWindow, + /// `RealBackend::end_telemetry_session`: `session_ended`, then the + /// flush bounded by 2.5 s. + EndTelemetrySession, + /// Wait for the geometry save, at most [`GEOMETRY_SAVE_WAIT`]. + WaitGeometrySave, + /// The webview's close handler: the pending UI scale, the settings + /// flush, then the store saves (`flushAppState`). Errors are logged. + FlushSaves, + /// `RunEvent::Exit`: the single-instance `destroy`. + ReleaseSingleInstance, + /// The window is destroyed and the process ends. + Exit, +} + +/// The close order. The Rust `CloseRequested` handler runs first and +/// blocks (boot.rs lines 599-631), the webview's `onCloseRequested` flushes +/// next and destroys the window, the plugins' exit hooks run last. +pub const SHUTDOWN_ORDER: [ShutdownStep; 7] = [ + ShutdownStep::StartGeometrySave, + ShutdownStep::HideWindow, + ShutdownStep::EndTelemetrySession, + ShutdownStep::WaitGeometrySave, + ShutdownStep::FlushSaves, + ShutdownStep::ReleaseSingleInstance, + ShutdownStep::Exit, +]; + +/// The side effects of each step. The egui `AppModel` implements it on the +/// real backend, storage and window; tests record the calls. +pub trait LifecycleHost { + /// True when this process goes on as the first instance (or alone), + /// false when it handed its arguments to a running one. + fn acquire_single_instance(&mut self) -> bool; + fn launch_url(&mut self, url: Option); + fn read_legacy_storage(&mut self); + fn initialize_storage(&mut self); + fn apply_theme(&mut self); + fn start_telemetry(&mut self); + fn begin_session(&mut self); + fn spawn_maintenance(&mut self); + fn register_schemes(&mut self); + fn arm_boot_failsafe(&mut self, after: Duration); + fn schedule_update_check(&mut self, after: Duration); + + fn start_geometry_save(&mut self); + fn hide_window(&mut self); + fn end_telemetry_session(&mut self); + fn wait_geometry_save(&mut self, bound: Duration); + fn flush_saves(&mut self) -> Result<(), String>; + fn release_single_instance(&mut self); + /// `std::process::exit` for a forwarded launch, closing the viewport + /// on a normal close. Fakes record it. + fn exit(&mut self, code: i32); + /// A log line (`level`, `source`, `message`). + fn log(&mut self, level: &str, source: &str, message: &str); +} + +/// How [`Lifecycle::boot`] ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BootOutcome { + /// Boot ran every step; the app is up. + Running, + /// Another instance took the arguments; `exit(0)` was called. + Forwarded, +} + +/// The entry point. +#[derive(Debug)] +pub struct Lifecycle { + args: Vec, + close: boot::CloseRequest, + booted: bool, +} + +impl Lifecycle { + /// `args`: this launch's command line, executable first + /// ([`single_instance::current_args`]). + pub fn new(args: Vec) -> Self { + Self { + args, + close: boot::CloseRequest::default(), + booted: false, + } + } + + pub fn args(&self) -> &[String] { + &self.args + } + + /// Runs [`BOOT_ORDER`] once. A second call does nothing. + pub fn boot(&mut self, host: &mut dyn LifecycleHost) -> BootOutcome { + if self.booted { + return BootOutcome::Running; + } + self.booted = true; + for step in BOOT_ORDER { + match step { + BootStep::SingleInstance => { + if !host.acquire_single_instance() { + host.exit(0); + return BootOutcome::Forwarded; + } + } + BootStep::LaunchUrl => host.launch_url(deep_link::handle_cli_arguments( + &self.args, + deep_link::SCHEMES, + )), + BootStep::LegacyStorageRead => host.read_legacy_storage(), + BootStep::StorageInitialize => host.initialize_storage(), + BootStep::Theme => host.apply_theme(), + BootStep::TelemetryStart => host.start_telemetry(), + BootStep::SessionBegin => host.begin_session(), + BootStep::Maintenance => host.spawn_maintenance(), + BootStep::SchemeRegistration => host.register_schemes(), + BootStep::BootFailsafe => host.arm_boot_failsafe(boot::BOOT_FAILSAFE), + BootStep::UpdaterCheck => host.schedule_update_check(UPDATE_CHECK_DELAY), + } + } + BootOutcome::Running + } + + /// Runs [`SHUTDOWN_ORDER`] on the first close request; false when a + /// close is already under way (`isClosing`). + pub fn shutdown(&mut self, host: &mut dyn LifecycleHost) -> bool { + if !self.close.request() { + return false; + } + for step in SHUTDOWN_ORDER { + match step { + ShutdownStep::StartGeometrySave => host.start_geometry_save(), + ShutdownStep::HideWindow => host.hide_window(), + ShutdownStep::EndTelemetrySession => host.end_telemetry_session(), + ShutdownStep::WaitGeometrySave => host.wait_geometry_save(GEOMETRY_SAVE_WAIT), + ShutdownStep::FlushSaves => { + if let Err(error) = host.flush_saves() { + host.log("error", "frontend.app", &boot::close_flush_error(&error)); + } + } + ShutdownStep::ReleaseSingleInstance => host.release_single_instance(), + ShutdownStep::Exit => host.exit(0), + } + } + true + } + + pub fn is_closing(&self) -> bool { + self.close.is_closing() + } +} diff --git a/crates/accshift-app/src/lifecycle/process.rs b/crates/accshift-app/src/lifecycle/process.rs new file mode 100644 index 0000000..85686f2 --- /dev/null +++ b/crates/accshift-app/src/lifecycle/process.rs @@ -0,0 +1,95 @@ +//! `tauri-plugin-process` 2.3.1 (`exit`, `restart`) on Tauri 2.11.5's +//! `process::restart` (src/process.rs lines 20-89). The spawn and the exit +//! go through [`ProcessControl`], so tests never relaunch or exit. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +/// The two process side effects. +pub trait ProcessControl { + /// `Command::new(program).args(args).spawn()`. + fn spawn(&self, program: &Path, args: &[OsString]) -> std::io::Result<()>; + /// `std::process::exit(code)`. The real one never returns. + fn exit(&self, code: i32); +} + +/// The real one. +pub struct SystemProcess; + +impl ProcessControl for SystemProcess { + fn spawn(&self, program: &Path, args: &[OsString]) -> std::io::Result<()> { + std::process::Command::new(program) + .args(args) + .spawn() + .map(|_| ()) + } + + fn exit(&self, code: i32) { + std::process::exit(code); + } +} + +/// `process::current_binary` (lines 20-27): the AppImage on Linux when +/// `APPIMAGE` is set, else the canonical current executable. +pub fn current_binary() -> std::io::Result { + #[cfg(target_os = "linux")] + if let Some(appimage) = std::env::var_os("APPIMAGE") { + return Ok(PathBuf::from(appimage)); + } + std::fs::canonicalize(std::env::current_exe()?) +} + +/// `restart_macos_app` (lines 92-130): inside `X.app/Contents/MacOS`, the +/// binary `Info.plist` names, which an update may have renamed. +#[cfg(target_os = "macos")] +fn macos_bundle_binary(current_binary: &Path) -> Option { + let macos_directory = current_binary.parent()?; + if macos_directory.file_name()? != "MacOS" { + return None; + } + let contents_directory = macos_directory.parent()?; + if contents_directory.file_name()? != "Contents" { + return None; + } + let info: plist::Dictionary = plist::from_file(contents_directory.join("Info.plist")).ok()?; + let binary_name = info.get("CFBundleExecutable")?.as_string()?; + Some(macos_directory.join(binary_name)) +} + +/// The program a relaunch starts. +pub fn relaunch_program(current_binary: &Path) -> PathBuf { + #[cfg(target_os = "macos")] + if let Some(bundle_binary) = macos_bundle_binary(current_binary) { + return bundle_binary; + } + current_binary.to_path_buf() +} + +/// `relaunch()`: `app.request_restart()`, which ends the event loop (the +/// plugins' `RunEvent::Exit` runs, so the single-instance mutex and window +/// go first: `before_exit`), then `process::restart`: start the binary with +/// this launch's arguments minus the first, then `exit(0)`. A spawn error is +/// only logged upstream; it is returned here and the exit still happens. +pub fn restart( + control: &dyn ProcessControl, + binary: std::io::Result, + args_os: &[OsString], + before_exit: impl FnOnce(), +) -> Option { + before_exit(); + let mut error = None; + if let Ok(path) = binary { + let program = relaunch_program(&path); + if let Err(e) = control.spawn(&program, args_os.get(1..).unwrap_or_default()) { + error = Some(e); + } + } + control.exit(0); + error +} + +/// `exit(code)`: `app.exit(code)`, the same exit path with that code. +pub fn exit(control: &dyn ProcessControl, code: i32, before_exit: impl FnOnce()) { + before_exit(); + control.exit(code); +} diff --git a/crates/accshift-app/src/lifecycle/single_instance/linux.rs b/crates/accshift-app/src/lifecycle/single_instance/linux.rs new file mode 100644 index 0000000..a2c76fe --- /dev/null +++ b/crates/accshift-app/src/lifecycle/single_instance/linux.rs @@ -0,0 +1,77 @@ +//! platform_impl/linux.rs of `tauri-plugin-single-instance` 2.4.4: own the +//! D-Bus name `{id}.SingleInstance`, or call `ExecuteCallback` on whoever +//! owns it. + +use super::{dbus_name_and_path, Notify, PlatformLaunch}; +use zbus::blocking::Connection; +use zbus::interface; +use zbus::names::WellKnownName; + +struct SingleInstanceDBus { + notify: Notify, +} + +/// linux.rs lines 17-27. +#[interface(name = "org.SingleInstance.DBus")] +impl SingleInstanceDBus { + fn execute_callback(&mut self, argv: Vec, cwd: String) { + self.notify.deliver(argv, cwd); + } +} + +/// The connection that owns the name. Dropping it is `destroy` +/// (linux.rs lines 103-113): release the name. +pub struct Guard { + connection: Option, + name: String, +} + +impl Drop for Guard { + fn drop(&mut self) { + if let Some(connection) = &self.connection { + if let Ok(name) = WellKnownName::try_from(self.name.clone()) { + let _ = connection.release_name(name); + } + } + } +} + +/// linux.rs lines 29-99. +pub(super) fn acquire( + identifier: &str, + args: &[String], + cwd: &str, + notify: Notify, +) -> PlatformLaunch { + let (dbus_name, dbus_path) = dbus_name_and_path(identifier); + // Upstream unwraps these three: no session bus panics the app. Here + // that case runs without single instance. + let built = zbus::blocking::connection::Builder::session() + .and_then(|builder| builder.name(dbus_name.as_str())) + .map(|builder| { + builder + .replace_existing_names(false) + .allow_name_replacements(false) + }) + .and_then(|builder| builder.serve_at(dbus_path.as_str(), SingleInstanceDBus { notify })) + .and_then(|builder| builder.build()); + match built { + Ok(connection) => PlatformLaunch::Primary(Guard { + connection: Some(connection), + name: dbus_name, + }), + Err(zbus::Error::NameTaken) => { + if let Ok(connection) = Connection::session() { + let _ = connection.call_method( + Some(dbus_name.as_str()), + dbus_path.as_str(), + Some("org.SingleInstance.DBus"), + "ExecuteCallback", + &(args.to_vec(), cwd), + ); + } + PlatformLaunch::Forwarded + } + Err(_) => PlatformLaunch::Alone(None), + } +} diff --git a/crates/accshift-app/src/lifecycle/single_instance/macos.rs b/crates/accshift-app/src/lifecycle/single_instance/macos.rs new file mode 100644 index 0000000..5a7b3f3 --- /dev/null +++ b/crates/accshift-app/src/lifecycle/single_instance/macos.rs @@ -0,0 +1,72 @@ +//! platform_impl/macos.rs of `tauri-plugin-single-instance` 2.4.4: a Unix +//! socket in `/tmp`. Upstream listens on a tokio task; a plain thread here, +//! same bytes. + +use super::{macos_socket_path, Notify, PlatformLaunch}; +use std::io::{BufWriter, ErrorKind, Read, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; + +/// The socket. Dropping it is `destroy` (macos.rs lines 56-59): remove the +/// file. The accept thread is left blocked, as upstream's task is. +pub struct Guard { + socket: PathBuf, +} + +impl Drop for Guard { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.socket); + } +} + +/// macos.rs lines 21-46. +pub(super) fn acquire( + identifier: &str, + args: &[String], + cwd: &str, + notify: Notify, +) -> PlatformLaunch { + let socket = macos_socket_path(identifier); + match notify_singleton(&socket, args, cwd) { + Ok(()) => PlatformLaunch::Forwarded, + Err(error) => match error.kind() { + ErrorKind::NotFound | ErrorKind::ConnectionRefused => { + let _ = std::fs::remove_file(&socket); + listen_for_other_instances(socket.clone(), notify); + PlatformLaunch::Primary(Guard { socket }) + } + _ => PlatformLaunch::Alone(None), + }, + } +} + +/// macos.rs lines 78-93: `cwd`, two NULs, the arguments joined by NUL. +fn notify_singleton(socket: &Path, args: &[String], cwd: &str) -> std::io::Result<()> { + let stream = UnixStream::connect(socket)?; + let mut writer = BufWriter::new(&stream); + writer.write_all(cwd.as_bytes())?; + writer.write_all(b"\0\0")?; + writer.write_all(args.join("\0").as_bytes())?; + writer.flush()?; + Ok(()) +} + +/// macos.rs lines 95-133. +fn listen_for_other_instances(socket: PathBuf, notify: Notify) { + let _ = std::thread::Builder::new() + .name("accshift-single-instance".into()) + .spawn(move || { + let Ok(listener) = UnixListener::bind(socket) else { + return; + }; + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + let mut text = String::new(); + if stream.read_to_string(&mut text).is_ok() { + let (cwd, args) = text.split_once("\0\0").unwrap_or_default(); + let args: Vec = args.split('\0').map(String::from).collect(); + notify.deliver(args, cwd.to_string()); + } + } + }); +} diff --git a/crates/accshift-app/src/lifecycle/single_instance/mod.rs b/crates/accshift-app/src/lifecycle/single_instance/mod.rs new file mode 100644 index 0000000..2c0ea28 --- /dev/null +++ b/crates/accshift-app/src/lifecycle/single_instance/mod.rs @@ -0,0 +1,232 @@ +//! The port of `tauri-plugin-single-instance` 2.4.4 (built with its +//! `deep-link` feature and without `semver`, as the Tauri app builds it). +//! +//! Same names on every OS, so a Tauri instance and an egui instance of the +//! same identifier find each other: on Windows the named mutex +//! `{id}-sim` and the hidden message window of class `{id}-sic` titled +//! `{id}-siw`, reached with `WM_COPYDATA` (`dwData` 1542, `"cwd|arg0|arg1\0"`); +//! on Linux the D-Bus name `{id}.SingleInstance` at `/{id with . as /}/SingleInstance` +//! (`-` as `_`), method `org.SingleInstance.DBus.ExecuteCallback(argv, cwd)`; +//! on macOS the socket `/tmp/{id with . and - as _}_si.sock`, `cwd\0\0arg0\0arg1`. +//! +//! [`acquire`] never exits the process: it answers [`Launch::Forwarded`] and +//! the caller exits (upstream calls `std::process::exit(0)` itself, after +//! `cleanup_before_exit` on Windows and Linux). Second launches arrive on +//! [`SingleInstance::try_recv`], from a thread; `wake` runs after each one +//! (the egui shell passes `request_repaint`). + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(windows)] +mod windows; + +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::Arc; + +/// A second launch: its whole `argv` (executable first) and its working +/// folder, as the plugin callback receives them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SecondInstance { + pub args: Vec, + pub cwd: String, +} + +pub type Wake = Arc; + +/// Where a second launch goes: the channel and the repaint hook. +#[derive(Clone)] +pub(crate) struct Notify { + sender: Sender, + wake: Option, +} + +impl Notify { + pub(crate) fn deliver(&self, args: Vec, cwd: String) { + let _ = self.sender.send(SecondInstance { args, cwd }); + if let Some(wake) = &self.wake { + wake(); + } + } +} + +/// How this launch goes on. +pub enum Launch { + /// First instance: keep the guard alive for the whole run. + Primary(SingleInstance), + /// Another instance got this launch's arguments: exit with code 0. + Forwarded, + /// Neither: run without single-instance protection, as upstream does + /// when the mutex exists but its window does not (Windows), the D-Bus + /// session refuses the name for another reason (Linux) or the socket + /// fails otherwise (macOS). The value keeps what upstream leaks for the + /// process lifetime (the Windows mutex handle). + Alone(Option), +} + +impl std::fmt::Debug for Launch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Primary(_) => "Primary", + Self::Forwarded => "Forwarded", + Self::Alone(_) => "Alone", + }) + } +} + +/// The running single-instance service. Dropping it is the plugin's +/// `destroy` (`RunEvent::Exit`): release the mutex and destroy the window, +/// release the D-Bus name, or remove the socket. +pub struct SingleInstance { + receiver: Receiver, + #[allow(dead_code)] + guard: Option, +} + +impl SingleInstance { + /// The next second launch, if one arrived. + pub fn try_recv(&self) -> Option { + self.receiver.try_recv().ok() + } + + /// The channel itself (blocking waits in tests). + pub fn receiver(&self) -> &Receiver { + &self.receiver + } +} + +#[cfg(windows)] +type PlatformGuard = windows::Guard; +#[cfg(target_os = "linux")] +type PlatformGuard = linux::Guard; +#[cfg(target_os = "macos")] +type PlatformGuard = macos::Guard; +#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] +type PlatformGuard = (); + +/// What a platform module answers. +pub(crate) enum PlatformLaunch { + Primary(PlatformGuard), + Forwarded, + Alone(Option), +} + +/// The plugin's `setup` for `identifier`, forwarding `args` and `cwd` when +/// another instance runs. Tests pass their own identifier and arguments; +/// the app passes [`current_args`] and [`current_cwd`]. +pub fn acquire(identifier: &str, args: &[String], cwd: &str, wake: Option) -> Launch { + let (sender, receiver) = mpsc::channel(); + let notify = Notify { sender, wake }; + let launch = platform_acquire(identifier, args, cwd, notify); + match launch { + PlatformLaunch::Primary(guard) => Launch::Primary(SingleInstance { + receiver, + guard: Some(guard), + }), + PlatformLaunch::Forwarded => Launch::Forwarded, + PlatformLaunch::Alone(guard) => Launch::Alone(guard.map(|guard| SingleInstance { + receiver, + guard: Some(guard), + })), + } +} + +#[cfg(windows)] +fn platform_acquire( + identifier: &str, + args: &[String], + cwd: &str, + notify: Notify, +) -> PlatformLaunch { + windows::acquire(identifier, args, cwd, notify) +} + +#[cfg(target_os = "linux")] +fn platform_acquire( + identifier: &str, + args: &[String], + cwd: &str, + notify: Notify, +) -> PlatformLaunch { + linux::acquire(identifier, args, cwd, notify) +} + +#[cfg(target_os = "macos")] +fn platform_acquire( + identifier: &str, + args: &[String], + cwd: &str, + notify: Notify, +) -> PlatformLaunch { + macos::acquire(identifier, args, cwd, notify) +} + +#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] +fn platform_acquire( + _identifier: &str, + _args: &[String], + _cwd: &str, + _notify: Notify, +) -> PlatformLaunch { + PlatformLaunch::Alone(None) +} + +/// `std::env::args()` as upstream forwards it, lossy instead of panicking +/// on an argument that is not UTF-8. +pub fn current_args() -> Vec { + std::env::args_os() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect() +} + +/// `std::env::current_dir().unwrap_or_default().to_str().unwrap_or_default()`. +pub fn current_cwd() -> String { + std::env::current_dir() + .unwrap_or_default() + .to_str() + .unwrap_or_default() + .to_string() +} + +/// The Windows names, `{id}-sic`, `{id}-siw`, `{id}-sim` +/// (platform_impl/windows.rs lines 65-67 without the `semver` suffix). +pub fn windows_names(identifier: &str) -> (String, String, String) { + ( + format!("{identifier}-sic"), + format!("{identifier}-siw"), + format!("{identifier}-sim"), + ) +} + +/// The `WM_COPYDATA` payload, `"{cwd}|{args joined by |}\0"` +/// (platform_impl/windows.rs lines 76-82). +pub fn windows_payload(args: &[String], cwd: &str) -> String { + format!("{cwd}|{}\0", args.join("|")) +} + +/// The receiving split of that payload (lines 145-155): text up to the first +/// NUL, cut on `|`, the first piece is the cwd. +pub fn parse_windows_payload(data: &[u8]) -> (String, Vec) { + let end = data.iter().position(|b| *b == 0).unwrap_or(data.len()); + let text = String::from_utf8_lossy(&data[..end]); + let mut pieces = text.split('|'); + let cwd = pieces.next().unwrap_or_default().to_string(); + (cwd, pieces.map(str::to_string).collect()) +} + +/// The D-Bus name and object path (platform_impl/linux.rs lines 37-49). +pub fn dbus_name_and_path(identifier: &str) -> (String, String) { + let name = format!("{identifier}.SingleInstance"); + let mut path = name.replace('.', "/").replace('-', "_"); + if !path.starts_with('/') { + path = format!("/{path}"); + } + (name, path) +} + +/// The macOS socket (platform_impl/macos.rs lines 61-72). +pub fn macos_socket_path(identifier: &str) -> std::path::PathBuf { + let identifier = identifier.replace(['.', '-'], "_"); + std::path::PathBuf::from(format!("/tmp/{identifier}_si.sock")) +} diff --git a/crates/accshift-app/src/lifecycle/single_instance/windows.rs b/crates/accshift-app/src/lifecycle/single_instance/windows.rs new file mode 100644 index 0000000..1debaf3 --- /dev/null +++ b/crates/accshift-app/src/lifecycle/single_instance/windows.rs @@ -0,0 +1,270 @@ +//! platform_impl/windows.rs of `tauri-plugin-single-instance` 2.4.4. +//! +//! Upstream creates the mutex and the message window on Tauri's main thread, +//! whose event loop pumps the window's messages. The egui shell owns no +//! Win32 loop it can lend, so here one dedicated thread creates the mutex +//! (it must own it to release it), creates the same window and pumps it. +//! Names, styles, the `WM_COPYDATA` payload and the release order on exit +//! are upstream's. + +use super::{parse_windows_payload, windows_names, windows_payload, Notify, PlatformLaunch}; +use std::sync::mpsc; +use std::thread::JoinHandle; +use windows_sys::Win32::Foundation::{ + CloseHandle, GetLastError, ERROR_ALREADY_EXISTS, HANDLE, HWND, LPARAM, LRESULT, WPARAM, +}; +use windows_sys::Win32::System::DataExchange::COPYDATASTRUCT; +use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW; +use windows_sys::Win32::System::Threading::{CreateMutexW, ReleaseMutex}; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, FindWindowW, GetMessageW, + GetWindowLongPtrW, PostMessageW, PostQuitMessage, RegisterClassExW, SendMessageW, + SetWindowLongPtrW, TranslateMessage, CREATESTRUCTW, GWLP_USERDATA, GWL_STYLE, MSG, WM_APP, + WM_COPYDATA, WM_CREATE, WM_DESTROY, WNDCLASSEXW, WS_EX_LAYERED, WS_EX_NOACTIVATE, + WS_EX_TOOLWINDOW, WS_EX_TRANSPARENT, WS_OVERLAPPED, WS_POPUP, WS_VISIBLE, +}; + +/// windows.rs line 41. +const WMCOPYDATA_SINGLE_INSTANCE_DATA: usize = 1542; + +/// Ours: asks the window thread to run `destroy` (release the mutex, then +/// destroy the window, upstream's order). +const WM_SINGLE_INSTANCE_DESTROY: u32 = WM_APP + 0x1542; + +struct UserData { + notify: Notify, + mutex: HANDLE, +} + +/// The running service: the window thread and its window. +pub struct Guard { + hwnd: isize, + /// A handle kept open for the run when upstream leaks it (mutex found, + /// window not found). + leaked_mutex: isize, + thread: Option>, +} + +impl Drop for Guard { + fn drop(&mut self) { + if self.hwnd != 0 { + // SAFETY: posting to a window this guard created; a dead handle + // only makes the call fail. + unsafe { PostMessageW(self.hwnd as HWND, WM_SINGLE_INSTANCE_DESTROY, 0, 0) }; + } + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + if self.leaked_mutex != 0 { + // SAFETY: a handle this guard owns, closed once. + unsafe { CloseHandle(self.leaked_mutex as HANDLE) }; + } + } +} + +enum ThreadAnswer { + Primary(isize), + Forwarded, + Alone(isize), +} + +fn encode_wide(text: &str) -> Vec { + text.encode_utf16().chain(std::iter::once(0)).collect() +} + +pub(super) fn acquire( + identifier: &str, + args: &[String], + cwd: &str, + notify: Notify, +) -> PlatformLaunch { + let (class, window, mutex) = windows_names(identifier); + let payload = windows_payload(args, cwd); + let (answer_tx, answer_rx) = mpsc::sync_channel::(1); + let thread = std::thread::Builder::new() + .name("accshift-single-instance".into()) + .spawn(move || run(class, window, mutex, payload, notify, answer_tx)); + let Ok(thread) = thread else { + return PlatformLaunch::Alone(None); + }; + match answer_rx.recv() { + Ok(ThreadAnswer::Primary(hwnd)) => PlatformLaunch::Primary(Guard { + hwnd, + leaked_mutex: 0, + thread: Some(thread), + }), + Ok(ThreadAnswer::Forwarded) => { + let _ = thread.join(); + PlatformLaunch::Forwarded + } + Ok(ThreadAnswer::Alone(mutex)) => { + let _ = thread.join(); + PlatformLaunch::Alone(Some(Guard { + hwnd: 0, + leaked_mutex: mutex, + thread: None, + })) + } + Err(_) => PlatformLaunch::Alone(None), + } +} + +/// windows.rs lines 54-108 (`setup`), then the message loop. +fn run( + class: String, + window: String, + mutex: String, + payload: String, + notify: Notify, + answer: mpsc::SyncSender, +) { + let class_name = encode_wide(&class); + let window_name = encode_wide(&window); + let mutex_name = encode_wide(&mutex); + // SAFETY: plain Win32 calls on NUL-terminated buffers that outlive them. + unsafe { + let hmutex = CreateMutexW(std::ptr::null(), 1, mutex_name.as_ptr()); + if GetLastError() == ERROR_ALREADY_EXISTS { + let hwnd = FindWindowW(class_name.as_ptr(), window_name.as_ptr()); + if !hwnd.is_null() { + let bytes = payload.as_bytes(); + let cds = COPYDATASTRUCT { + dwData: WMCOPYDATA_SINGLE_INSTANCE_DATA, + cbData: bytes.len() as _, + lpData: bytes.as_ptr() as _, + }; + SendMessageW(hwnd, WM_COPYDATA, 0, &cds as *const _ as LPARAM); + // Upstream exits right here, which closes the handle. + CloseHandle(hmutex); + let _ = answer.send(ThreadAnswer::Forwarded); + return; + } + let _ = answer.send(ThreadAnswer::Alone(hmutex as isize)); + return; + } + + let userdata = Box::into_raw(Box::new(UserData { + notify, + mutex: hmutex, + })); + let hwnd = create_event_target_window(&class_name, &window_name, userdata); + if hwnd.is_null() { + drop(Box::from_raw(userdata)); + ReleaseMutex(hmutex); + CloseHandle(hmutex); + let _ = answer.send(ThreadAnswer::Alone(0)); + return; + } + let _ = answer.send(ThreadAnswer::Primary(hwnd as isize)); + + let mut msg: MSG = std::mem::zeroed(); + while GetMessageW(&mut msg, std::ptr::null_mut(), 0, 0) > 0 { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } +} + +/// windows.rs lines 119-130 (`destroy`), on the thread that owns the mutex. +unsafe fn destroy(hwnd: HWND) { + let userdata = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *mut UserData; + if !userdata.is_null() && !(*userdata).mutex.is_null() { + ReleaseMutex((*userdata).mutex); + CloseHandle((*userdata).mutex); + (*userdata).mutex = std::ptr::null_mut(); + } + DestroyWindow(hwnd); +} + +/// windows.rs lines 132-167, plus the destroy request and the end of the +/// loop this thread runs. +unsafe extern "system" fn single_instance_window_proc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + match msg { + WM_CREATE => { + let create_struct = &*(lparam as *const CREATESTRUCTW); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, create_struct.lpCreateParams as isize); + 0 + } + WM_COPYDATA => { + let cds = &*(lparam as *const COPYDATASTRUCT); + if cds.dwData == WMCOPYDATA_SINGLE_INSTANCE_DATA { + let userdata = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *const UserData; + if !userdata.is_null() && !cds.lpData.is_null() { + let data = + std::slice::from_raw_parts(cds.lpData as *const u8, cds.cbData as usize); + let (cwd, args) = parse_windows_payload(data); + (*userdata).notify.deliver(args, cwd); + } + } + 1 + } + WM_SINGLE_INSTANCE_DESTROY => { + destroy(hwnd); + 0 + } + WM_DESTROY => { + let userdata = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *mut UserData; + if !userdata.is_null() { + SetWindowLongPtrW(hwnd, GWLP_USERDATA, 0); + let userdata = Box::from_raw(userdata); + // A window closed some other way still frees the mutex. + if !userdata.mutex.is_null() { + ReleaseMutex(userdata.mutex); + CloseHandle(userdata.mutex); + } + } + PostQuitMessage(0); + 0 + } + _ => DefWindowProcW(hwnd, msg, wparam, lparam), + } +} + +/// windows.rs lines 169-208: a 0x0 layered, transparent, non-activating +/// tool window, restyled to `WS_VISIBLE | WS_POPUP` like upstream. +unsafe fn create_event_target_window( + class_name: &[u16], + window_name: &[u16], + userdata: *mut UserData, +) -> HWND { + let class = WNDCLASSEXW { + cbSize: std::mem::size_of::() as u32, + style: 0, + lpfnWndProc: Some(single_instance_window_proc), + cbClsExtra: 0, + cbWndExtra: 0, + hInstance: GetModuleHandleW(std::ptr::null()), + hIcon: std::ptr::null_mut(), + hCursor: std::ptr::null_mut(), + hbrBackground: std::ptr::null_mut(), + lpszMenuName: std::ptr::null(), + lpszClassName: class_name.as_ptr(), + hIconSm: std::ptr::null_mut(), + }; + // Upstream ignores the result too: a class left registered by an + // earlier instance in the same process is reused. + RegisterClassExW(&class); + let hwnd = CreateWindowExW( + WS_EX_NOACTIVATE | WS_EX_TRANSPARENT | WS_EX_LAYERED | WS_EX_TOOLWINDOW, + class_name.as_ptr(), + window_name.as_ptr(), + WS_OVERLAPPED, + 0, + 0, + 0, + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + GetModuleHandleW(std::ptr::null()), + userdata as *const _, + ); + if !hwnd.is_null() { + SetWindowLongPtrW(hwnd, GWL_STYLE, (WS_VISIBLE | WS_POPUP) as isize); + } + hwnd +} diff --git a/crates/accshift-app/src/lifecycle/ui_scale.rs b/crates/accshift-app/src/lifecycle/ui_scale.rs new file mode 100644 index 0000000..40e2e96 --- /dev/null +++ b/crates/accshift-app/src/lifecycle/ui_scale.rs @@ -0,0 +1,136 @@ +//! `src/lib/app/useUiScale.svelte.ts` and the decisions of +//! `src/lib/app/useThemeApplication.svelte.ts` that are not drawing. + +use crate::platforms::RuntimeOs; +use std::time::{Duration, Instant}; + +pub const UI_SCALE_STEP_PERCENT: i64 = 5; +pub const UI_SCALE_MIN_PERCENT: i64 = 75; +pub const UI_SCALE_MAX_PERCENT: i64 = 150; +pub const WHEEL_ZOOM_THRESHOLD: f64 = 80.0; +/// The persist debounce of `queuePersistUiScalePercent`. +pub const PERSIST_DELAY: Duration = Duration::from_millis(180); + +/// `clampUiScalePercent`: round to the 5 % step (JS `Math.round`, halves +/// up), then clamp to 75..150. +pub fn clamp_ui_scale_percent(value: f64) -> i64 { + let step = UI_SCALE_STEP_PERCENT as f64; + let rounded = ((value / step) + 0.5).floor() * step; + (rounded as i64).clamp(UI_SCALE_MIN_PERCENT, UI_SCALE_MAX_PERCENT) +} + +/// The wheel's `deltaMode`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WheelDeltaMode { + Pixel, + Line, + Page, +} + +/// `createUiScale`: the zoom shortcuts and the debounced save. +#[derive(Debug, Default)] +pub struct UiScale { + wheel_accumulator: f64, + persist_at: Option, + persist_value: i64, +} + +impl UiScale { + pub fn new() -> Self { + Self::default() + } + + /// `setUiScalePercent`: the new live value, or `None` when unchanged. + /// A changed value schedules its save [`PERSIST_DELAY`] later. + pub fn set(&mut self, current: i64, value: f64, now: Instant) -> Option { + let next = clamp_ui_scale_percent(value); + if next == current { + return None; + } + self.persist_at = Some(now + PERSIST_DELAY); + self.persist_value = next; + Some(next) + } + + /// `handleCtrlWheelZoom`. `page_height` is `window.innerHeight`. + pub fn wheel( + &mut self, + ctrl: bool, + delta_y: f64, + mode: WheelDeltaMode, + page_height: f64, + current: i64, + now: Instant, + ) -> Option { + if !ctrl { + self.wheel_accumulator = 0.0; + return None; + } + let unit = match mode { + WheelDeltaMode::Line => 16.0, + WheelDeltaMode::Page => page_height, + WheelDeltaMode::Pixel => 1.0, + }; + self.wheel_accumulator += delta_y * unit; + if self.wheel_accumulator.abs() < WHEEL_ZOOM_THRESHOLD { + return None; + } + let direction = if self.wheel_accumulator < 0.0 { 1 } else { -1 }; + self.wheel_accumulator = 0.0; + self.set( + current, + (current + direction * UI_SCALE_STEP_PERCENT) as f64, + now, + ) + } + + pub fn zoom_in(&mut self, current: i64, now: Instant) -> Option { + self.wheel_accumulator = 0.0; + self.set(current, (current + UI_SCALE_STEP_PERCENT) as f64, now) + } + + pub fn zoom_out(&mut self, current: i64, now: Instant) -> Option { + self.wheel_accumulator = 0.0; + self.set(current, (current - UI_SCALE_STEP_PERCENT) as f64, now) + } + + pub fn reset(&mut self, current: i64, now: Instant) -> Option { + self.wheel_accumulator = 0.0; + self.set(current, 100.0, now) + } + + /// When the debounced save is due. + pub fn next_persist(&self) -> Option { + self.persist_at + } + + /// The value to save now (`persistUiScalePercent`: save only when the + /// stored one differs), once the debounce elapsed. + pub fn take_due(&mut self, now: Instant) -> Option { + match self.persist_at { + Some(at) if now >= at => { + self.persist_at = None; + Some(clamp_ui_scale_percent(self.persist_value as f64)) + } + _ => None, + } + } + + /// `destroy`: a pending save is flushed with the live value, not dropped. + pub fn destroy(&mut self, current: i64) -> Option { + self.persist_at + .take() + .map(|_| clamp_ui_scale_percent(current as f64)) + } +} + +/// `liquidBackdropActive`: a Liquid Glass theme on Windows. +pub fn liquid_backdrop_active(theme_uses_liquid_glass: bool, os: RuntimeOs) -> bool { + theme_uses_liquid_glass && os == RuntimeOs::Windows +} + +/// `backdropAvailable`: never on Linux; with Liquid Glass, only once a +/// wallpaper snapshot exists. +pub fn backdrop_available(os: RuntimeOs, liquid_active: bool, wallpaper_present: bool) -> bool { + os != RuntimeOs::Linux && (!liquid_active || wallpaper_present) +} diff --git a/crates/accshift-app/src/lifecycle/update_flow.rs b/crates/accshift-app/src/lifecycle/update_flow.rs new file mode 100644 index 0000000..e8f2671 --- /dev/null +++ b/crates/accshift-app/src/lifecycle/update_flow.rs @@ -0,0 +1,412 @@ +//! `src/lib/app/useAppUpdater.svelte.ts` as a state machine: the background +//! check, download and "restart to update" button. +//! +//! No async: each blocking half (check, download, install, relaunch) is an +//! [`UpdateCommand`] the shell runs on a [`crate::jobs::Jobs`] thread +//! ([`run_command`] does it with the real updater), then hands the result +//! back to the matching `on_*` method. Every method answers the outputs in +//! the order the TS code produced them: telemetry, toasts, log lines and the +//! next command. + +use super::updater::{InstallerHost, Update, Updater}; +use crate::text::Text; + +/// `UpdateState`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum UpdateState { + #[default] + Idle, + Checking, + Downloading, + Ready, + Applying, +} + +/// `UpdateCheckErrorCode`, plus the three codes of the later stages. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateErrorCode { + TargetMissing, + ManifestInvalid, + CheckFailed, + DownloadFailed, + InstallFailed, + RelaunchFailed, +} + +impl UpdateErrorCode { + pub fn as_str(self) -> &'static str { + match self { + Self::TargetMissing => "update_target_missing", + Self::ManifestInvalid => "update_manifest_invalid", + Self::CheckFailed => "check_failed", + Self::DownloadFailed => "download_failed", + Self::InstallFailed => "install_failed", + Self::RelaunchFailed => "relaunch_failed", + } + } +} + +/// `TARGET_MISSING_RE`. +const TARGET_MISSING: &str = "found in the response `platforms` object"; + +/// `MANIFEST_INVALID_PATTERNS`, all case-insensitive, lowercased here. The +/// last regex, `/while parsing (major|minor|patch) version number/i`, is its +/// three spellings. +const MANIFEST_INVALID: [&str; 10] = [ + "could not fetch a valid release json", + "could not be decoded", + "invalid updater binary format", + "missing field", + "expected value at line", + "invalid type:", + "trailing characters at line", + "while parsing major version number", + "while parsing minor version number", + "while parsing patch version number", +]; + +/// `classifyUpdateCheckError`: sorts a failed check by its `Display` text. +/// `None` is the TS `undefined`. +pub fn classify_update_check_error(error: Option<&str>) -> UpdateErrorCode { + let text = error.unwrap_or("undefined"); + if text.contains(TARGET_MISSING) { + return UpdateErrorCode::TargetMissing; + } + let lower = text.to_lowercase(); + if MANIFEST_INVALID + .iter() + .any(|pattern| lower.contains(pattern)) + { + return UpdateErrorCode::ManifestInvalid; + } + UpdateErrorCode::CheckFailed +} + +/// `trackUpdate` stages. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateStage { + Available, + Downloaded, + Applied, + Failed, +} + +impl UpdateStage { + pub fn as_str(self) -> &'static str { + match self { + Self::Available => "available", + Self::Downloaded => "downloaded", + Self::Applied => "applied", + Self::Failed => "failed", + } + } +} + +/// A pending update as the state machine needs it: its version. +pub trait PendingUpdate: Clone { + fn version(&self) -> String; +} + +impl PendingUpdate for Update { + fn version(&self) -> String { + self.version.clone() + } +} + +/// The blocking work the shell runs next. +#[derive(Debug, Clone, PartialEq)] +pub enum UpdateCommand

{ + /// `check()`; answer with [`AppUpdater::on_check_result`]. + Check, + /// `update.download()`; answer with [`AppUpdater::on_download_result`]. + Download(P), + /// `beforeRelaunch` (flush the stores and settings), then + /// `update.install()`; answer with [`AppUpdater::on_install_result`]. + /// On Windows a real install exits the process here. + Install { update: P, bytes: Vec }, + /// `relaunch()`; answer only on failure, with + /// [`AppUpdater::on_relaunch_failed`]. + Relaunch, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogLevel { + Info, + Error, +} + +/// What the flow asks of the shell, in order. +#[derive(Debug, Clone, PartialEq)] +pub enum UpdateOutput

{ + /// `trackUpdate(stage, targetVersion, errorCode)`; `None` is `undefined`. + Track { + stage: UpdateStage, + target_version: Option, + error_code: Option, + }, + Toast(Text), + /// `console.info` or `console.error` with the error text. + Log { + level: LogLevel, + message: String, + }, + Run(UpdateCommand

), +} + +/// `createAppUpdater`. +#[derive(Debug, Clone)] +pub struct AppUpdater

{ + state: UpdateState, + version: String, + pending: Option

, + bytes: Option>, + check_started: bool, + /// `import.meta.env.DEV`: the flow never runs in a dev build. + dev: bool, +} + +impl AppUpdater

{ + /// `dev`: true skips every check, as `import.meta.env.DEV` did. The + /// egui shell passes `cfg!(debug_assertions)`. + pub fn new(dev: bool) -> Self { + Self { + state: UpdateState::Idle, + version: String::new(), + pending: None, + bytes: None, + check_started: false, + dev, + } + } + + pub fn state(&self) -> UpdateState { + self.state + } + + pub fn version(&self) -> &str { + &self.version + } + + /// `ctaLabel`: the titlebar button text, only while ready or applying. + pub fn cta_label(&self) -> Option { + match self.state { + UpdateState::Ready => Some(Text::key("update.ctaAvailable")), + UpdateState::Applying => Some(Text::key("update.ctaInstalling")), + _ => None, + } + } + + /// `ctaTitle`. + pub fn cta_title(&self) -> Text { + if self.version.is_empty() { + Text::key("update.restartToApply") + } else { + Text::with( + "update.restartToApplyVersion", + [("version", self.version.clone())], + ) + } + } + + /// `ctaDisabled`. + pub fn cta_disabled(&self) -> bool { + self.state == UpdateState::Applying + } + + /// `startBackgroundUpdateFlow`, up to the `check()` call. App.svelte + /// calls it 3500 ms after mount ([`super::UPDATE_CHECK_DELAY`]). + pub fn start(&mut self) -> Vec> { + if self.dev || self.check_started { + return Vec::new(); + } + self.check_started = true; + self.state = UpdateState::Checking; + vec![UpdateOutput::Run(UpdateCommand::Check)] + } + + /// The `check()` result, the error as its `Display` text. + pub fn on_check_result(&mut self, result: Result, String>) -> Vec> { + match result { + Ok(None) => { + self.state = UpdateState::Idle; + Vec::new() + } + Ok(Some(update)) => { + self.version = update.version(); + self.pending = Some(update.clone()); + self.state = UpdateState::Downloading; + vec![ + UpdateOutput::Track { + stage: UpdateStage::Available, + target_version: Some(self.version.clone()), + error_code: None, + }, + UpdateOutput::Run(UpdateCommand::Download(update)), + ] + } + Err(error) => { + let code = classify_update_check_error(Some(&error)); + self.flow_failed(code, error) + } + } + } + + /// The `update.download()` result. + pub fn on_download_result(&mut self, result: Result, String>) -> Vec> { + match result { + Ok(bytes) => { + self.bytes = Some(bytes); + self.state = UpdateState::Ready; + let toast = if self.version.is_empty() { + Text::key("update.readyToast") + } else { + Text::with( + "update.readyToastVersion", + [("version", self.version.clone())], + ) + }; + vec![ + UpdateOutput::Track { + stage: UpdateStage::Downloaded, + target_version: Some(self.version.clone()), + error_code: None, + }, + UpdateOutput::Toast(toast), + ] + } + Err(error) => self.flow_failed(UpdateErrorCode::DownloadFailed, error), + } + } + + /// The catch of `startBackgroundUpdateFlow`. + fn flow_failed(&mut self, code: UpdateErrorCode, error: String) -> Vec> { + let log = if code == UpdateErrorCode::TargetMissing { + UpdateOutput::Log { + level: LogLevel::Info, + message: format!("Updater: this platform is not in the release manifest: {error}"), + } + } else { + UpdateOutput::Log { + level: LogLevel::Error, + message: format!("Updater check/download failed: {error}"), + } + }; + let track = UpdateOutput::Track { + stage: UpdateStage::Failed, + target_version: self.version_or_undefined(), + error_code: Some(code), + }; + self.reset(); + vec![log, track] + } + + fn version_or_undefined(&self) -> Option { + (!self.version.is_empty()).then(|| self.version.clone()) + } + + fn reset(&mut self) { + self.pending = None; + self.bytes = None; + self.version.clear(); + self.state = UpdateState::Idle; + self.check_started = false; + } + + /// `applyReadyUpdate`, up to `install()`: only from `Ready`. + pub fn apply(&mut self) -> Vec> { + if self.state != UpdateState::Ready { + return Vec::new(); + } + let Some(update) = self.pending.clone() else { + return Vec::new(); + }; + self.state = UpdateState::Applying; + let bytes = self.bytes.clone().unwrap_or_default(); + vec![UpdateOutput::Run(UpdateCommand::Install { update, bytes })] + } + + /// The `beforeRelaunch` plus `install()` result. Success tracks + /// `applied` before the relaunch, as TS: there is no "after". + pub fn on_install_result(&mut self, result: Result<(), String>) -> Vec> { + match result { + Ok(()) => vec![ + UpdateOutput::Track { + stage: UpdateStage::Applied, + target_version: Some(self.version.clone()), + error_code: None, + }, + UpdateOutput::Run(UpdateCommand::Relaunch), + ], + Err(error) => self.apply_failed(UpdateErrorCode::InstallFailed, error), + } + } + + /// A `relaunch()` that came back with an error. + pub fn on_relaunch_failed(&mut self, error: String) -> Vec> { + self.apply_failed(UpdateErrorCode::RelaunchFailed, error) + } + + /// The catch of `applyReadyUpdate`: track, reset, toast, check again. + fn apply_failed(&mut self, code: UpdateErrorCode, error: String) -> Vec> { + let mut outputs = vec![ + UpdateOutput::Log { + level: LogLevel::Error, + message: format!("Failed to restart for update: {error}"), + }, + UpdateOutput::Track { + stage: UpdateStage::Failed, + target_version: self.version_or_undefined(), + error_code: Some(code), + }, + ]; + self.reset(); + outputs.push(UpdateOutput::Toast(Text::key("update.restartFailed"))); + outputs.extend(self.start()); + outputs + } +} + +/// What [`run_command`] hands back, for the matching `on_*` method. +#[derive(Debug)] +pub enum CommandResult { + /// Boxed: an [`Update`] is far larger than the other variants. + Checked(Box, String>>), + Downloaded(Result, String>), + Installed(Result<(), String>), + /// Only seen when the relaunch failed: a successful one never returns. + RelaunchFailed(String), +} + +/// The side effects around an install: the flush before it and the +/// relaunch after it. +pub trait ApplyHost: InstallerHost { + /// `beforeRelaunch` (App.svelte `flushAppState`: the settings flush, + /// then the pending store saves; the first error wins). + fn before_relaunch(&self) -> Result<(), String>; + /// `relaunch()`; the real one does not return. + fn relaunch(&self) -> Result<(), String>; +} + +/// Runs one command with the real updater. Blocking: call it on a job +/// thread. +pub fn run_command( + updater: &Updater, + command: UpdateCommand, + host: &dyn ApplyHost, +) -> Option { + match command { + UpdateCommand::Check => Some(CommandResult::Checked(Box::new( + updater.check().map_err(|e| e.to_string()), + ))), + UpdateCommand::Download(update) => Some(CommandResult::Downloaded( + update.download(|_, _| {}, || {}).map_err(|e| e.to_string()), + )), + UpdateCommand::Install { update, bytes } => { + let result = host + .before_relaunch() + .and_then(|()| update.install(&bytes, host).map_err(|e| e.to_string())); + Some(CommandResult::Installed(result)) + } + UpdateCommand::Relaunch => host.relaunch().err().map(CommandResult::RelaunchFailed), + } +} diff --git a/crates/accshift-app/src/lifecycle/updater.rs b/crates/accshift-app/src/lifecycle/updater.rs new file mode 100644 index 0000000..adfced9 --- /dev/null +++ b/crates/accshift-app/src/lifecycle/updater.rs @@ -0,0 +1,1185 @@ +//! The port of `tauri-plugin-updater` 2.11.0, the version the Tauri app +//! builds (Cargo.lock), with the settings of `src-tauri/tauri.conf.json`. +//! +//! Same contract on the wire and on disk: the same `latest.json` shapes +//! (`platforms` map or a single `url` and `signature`), the same target keys +//! (`windows-x86_64-nsis` then `windows-x86_64`), the same minisign check +//! with the same public key, the same installer launch (`ShellExecuteW` on +//! the NSIS setup with `/P /UPDATE /R /ARGS ...`, then exit). Source lines +//! cited below are in the plugin's `src/updater.rs` unless named otherwise. +//! +//! Blocking: [`Updater::check`] and [`Update::download`] use +//! `reqwest::blocking`, so call them from a [`crate::jobs::Jobs`] thread, +//! never from the UI thread and never inside a tokio runtime. +//! [`Update::install`] reaches the OS through [`InstallerHost`]: tests pass +//! a fake and no installer or exit ever runs. + +pub mod error; +#[cfg(any(target_os = "linux", target_os = "macos"))] +mod unix; + +pub use error::{UpdaterError, UpdaterResult}; + +use base64::Engine; +use percent_encoding::{AsciiSet, CONTROLS}; +use semver::Version; +use serde::de::Error as DeError; +use serde::{Deserialize, Deserializer, Serialize}; +use std::collections::HashMap; +use std::ffi::{OsStr, OsString}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use time::OffsetDateTime; +use url::Url; + +/// The plugin version this port follows (Cargo.lock of the Tauri app). +pub const PLUGIN_VERSION: &str = "2.11.0"; +/// updater.rs line 44: `tauri-plugin-updater/{CARGO_PKG_VERSION}`. The +/// release host sees the same client. +pub const UPDATER_USER_AGENT: &str = "tauri-plugin-updater/2.11.0"; +/// `plugins.updater.pubkey` of tauri.conf.json: the base64 of the minisign +/// public key file. +pub const PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDY5QjgxOUY2RjJFMjU5NTMKUldSVFdlTHk5aG00YVVla0RIQ25oZGlMc2tWRVVMTUQrSG96bUtiQ2ZLNFlSRkpmS1pGcVlKSzcK"; +/// `plugins.updater.endpoints` of tauri.conf.json. +pub const ENDPOINTS: &[&str] = + &["https://github.com/klNuno/accshift/releases/latest/download/latest.json"]; +/// `version` of tauri.conf.json, the `current_version` the check compares +/// against (Tauri reads it from the config, not from Cargo). +pub const APP_VERSION: &str = "1.0.5"; +/// `productName`: `package_info().name`, the prefix of the installer temp +/// folder. tauri.linux.conf.json overrides it. +#[cfg(target_os = "linux")] +pub const PRODUCT_NAME: &str = "accshift"; +#[cfg(not(target_os = "linux"))] +pub const PRODUCT_NAME: &str = "Accshift"; + +/// config.rs lines 11-25: `plugins.updater.windows.installMode`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum WindowsUpdateInstallMode { + BasicUi, + Quiet, + #[default] + Passive, +} + +impl WindowsUpdateInstallMode { + /// config.rs lines 27-33. + pub fn msiexec_args(self) -> &'static [&'static str] { + match self { + Self::BasicUi => &["/qb+"], + Self::Quiet => &["/quiet"], + Self::Passive => &["/passive"], + } + } + + /// config.rs lines 35-38. + pub fn msi_restart_after_install_args(self) -> &'static [&'static str] { + &["AUTOLAUNCHAPP=True"] + } + + /// config.rs lines 41-50. + pub fn nsis_args(self) -> &'static [&'static str] { + match self { + Self::Passive => &["/P"], + Self::Quiet => &["/S"], + Self::BasicUi => &[], + } + } + + /// config.rs lines 52-58. + pub fn nsis_restart_after_install_args(self) -> &'static [&'static str] { + match self { + Self::BasicUi => &[], + _ => &["/R"], + } + } +} + +/// updater.rs lines 47-69. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Installer { + AppImage, + Deb, + Rpm, + App, + Msi, + Nsis, +} + +impl Installer { + pub fn name(self) -> &'static str { + match self { + Self::AppImage => "appimage", + Self::Deb => "deb", + Self::Rpm => "rpm", + Self::App => "app", + Self::Msi => "msi", + Self::Nsis => "nsis", + } + } +} + +/// tauri-utils 2.9.3 `platform::BundleType`, the kinds the bundler patches +/// into the binary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BundleType { + Deb, + Rpm, + AppImage, + Msi, + Nsis, + App, +} + +/// tauri-utils 2.9.3 `src/platform.rs` line 349. The Tauri bundler finds +/// this exact byte string in the executable and overwrites its last three +/// letters with the bundle kind (`NSS`, `MSI`, `DEB`, `RPM`, `APP`), so the +/// egui binary bundled by the same tool learns it was installed by NSIS and +/// asks for `windows-x86_64-nsis` first, like the Tauri one. `static mut` +/// for the same reason as upstream: an immutable static is folded away. +#[used] +static mut TAURI_BUNDLE_TYPE: &str = "__TAURI_BUNDLE_TYPE_VAR_UNK"; + +/// tauri-utils 2.9.3 `platform::bundle_type`, lines 353-370. +pub fn bundle_type() -> Option { + // SAFETY: read only; the value is only ever changed in the file on disk. + let marker: &str = unsafe { std::ptr::read_volatile(&raw const TAURI_BUNDLE_TYPE) }; + bundle_type_from_marker(marker) +} + +/// The match of [`bundle_type`], on any marker (tests). +pub fn bundle_type_from_marker(marker: &str) -> Option { + match marker { + "__TAURI_BUNDLE_TYPE_VAR_DEB" => Some(BundleType::Deb), + "__TAURI_BUNDLE_TYPE_VAR_RPM" => Some(BundleType::Rpm), + "__TAURI_BUNDLE_TYPE_VAR_APP" => Some(BundleType::AppImage), + "__TAURI_BUNDLE_TYPE_VAR_MSI" => Some(BundleType::Msi), + "__TAURI_BUNDLE_TYPE_VAR_NSS" => Some(BundleType::Nsis), + _ => { + if cfg!(target_os = "macos") { + Some(BundleType::App) + } else { + None + } + } + } +} + +/// updater.rs lines 1502-1512. +pub fn installer_for_bundle_type(bundle: Option) -> Option { + Some(match bundle? { + BundleType::Deb => Installer::Deb, + BundleType::Rpm => Installer::Rpm, + BundleType::AppImage => Installer::AppImage, + BundleType::Msi => Installer::Msi, + BundleType::Nsis => Installer::Nsis, + BundleType::App => Installer::App, + }) +} + +/// updater.rs lines 1395-1406 (`darwin`, not `macos`, as upstream). +pub fn updater_os() -> Option<&'static str> { + if cfg!(target_os = "linux") { + Some("linux") + } else if cfg!(target_os = "macos") { + Some("darwin") + } else if cfg!(target_os = "windows") { + Some("windows") + } else { + None + } +} + +/// updater.rs lines 1408-1422. +pub fn updater_arch() -> Option<&'static str> { + if cfg!(target_arch = "x86") { + Some("i686") + } else if cfg!(target_arch = "x86_64") { + Some("x86_64") + } else if cfg!(target_arch = "arm") { + Some("armv7") + } else if cfg!(target_arch = "aarch64") { + Some("aarch64") + } else if cfg!(target_arch = "riscv64") { + Some("riscv64") + } else { + None + } +} + +/// updater.rs lines 1387-1393: `{os}-{arch}`. +pub fn target() -> Option { + Some(format!("{}-{}", updater_os()?, updater_arch()?)) +} + +/// updater.rs lines 71-77. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct ReleaseManifestPlatform { + pub url: Url, + pub signature: String, +} + +/// updater.rs lines 79-86. +#[derive(Debug, Clone, PartialEq)] +pub enum RemoteReleaseInner { + Dynamic(ReleaseManifestPlatform), + Static { + platforms: HashMap, + }, +} + +/// updater.rs lines 88-127: a parsed `latest.json`. +#[derive(Debug, Clone, PartialEq)] +pub struct RemoteRelease { + pub version: Version, + pub notes: Option, + pub pub_date: Option, + pub data: RemoteReleaseInner, +} + +impl RemoteRelease { + /// updater.rs lines 105-114. + pub fn download_url(&self, target: &str) -> UpdaterResult<&Url> { + match &self.data { + RemoteReleaseInner::Dynamic(platform) => Ok(&platform.url), + RemoteReleaseInner::Static { platforms } => platforms + .get(target) + .map(|platform| &platform.url) + .ok_or_else(|| UpdaterError::TargetNotFound(target.to_string())), + } + } + + /// updater.rs lines 117-126. + pub fn signature(&self, target: &str) -> UpdaterResult<&String> { + match &self.data { + RemoteReleaseInner::Dynamic(platform) => Ok(&platform.signature), + RemoteReleaseInner::Static { platforms } => platforms + .get(target) + .map(|platform| &platform.signature) + .ok_or_else(|| UpdaterError::TargetNotFound(target.to_string())), + } + } + + /// The `platforms` keys, sorted (empty for the dynamic shape). + pub fn platform_keys(&self) -> Vec { + match &self.data { + RemoteReleaseInner::Dynamic(_) => Vec::new(), + RemoteReleaseInner::Static { platforms } => { + let mut keys: Vec = platforms.keys().cloned().collect(); + keys.sort(); + keys + } + } + } +} + +/// updater.rs lines 1454-1500, with the same error messages. +impl<'de> Deserialize<'de> for RemoteRelease { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct InnerRemoteRelease { + #[serde(alias = "name", deserialize_with = "parse_version")] + version: Version, + notes: Option, + pub_date: Option, + platforms: Option>, + url: Option, + signature: Option, + } + + let release = InnerRemoteRelease::deserialize(deserializer)?; + let pub_date = match release.pub_date { + Some(date) => Some( + OffsetDateTime::parse(&date, &time::format_description::well_known::Rfc3339) + .map_err(|e| DeError::custom(format!("invalid value for `pub_date`: {e}")))?, + ), + None => None, + }; + Ok(RemoteRelease { + version: release.version, + notes: release.notes, + pub_date, + data: match release.platforms { + Some(platforms) => RemoteReleaseInner::Static { platforms }, + None => RemoteReleaseInner::Dynamic(ReleaseManifestPlatform { + url: release.url.ok_or_else(|| { + DeError::custom("the `url` field was not set on the updater response") + })?, + signature: release.signature.ok_or_else(|| { + DeError::custom("the `signature` field was not set on the updater response") + })?, + }), + }, + }) + } +} + +/// updater.rs lines 1514-1521: a leading `v` is dropped. +fn parse_version<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let text = String::deserialize(deserializer)?; + Version::parse(text.trim_start_matches('v')).map_err(DeError::custom) +} + +/// Parses a manifest body the way the check does (`serde_json::from_value`). +pub fn parse_manifest(json: serde_json::Value) -> UpdaterResult { + serde_json::from_value(json).map_err(|e| UpdaterError::Serialization(e.to_string())) +} + +/// updater.rs lines 1524-1534: base64 of the key file, base64 of the `.sig` +/// file, legacy (non-prehashed) signatures accepted. +pub fn verify_signature(data: &[u8], release_signature: &str, pub_key: &str) -> UpdaterResult<()> { + let pub_key_decoded = base64_to_string(pub_key)?; + let public_key = minisign_verify::PublicKey::decode(&pub_key_decoded) + .map_err(|e| UpdaterError::Minisign(e.to_string()))?; + let signature_decoded = base64_to_string(release_signature)?; + let signature = minisign_verify::Signature::decode(&signature_decoded) + .map_err(|e| UpdaterError::Minisign(e.to_string()))?; + public_key + .verify(data, &signature, true) + .map_err(|e| UpdaterError::Minisign(e.to_string())) +} + +/// updater.rs lines 1536-1543. +fn base64_to_string(base64_string: &str) -> UpdaterResult { + let decoded = base64::engine::general_purpose::STANDARD + .decode(base64_string) + .map_err(|e| UpdaterError::Base64(e.to_string()))?; + String::from_utf8(decoded).map_err(|_| UpdaterError::SignatureUtf8(base64_string.into())) +} + +/// What `UpdaterBuilder` collects (updater.rs lines 150-405) and the plugin +/// fills from its config and the app (lib.rs lines 79-123). +#[derive(Clone)] +pub struct UpdaterConfig { + pub pubkey: String, + pub endpoints: Vec, + pub install_mode: WindowsUpdateInstallMode, + /// `plugins.updater.windows.installerArgs`; none in tauri.conf.json. + pub installer_args: Vec, + pub current_version: Version, + pub app_name: String, + /// `UpdaterBuilder::target`, unset by the app. + pub target: Option, + /// `None`: no timeout, as the async client the plugin uses. + pub timeout: Option, + pub headers: Vec<(String, String)>, + /// `env().args_os`, the whole command line including the executable. + pub current_exe_args: Vec, + pub restart_after_install: bool, + /// `bundle_type()`, read once. + pub bundle_type: Option, + /// The current executable (`tauri_utils::platform::current_exe`), or the + /// `APPIMAGE` file on Linux. + pub executable_path: Option, + /// `version_comparator`: `None` is `release > current`. The JS `check` + /// with `allowDowngrades` passes `release != current`. + pub allow_downgrades: bool, +} + +impl UpdaterConfig { + /// The app's settings: tauri.conf.json, this process's arguments and + /// bundle marker. + pub fn accshift() -> Self { + Self { + pubkey: PUBKEY.to_string(), + endpoints: ENDPOINTS + .iter() + .map(|endpoint| Url::parse(endpoint).expect("tauri.conf.json endpoint parses")) + .collect(), + install_mode: WindowsUpdateInstallMode::Passive, + installer_args: Vec::new(), + current_version: Version::parse(APP_VERSION).expect("APP_VERSION is semver"), + app_name: PRODUCT_NAME.to_string(), + target: None, + timeout: None, + headers: Vec::new(), + current_exe_args: std::env::args_os().collect(), + restart_after_install: true, + bundle_type: bundle_type(), + executable_path: default_executable_path(), + allow_downgrades: false, + } + } + + /// config.rs `validate_endpoints`: anything but https is refused in a + /// release build and only warned about in a debug one. + pub fn validate(&self) -> UpdaterResult<()> { + if self.endpoints.is_empty() { + return Err(UpdaterError::EmptyEndpoints); + } + for endpoint in &self.endpoints { + if endpoint.scheme() != "https" && !cfg!(debug_assertions) { + return Err(UpdaterError::InsecureTransportProtocol); + } + } + Ok(()) + } +} + +/// lib.rs lines 105-113 and updater.rs lines 376-383: the AppImage on Linux +/// when `APPIMAGE` is set, else the canonical current executable. +fn default_executable_path() -> Option { + #[cfg(target_os = "linux")] + if let Some(appimage) = std::env::var_os("APPIMAGE") { + return Some(PathBuf::from(appimage)); + } + std::env::current_exe().and_then(std::fs::canonicalize).ok() +} + +/// updater.rs lines 1424-1452: the folder the update replaces. The +/// executable's folder, or the `.app` on macOS; Linux uses the file itself. +pub fn extract_path_from_executable(executable_path: &Path) -> UpdaterResult { + let extract_path = executable_path + .parent() + .map(PathBuf::from) + .ok_or(UpdaterError::FailedToDetermineExtractPath)?; + #[cfg(target_os = "macos")] + if extract_path + .display() + .to_string() + .contains("Contents/MacOS") + { + return extract_path + .parent() + .and_then(Path::parent) + .map(PathBuf::from) + .ok_or(UpdaterError::FailedToDetermineExtractPath); + } + Ok(extract_path) +} + +/// `CONTROLS` plus `+`, updater.rs line 468. +const VERSION_ENCODE_SET: &AsciiSet = &CONTROLS.add(b'+'); + +/// One configured updater. +pub struct Updater { + config: UpdaterConfig, + arch: &'static str, +} + +impl Updater { + /// `UpdaterBuilder::build` (updater.rs lines 365-405). + pub fn new(config: UpdaterConfig) -> UpdaterResult { + config.validate()?; + let arch = updater_arch().ok_or(UpdaterError::UnsupportedArch)?; + Ok(Self { config, arch }) + } + + pub fn config(&self) -> &UpdaterConfig { + &self.config + } + + /// updater.rs lines 459-488: `{{current_version}}`, `{{target}}`, + /// `{{arch}}` and `{{bundle_type}}`, raw and percent-encoded. + pub fn endpoint_url(&self, endpoint: &Url, target: &str) -> UpdaterResult { + let version = self.config.current_version.to_string(); + let encoded_version = + percent_encoding::percent_encode(version.as_bytes(), VERSION_ENCODE_SET).to_string(); + let installer = installer_for_bundle_type(self.config.bundle_type) + .map(Installer::name) + .unwrap_or("unknown"); + endpoint + .to_string() + .replace("%7B%7Bcurrent_version%7D%7D", &encoded_version) + .replace("%7B%7Btarget%7D%7D", target) + .replace("%7B%7Barch%7D%7D", self.arch) + .replace("%7B%7Bbundle_type%7D%7D", installer) + .replace("{{current_version}}", &encoded_version) + .replace("{{target}}", target) + .replace("{{arch}}", self.arch) + .replace("{{bundle_type}}", installer) + .parse() + .map_err(|e: url::ParseError| UpdaterError::UrlParse(e.to_string())) + } + + fn client(&self) -> UpdaterResult { + reqwest::blocking::Client::builder() + .user_agent(UPDATER_USER_AGENT) + .timeout(self.config.timeout) + .build() + .map_err(|e| UpdaterError::Reqwest(e.to_string())) + } + + fn headers(&self, accept: &'static str) -> UpdaterResult { + headers_with_accept(&self.config.headers, accept) + } + + /// `Updater::check`, updater.rs lines 432-606. `Ok(None)`: no update + /// (204, or the release is not newer). + pub fn check(&self) -> UpdaterResult> { + let headers = self.headers("application/json")?; + + #[cfg(target_os = "linux")] + { + if std::env::var_os("SSL_CERT_FILE").is_none() { + std::env::set_var("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt"); + } + if std::env::var_os("SSL_CERT_DIR").is_none() { + std::env::set_var("SSL_CERT_DIR", "/etc/ssl/certs"); + } + } + + let target = match &self.config.target { + Some(target) => target.clone(), + None => updater_os().ok_or(UpdaterError::UnsupportedOs)?.to_string(), + }; + + let mut remote_release: Option = None; + let mut raw_json: Option = None; + let mut last_error: Option = None; + for endpoint in &self.config.endpoints { + let url = self.endpoint_url(endpoint, &target)?; + let response = self.client()?.get(url).headers(headers.clone()).send(); + match response { + Ok(response) => { + if response.status().is_success() { + if response.status() == reqwest::StatusCode::NO_CONTENT { + return Ok(None); + } + let body: serde_json::Value = response + .json() + .map_err(|e| UpdaterError::Reqwest(e.to_string()))?; + raw_json = Some(body.clone()); + match parse_manifest(body) { + Ok(release) => { + last_error = None; + remote_release = Some(release); + break; + } + Err(error) => last_error = Some(error), + } + } + // A non-success status only logs upstream (line 545): + // it is not kept as the last error. + } + Err(error) => last_error = Some(UpdaterError::Reqwest(error.to_string())), + } + } + + if let Some(error) = last_error { + return Err(error); + } + let release = remote_release.ok_or(UpdaterError::ReleaseNotFound)?; + + let should_update = if self.config.allow_downgrades { + release.version != self.config.current_version + } else { + release.version > self.config.current_version + }; + + // Resolved before the version test, as upstream (lines 581-582): a + // manifest without this platform fails even when nothing is newer. + let installer = installer_for_bundle_type(self.config.bundle_type); + let (download_url, signature) = self.get_urls(&release, installer)?; + + if !should_update { + return Ok(None); + } + Ok(Some(Update { + body: release.notes.clone(), + current_version: self.config.current_version.to_string(), + version: release.version.to_string(), + date: release.pub_date, + target, + download_url: download_url.clone(), + signature: signature.clone(), + raw_json: raw_json.unwrap_or_default(), + timeout: None, + headers: self.config.headers.clone(), + install: InstallContext { + app_name: self.config.app_name.clone(), + install_mode: self.config.install_mode, + installer_args: self.config.installer_args.clone(), + current_exe_args: self.config.current_exe_args.clone(), + restart_after_install: self.config.restart_after_install, + bundle_type: self.config.bundle_type, + executable_path: self.config.executable_path.clone(), + pubkey: self.config.pubkey.clone(), + }, + })) + } + + /// The release a check would read, without the version test or the + /// target lookup (acceptance check against the real endpoint). + pub fn fetch_release(&self) -> UpdaterResult> { + let target = updater_os().ok_or(UpdaterError::UnsupportedOs)?; + let mut last_error = None; + for endpoint in &self.config.endpoints { + let url = self.endpoint_url(endpoint, target)?; + match self + .client()? + .get(url) + .headers(self.headers("application/json")?) + .send() + { + Ok(response) if response.status() == reqwest::StatusCode::NO_CONTENT => { + return Ok(None) + } + Ok(response) if response.status().is_success() => { + let body: serde_json::Value = response + .json() + .map_err(|e| UpdaterError::Reqwest(e.to_string()))?; + return parse_manifest(body).map(Some); + } + Ok(_) => {} + Err(error) => last_error = Some(UpdaterError::Reqwest(error.to_string())), + } + } + Err(last_error.unwrap_or(UpdaterError::ReleaseNotFound)) + } + + /// updater.rs lines 608-638. + pub fn get_urls<'a>( + &self, + release: &'a RemoteRelease, + installer: Option, + ) -> UpdaterResult<(&'a Url, &'a String)> { + if let Some(target) = &self.config.target { + return Ok((release.download_url(target)?, release.signature(target)?)); + } + let os = updater_os().ok_or(UpdaterError::UnsupportedOs)?; + let targets = fallback_targets(os, self.arch, installer); + for target in &targets { + if let (Ok(url), Ok(signature)) = + (release.download_url(target), release.signature(target)) + { + return Ok((url, signature)); + } + } + Err(UpdaterError::TargetsNotFound(targets)) + } +} + +/// `[{os}-{arch}-{installer}, {os}-{arch}]`, updater.rs lines 618-626. +pub fn fallback_targets(os: &str, arch: &str, installer: Option) -> Vec { + let mut targets = Vec::new(); + if let Some(installer) = installer { + targets.push(format!("{os}-{arch}-{}", installer.name())); + } + targets.push(format!("{os}-{arch}")); + targets +} + +fn headers_with_accept( + headers: &[(String, String)], + accept: &'static str, +) -> UpdaterResult { + use reqwest::header::{HeaderMap, HeaderName, HeaderValue, ACCEPT}; + let mut map = HeaderMap::new(); + for (name, value) in headers { + let name = HeaderName::from_bytes(name.as_bytes()) + .map_err(|e| UpdaterError::Reqwest(e.to_string()))?; + let value = + HeaderValue::from_str(value).map_err(|e| UpdaterError::Reqwest(e.to_string()))?; + map.insert(name, value); + } + if !map.contains_key(ACCEPT) { + map.insert(ACCEPT, HeaderValue::from_static(accept)); + } + Ok(map) +} + +/// What the install needs from the updater that found the release. +#[derive(Debug, Clone)] +pub struct InstallContext { + pub app_name: String, + pub install_mode: WindowsUpdateInstallMode, + pub installer_args: Vec, + pub current_exe_args: Vec, + pub restart_after_install: bool, + pub bundle_type: Option, + pub executable_path: Option, + pub pubkey: String, +} + +/// The JS `DownloadEvent` (commands.rs lines 121-133): `Started` on the +/// first chunk, then one `Progress` per chunk, then `Finished`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DownloadEvent { + Started { content_length: Option }, + Progress { chunk_length: usize }, + Finished, +} + +/// updater.rs lines 640-668: an available update. +#[derive(Debug, Clone)] +pub struct Update { + pub body: Option, + pub current_version: String, + pub version: String, + pub date: Option, + pub target: String, + pub download_url: Url, + pub signature: String, + pub raw_json: serde_json::Value, + pub timeout: Option, + pub headers: Vec<(String, String)>, + pub install: InstallContext, +} + +/// The side effects of an install: the hook before exit, the installer +/// launch, the exit, the macOS main-thread hop. [`SystemInstaller`] is the +/// real one; tests record calls instead. +pub trait InstallerHost { + /// `on_before_exit`: the plugin runs `app.cleanup_before_exit()` (hides + /// the windows on Windows). + fn before_exit(&self); + /// `ShellExecuteW(null, "open", file, parameters, null, SW_SHOW)`; + /// an `Err` when it answers 32 or less. + fn shell_execute(&self, file: &OsStr, parameters: &OsStr) -> std::io::Result<()>; + /// `std::process::exit(code)`. The real one never returns. + fn exit(&self, code: i32); + /// `run_on_main_thread` (macOS admin prompt). Inline by default. + fn run_on_main_thread(&self, task: Box) -> Result<(), String> { + task(); + Ok(()) + } +} + +/// The real [`InstallerHost`]. +pub struct SystemInstaller { + pub before_exit: Box, +} + +impl InstallerHost for SystemInstaller { + fn before_exit(&self) { + (self.before_exit)(); + } + + #[cfg(windows)] + fn shell_execute(&self, file: &OsStr, parameters: &OsStr) -> std::io::Result<()> { + use windows_sys::Win32::UI::Shell::ShellExecuteW; + use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOW; + let operation = encode_wide("open"); + let file = encode_wide(file); + let parameters = encode_wide(parameters); + // SAFETY: every pointer is a NUL-terminated UTF-16 buffer that + // outlives the call. + let result = unsafe { + ShellExecuteW( + std::ptr::null_mut(), + operation.as_ptr(), + file.as_ptr(), + parameters.as_ptr(), + std::ptr::null(), + SW_SHOW, + ) + }; + if result as isize <= 32 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + } + + #[cfg(not(windows))] + fn shell_execute(&self, _file: &OsStr, _parameters: &OsStr) -> std::io::Result<()> { + Err(std::io::Error::from(std::io::ErrorKind::Unsupported)) + } + + fn exit(&self, code: i32) { + std::process::exit(code); + } +} + +#[cfg(windows)] +fn encode_wide(text: impl AsRef) -> Vec { + use std::os::windows::ffi::OsStrExt; + text.as_ref() + .encode_wide() + .chain(std::iter::once(0)) + .collect() +} + +impl Update { + /// The `pub_date` as RFC 3339, as the JS `Update.date` carries it. + pub fn date_rfc3339(&self) -> Option { + self.date.and_then(|date| { + date.format(&time::format_description::well_known::Rfc3339) + .ok() + }) + } + + /// `Update::download`, updater.rs lines 680-743: the bytes, verified + /// against the release signature. `on_chunk(len, content_length)` per + /// chunk read, `on_download_finish` once the body is read. + pub fn download( + &self, + mut on_chunk: impl FnMut(usize, Option), + on_download_finish: impl FnOnce(), + ) -> UpdaterResult> { + let headers = headers_with_accept(&self.headers, "application/octet-stream")?; + let client = reqwest::blocking::Client::builder() + .user_agent(UPDATER_USER_AGENT) + .timeout(self.timeout) + .build() + .map_err(|e| UpdaterError::Reqwest(e.to_string()))?; + let mut response = client + .get(self.download_url.clone()) + .headers(headers) + .send() + .map_err(|e| UpdaterError::Reqwest(e.to_string()))?; + if !response.status().is_success() { + return Err(UpdaterError::Network(format!( + "Download request failed with status: {}", + response.status() + ))); + } + let content_length: Option = response + .headers() + .get("Content-Length") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse().ok()); + + let mut buffer = Vec::new(); + let mut chunk = vec![0u8; 64 * 1024]; + loop { + let read = response + .read(&mut chunk) + .map_err(|e| UpdaterError::Reqwest(e.to_string()))?; + if read == 0 { + break; + } + on_chunk(read, content_length); + buffer.extend_from_slice(&chunk[..read]); + } + on_download_finish(); + + verify_signature(&buffer, &self.signature, &self.install.pubkey)?; + Ok(buffer) + } + + /// [`Self::download`] reporting [`DownloadEvent`]s, as the JS command. + pub fn download_with_events( + &self, + mut on_event: impl FnMut(DownloadEvent), + ) -> UpdaterResult> { + let mut first_chunk = true; + let events = std::cell::RefCell::new(&mut on_event); + self.download( + |chunk_length, content_length| { + let mut on_event = events.borrow_mut(); + if first_chunk { + first_chunk = false; + on_event(DownloadEvent::Started { content_length }); + } + on_event(DownloadEvent::Progress { chunk_length }); + }, + || (events.borrow_mut())(DownloadEvent::Finished), + ) + } + + /// `Update::install`, updater.rs lines 751-753. Windows: launches the + /// installer then calls `host.exit(0)`. Linux and macOS: replaces the + /// app in place; the caller relaunches. + pub fn install(&self, bytes: &[u8], host: &dyn InstallerHost) -> UpdaterResult<()> { + self.install_inner(bytes, host) + } + + #[cfg(windows)] + fn install_inner(&self, bytes: &[u8], host: &dyn InstallerHost) -> UpdaterResult<()> { + let updater_type = self.extract(bytes)?; + host.before_exit(); + let file = windows_installer_file(&updater_type, std::env::var("SYSTEMROOT").ok()); + let parameters = self.updater_parameters(&updater_type); + host.shell_execute(&file, ¶meters) + .map_err(UpdaterError::Io)?; + host.exit(0); + Ok(()) + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn install_inner(&self, bytes: &[u8], host: &dyn InstallerHost) -> UpdaterResult<()> { + let executable = self + .install + .executable_path + .clone() + .ok_or(UpdaterError::FailedToDetermineExtractPath)?; + // updater.rs lines 379-383: Linux replaces the file itself. + let extract_path = if cfg!(target_os = "linux") { + executable + } else { + extract_path_from_executable(&executable)? + }; + let main_thread = |task: Box| host.run_on_main_thread(task); + unix::install_files( + installer_for_bundle_type(self.install.bundle_type), + &extract_path, + bytes, + &main_thread, + ) + } + + #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] + fn install_inner(&self, _bytes: &[u8], _host: &dyn InstallerHost) -> UpdaterResult<()> { + Err(UpdaterError::UnsupportedOs) + } + + /// The installer command line, updater.rs lines 879-939. + pub fn updater_parameters(&self, updater_type: &WindowsUpdaterType) -> OsString { + let context = &self.install; + let mode = context.install_mode; + let current_args = context.current_exe_args.get(1..).unwrap_or_default(); + let mut args: Vec = Vec::new(); + match updater_type { + WindowsUpdaterType::Nsis { .. } => { + args.extend(mode.nsis_args().iter().map(OsString::from)); + args.push("/UPDATE".into()); + if context.restart_after_install { + args.extend( + mode.nsis_restart_after_install_args() + .iter() + .map(OsString::from), + ); + args.push("/ARGS".into()); + args.extend(current_args.iter().map(escape_nsis_current_exe_arg)); + } + args.extend(context.installer_args.iter().cloned()); + } + WindowsUpdaterType::Msi { path, .. } => { + args.push("/i".into()); + args.push(path.as_os_str().to_os_string()); + args.extend(mode.msiexec_args().iter().map(OsString::from)); + args.push("/promptrestart".into()); + args.extend(context.installer_args.iter().cloned()); + if context.restart_after_install { + args.extend( + mode.msi_restart_after_install_args() + .iter() + .map(OsString::from), + ); + let escaped = current_args + .iter() + .map(escape_msi_property_arg) + .collect::>() + .join(" "); + args.push(format!("LAUNCHAPPARGS=\"{escaped}\"").into()); + } + } + } + join_os(&args, " ") + } + + /// updater.rs lines 947-954: a zip of the installer, else the + /// installer itself. + pub fn extract(&self, bytes: &[u8]) -> UpdaterResult { + #[cfg(windows)] + if infer::archive::is_zip(bytes) { + return self.extract_zip(bytes); + } + self.extract_exe(bytes) + } + + /// updater.rs lines 956-965: kept on purpose (`keep`), the installer + /// runs after this process exits. + fn make_temp_dir(&self) -> UpdaterResult { + Ok(tempfile::Builder::new() + .prefix(&format!( + "{}-{}-updater-", + self.install.app_name, self.version + )) + .tempdir()? + .keep()) + } + + /// updater.rs lines 966-986. + #[cfg(windows)] + fn extract_zip(&self, bytes: &[u8]) -> UpdaterResult { + let temp_dir = self.make_temp_dir()?; + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)) + .map_err(|e| UpdaterError::Extract(e.to_string()))?; + archive + .extract(&temp_dir) + .map_err(|e| UpdaterError::Extract(e.to_string()))?; + for entry in std::fs::read_dir(&temp_dir)? { + let path = entry?.path(); + let ext = path.extension(); + if ext == Some(OsStr::new("exe")) { + return Ok(WindowsUpdaterType::nsis(path, None)); + } else if ext == Some(OsStr::new("msi")) { + return Ok(WindowsUpdaterType::msi(path, None)); + } + } + Err(UpdaterError::BinaryNotFoundInArchive) + } + + /// updater.rs lines 988-998. + fn extract_exe(&self, bytes: &[u8]) -> UpdaterResult { + if infer::app::is_exe(bytes) { + let (path, temp) = self.write_to_temp(bytes, ".exe")?; + Ok(WindowsUpdaterType::nsis(path, temp)) + } else if infer::archive::is_msi(bytes) { + let (path, temp) = self.write_to_temp(bytes, ".msi")?; + Ok(WindowsUpdaterType::msi(path, temp)) + } else { + Err(UpdaterError::InvalidUpdaterFormat) + } + } + + /// updater.rs lines 1000-1021: `{app}-{version}-installer{ext}` in a + /// kept temp folder; the file itself is deleted when the value drops. + fn write_to_temp( + &self, + bytes: &[u8], + ext: &str, + ) -> UpdaterResult<(PathBuf, Option)> { + use std::io::Write; + let temp_dir = self.make_temp_dir()?; + let mut temp_file = tempfile::Builder::new() + .prefix(&format!( + "{}-{}-installer", + self.install.app_name, self.version + )) + .suffix(ext) + .rand_bytes(0) + .tempfile_in(temp_dir)?; + temp_file.write_all(bytes)?; + let temp = temp_file.into_temp_path(); + Ok((temp.to_path_buf(), Some(temp))) + } +} + +/// updater.rs lines 786-810. The MSI path is stored quoted, as upstream. +#[derive(Debug)] +pub enum WindowsUpdaterType { + Nsis { + path: PathBuf, + temp: Option, + }, + Msi { + path: PathBuf, + temp: Option, + }, +} + +impl WindowsUpdaterType { + pub fn nsis(path: PathBuf, temp: Option) -> Self { + Self::Nsis { path, temp } + } + + pub fn msi(path: PathBuf, temp: Option) -> Self { + let mut quoted = OsString::from("\""); + quoted.push(path.as_os_str()); + quoted.push("\""); + Self::Msi { + path: PathBuf::from(quoted), + temp, + } + } + + pub fn path(&self) -> &Path { + match self { + Self::Nsis { path, .. } | Self::Msi { path, .. } => path, + } + } +} + +/// updater.rs lines 848-852: the NSIS setup itself, or +/// `%SYSTEMROOT%\System32\msiexec.exe` (`msiexec.exe` without the variable). +pub fn windows_installer_file( + updater_type: &WindowsUpdaterType, + system_root: Option, +) -> OsString { + match updater_type { + WindowsUpdaterType::Nsis { path, .. } => path.as_os_str().to_os_string(), + WindowsUpdaterType::Msi { .. } => match system_root { + Some(root) => OsString::from(format!("{root}\\System32\\msiexec.exe")), + None => OsString::from("msiexec.exe"), + }, + } +} + +fn join_os(parts: &[OsString], separator: &str) -> OsString { + let mut joined = OsString::new(); + for (index, part) in parts.iter().enumerate() { + if index > 0 { + joined.push(separator); + } + joined.push(part); + } + joined +} + +/// updater.rs lines 1572-1609: std's Windows argument quoting, plus `/` +/// forcing quotes so NSIS does not read an option. Works on UTF-16 units on +/// Windows and on the lossy text elsewhere. +pub fn escape_nsis_current_exe_arg(arg: impl AsRef) -> OsString { + let arg = arg.as_ref(); + let quote = arg + .as_encoded_bytes() + .iter() + .any(|c| *c == b' ' || *c == b'\t' || *c == b'/') + || arg.is_empty(); + let units = os_to_units(arg); + let mut cmd: Vec = Vec::with_capacity(units.len() + 2); + if quote { + cmd.push('"' as u16); + } + let mut backslashes: usize = 0; + for x in units { + if x == '\\' as u16 { + backslashes += 1; + } else { + if x == '"' as u16 { + cmd.extend((0..=backslashes).map(|_| '\\' as u16)); + } + backslashes = 0; + } + cmd.push(x); + } + if quote { + cmd.extend((0..backslashes).map(|_| '\\' as u16)); + cmd.push('"' as u16); + } + units_to_os(cmd) +} + +#[cfg(windows)] +fn os_to_units(text: &OsStr) -> Vec { + use std::os::windows::ffi::OsStrExt; + text.encode_wide().collect() +} + +#[cfg(not(windows))] +fn os_to_units(text: &OsStr) -> Vec { + text.to_string_lossy().encode_utf16().collect() +} + +#[cfg(windows)] +fn units_to_os(units: Vec) -> OsString { + use std::os::windows::ffi::OsStringExt; + OsString::from_wide(&units) +} + +#[cfg(not(windows))] +fn units_to_os(units: Vec) -> OsString { + OsString::from(String::from_utf16_lossy(&units)) +} + +/// updater.rs lines 1612-1635: one argument inside `LAUNCHAPPARGS="..."`. +pub fn escape_msi_property_arg(arg: impl AsRef) -> String { + let mut arg = arg.as_ref().to_string_lossy().to_string(); + if arg.is_empty() { + return "\"\"\"\"".to_string(); + } else if !arg.contains(' ') && !arg.contains('"') { + return arg; + } + if arg.contains('"') { + arg = arg.replace('"', r#""""""#); + } + if arg.starts_with('-') { + if let Some((a1, a2)) = arg.split_once('=') { + format!("{a1}=\"\"{a2}\"\"") + } else { + format!("\"\"{arg}\"\"") + } + } else { + format!("\"\"{arg}\"\"") + } +} diff --git a/crates/accshift-app/src/lifecycle/updater/error.rs b/crates/accshift-app/src/lifecycle/updater/error.rs new file mode 100644 index 0000000..0bb27eb --- /dev/null +++ b/crates/accshift-app/src/lifecycle/updater/error.rs @@ -0,0 +1,128 @@ +//! `tauri-plugin-updater` 2.11.0 `src/error.rs`, the variants this port can +//! produce, with the same `Display` strings. +//! +//! The TS side never saw a variant: the plugin serialized the error through +//! `Display` and `useAppUpdater` sorted the string with regular expressions +//! ([`super::super::update_flow::classify_update_check_error`]). The strings +//! are therefore the contract. The plugin's `#[error(transparent)]` variants +//! keep the source's own message here; they hold it as text so this file +//! needs nothing but `std` (it is shared with the Linux and macOS installers). + +use std::fmt; + +#[derive(Debug)] +#[non_exhaustive] +pub enum UpdaterError { + /// error.rs line 13. + EmptyEndpoints, + /// Transparent `std::io::Error`. + Io(std::io::Error), + /// Transparent `semver::Error`. + Semver(String), + /// Transparent `serde_json::Error`. + Serialization(String), + /// error.rs line 25. + ReleaseNotFound, + /// error.rs line 28. + UnsupportedArch, + /// error.rs line 31. + UnsupportedOs, + /// error.rs line 34. + FailedToDetermineExtractPath, + /// Transparent `url::ParseError`. + UrlParse(String), + /// Transparent `reqwest::Error`. + Reqwest(String), + /// error.rs line 43. + TargetNotFound(String), + /// error.rs lines 46-49. + TargetsNotFound(Vec), + /// error.rs line 51, "`{0}`". + Network(String), + /// Transparent `minisign_verify::Error`. + Minisign(String), + /// Transparent `base64::DecodeError`. + Base64(String), + /// error.rs line 60. + SignatureUtf8(String), + /// Transparent `zip::result::ZipError` (Windows). + Extract(String), + /// error.rs line 67. + TempDirNotOnSameMountPoint, + /// error.rs line 69. + BinaryNotFoundInArchive, + /// error.rs line 71. + TempDirNotFound, + /// error.rs line 73. + AuthenticationFailed, + /// error.rs line 77. + PackageInstallFailed, + /// error.rs line 79. + InvalidUpdaterFormat, + /// error.rs line 90. + InsecureTransportProtocol, +} + +impl fmt::Display for UpdaterError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyEndpoints => f.write_str("Updater does not have any endpoints set."), + Self::Io(error) => error.fmt(f), + Self::Semver(text) + | Self::Serialization(text) + | Self::UrlParse(text) + | Self::Reqwest(text) + | Self::Minisign(text) + | Self::Base64(text) + | Self::Extract(text) => f.write_str(text), + Self::ReleaseNotFound => { + f.write_str("Could not fetch a valid release JSON from the remote") + } + Self::UnsupportedArch => f.write_str( + "Unsupported application architecture, expected one of `x86`, `x86_64`, `arm` or `aarch64`.", + ), + Self::UnsupportedOs => { + f.write_str("Unsupported OS, expected one of `linux`, `darwin` or `windows`.") + } + Self::FailedToDetermineExtractPath => { + f.write_str("Failed to determine updater package extract path.") + } + Self::TargetNotFound(target) => write!( + f, + "the platform `{target}` was not found in the response `platforms` object" + ), + Self::TargetsNotFound(targets) => write!( + f, + "None of the fallback platforms `{targets:?}` were found in the response `platforms` object" + ), + Self::Network(text) => write!(f, "`{text}`"), + Self::SignatureUtf8(signature) => write!( + f, + "The signature {signature} could not be decoded, please check if it is a valid base64 string. The signature must be the contents of the `.sig` file generated by the Tauri bundler, as a string." + ), + Self::TempDirNotOnSameMountPoint => { + f.write_str("temp directory is not on the same mount point as the AppImage") + } + Self::BinaryNotFoundInArchive => { + f.write_str("binary for the current target not found in the archive") + } + Self::TempDirNotFound => f.write_str("failed to create temporary directory"), + Self::AuthenticationFailed => f.write_str("Authentication failed or was cancelled"), + Self::PackageInstallFailed => f.write_str("Failed to install package"), + Self::InvalidUpdaterFormat => f.write_str("invalid updater binary format"), + Self::InsecureTransportProtocol => f.write_str( + "The configured updater endpoint must use a secure protocol like `https`.", + ), + } + } +} + +impl std::error::Error for UpdaterError {} + +impl From for UpdaterError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +pub type UpdaterResult = Result; diff --git a/crates/accshift-app/src/lifecycle/updater/unix.rs b/crates/accshift-app/src/lifecycle/updater/unix.rs new file mode 100644 index 0000000..ff70125 --- /dev/null +++ b/crates/accshift-app/src/lifecycle/updater/unix.rs @@ -0,0 +1,305 @@ +//! The Linux and macOS installs of `tauri-plugin-updater` 2.11.0 +//! (`src/updater.rs` lines 1023-1385). Unlike Windows, nothing exits: the +//! caller relaunches once the files are replaced. +//! +//! Only `std`, `tempfile`, `infer`, `flate2`, `tar`, `dirs` and `osakit`: +//! the file is type-checked for Linux and macOS from Windows by a stub crate +//! that includes it by path (see the M5 result's commands). + +use super::error::{UpdaterError, UpdaterResult}; +use super::Installer; +use std::io::Cursor; +use std::path::{Path, PathBuf}; + +/// `run_on_main_thread` of the app; the macOS admin prompt runs there. +pub type MainThread<'a> = &'a dyn Fn(Box) -> Result<(), String>; + +/// updater.rs lines 1039-1045 (Linux): deb and rpm bundles go through the +/// package manager, everything else is an AppImage. +#[cfg(target_os = "linux")] +pub fn install_files( + installer: Option, + extract_path: &Path, + bytes: &[u8], + _main_thread: MainThread<'_>, +) -> UpdaterResult<()> { + match installer { + Some(Installer::Deb) => install_deb(extract_path, bytes), + Some(Installer::Rpm) => install_rpm(extract_path, bytes), + _ => install_appimage(extract_path, bytes), + } +} + +/// updater.rs lines 1047-1118. +#[cfg(target_os = "linux")] +fn install_appimage(extract_path: &Path, bytes: &[u8]) -> UpdaterResult<()> { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let extract_path_metadata = extract_path.metadata()?; + + for location in tmp_dir_locations(extract_path) { + let Some(location) = location else { continue }; + let tmp_dir = tempfile::Builder::new() + .prefix("tauri_current_app") + .tempdir_in(location)?; + let tmp_dir_metadata = tmp_dir.path().metadata()?; + if extract_path_metadata.dev() != tmp_dir_metadata.dev() { + continue; + } + let mut perms = tmp_dir_metadata.permissions(); + perms.set_mode(0o700); + std::fs::set_permissions(tmp_dir.path(), perms)?; + + let tmp_app_image = tmp_dir.path().join("current_app.AppImage"); + let permissions = std::fs::metadata(extract_path)?.permissions(); + std::fs::rename(extract_path, &tmp_app_image)?; + + if infer::archive::is_gz(bytes) { + let decoder = flate2::read::GzDecoder::new(Cursor::new(bytes)); + let mut archive = tar::Archive::new(decoder); + for mut entry in archive.entries()?.flatten() { + if let Ok(path) = entry.path() { + if path.extension() == Some(std::ffi::OsStr::new("AppImage")) { + if let Err(err) = entry.unpack(extract_path) { + std::fs::rename(&tmp_app_image, extract_path)?; + return Err(err.into()); + } + return Ok(()); + } + } + } + std::fs::rename(&tmp_app_image, extract_path)?; + return Err(UpdaterError::BinaryNotFoundInArchive); + } + + return match std::fs::write(extract_path, bytes) + .and_then(|_| std::fs::set_permissions(extract_path, permissions)) + { + Err(err) => { + std::fs::rename(&tmp_app_image, extract_path)?; + Err(err.into()) + } + Ok(()) => Ok(()), + }; + } + Err(UpdaterError::TempDirNotOnSameMountPoint) +} + +/// The three temp locations, in order (lines 1051-1055). +#[cfg(target_os = "linux")] +fn tmp_dir_locations(extract_path: &Path) -> [Option; 3] { + [ + Some(std::env::temp_dir()), + dirs::cache_dir(), + extract_path.parent().map(Path::to_path_buf), + ] +} + +/// updater.rs lines 1120-1128. +#[cfg(target_os = "linux")] +fn install_deb(extract_path: &Path, bytes: &[u8]) -> UpdaterResult<()> { + if !infer::archive::is_deb(bytes) { + return Err(UpdaterError::InvalidUpdaterFormat); + } + try_tmp_locations(extract_path, bytes, "dpkg", "-i", "deb") +} + +/// updater.rs lines 1130-1136. +#[cfg(target_os = "linux")] +fn install_rpm(extract_path: &Path, bytes: &[u8]) -> UpdaterResult<()> { + if !infer::archive::is_rpm(bytes) { + return Err(UpdaterError::InvalidUpdaterFormat); + } + try_tmp_locations(extract_path, bytes, "rpm", "-U", "rpm") +} + +/// updater.rs lines 1138-1172. +#[cfg(target_os = "linux")] +fn try_tmp_locations( + extract_path: &Path, + bytes: &[u8], + install_cmd: &str, + install_arg: &str, + package_extension: &str, +) -> UpdaterResult<()> { + for location in tmp_dir_locations(extract_path).into_iter().flatten() { + let prefix = format!("tauri_{package_extension}_update"); + if let Ok(tmp_dir) = tempfile::Builder::new() + .prefix(&prefix) + .tempdir_in(location) + { + let pkg_path = tmp_dir.path().join(format!("package.{package_extension}")); + if std::fs::write(&pkg_path, bytes).is_ok() { + return try_install_with_privileges(&pkg_path, install_cmd, install_arg); + } + } + } + Err(UpdaterError::TempDirNotFound) +} + +/// updater.rs lines 1174-1211: pkexec, then a zenity or kdialog password +/// fed to `sudo -S`, then terminal sudo. +#[cfg(target_os = "linux")] +fn try_install_with_privileges( + pkg_path: &Path, + install_cmd: &str, + install_arg: &str, +) -> UpdaterResult<()> { + use std::process::Command; + if let Ok(status) = Command::new("pkexec") + .arg(install_cmd) + .arg(install_arg) + .arg(pkg_path) + .status() + { + if status.success() { + return Ok(()); + } + } + if let Ok(password) = get_password_graphically() { + if install_with_sudo(pkg_path, &password, install_cmd, install_arg)? { + return Ok(()); + } + } + let status = Command::new("sudo") + .arg(install_cmd) + .arg(install_arg) + .arg(pkg_path) + .status()?; + if status.success() { + Ok(()) + } else { + Err(UpdaterError::PackageInstallFailed) + } +} + +/// updater.rs lines 1213-1241. +#[cfg(target_os = "linux")] +fn get_password_graphically() -> UpdaterResult { + use std::process::Command; + if let Ok(output) = Command::new("zenity") + .args([ + "--password", + "--title=Authentication Required", + "--text=Enter your password to install the update:", + ]) + .output() + { + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()); + } + } + if let Ok(output) = Command::new("kdialog") + .args(["--password", "Enter your password to install the update:"]) + .output() + { + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()); + } + } + Err(UpdaterError::AuthenticationFailed) +} + +/// updater.rs lines 1243-1272. +#[cfg(target_os = "linux")] +fn install_with_sudo( + pkg_path: &Path, + password: &str, + install_cmd: &str, + install_arg: &str, +) -> UpdaterResult { + use std::io::Write; + use std::process::{Command, Stdio}; + let mut child = Command::new("sudo") + .arg("-S") + .arg(install_cmd) + .arg(install_arg) + .arg(pkg_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + if let Some(mut stdin) = child.stdin.take() { + writeln!(stdin, "{password}")?; + } + Ok(child.wait()?.success()) +} + +/// updater.rs lines 1288-1384 (macOS): unpack the `.app.tar.gz` without +/// its first path component, move the current app aside, move the new one +/// in, asking for admin rights through AppleScript when the move is denied. +#[cfg(target_os = "macos")] +pub fn install_files( + _installer: Option, + extract_path: &Path, + bytes: &[u8], + main_thread: MainThread<'_>, +) -> UpdaterResult<()> { + use flate2::read::GzDecoder; + + let tmp_backup_dir = tempfile::Builder::new() + .prefix("tauri_current_app") + .tempdir()?; + let tmp_extract_dir = tempfile::Builder::new() + .prefix("tauri_updated_app") + .tempdir()?; + + let mut archive = tar::Archive::new(GzDecoder::new(Cursor::new(bytes))); + for entry in archive.entries()? { + let mut entry = entry?; + let collected_path: PathBuf = entry.path()?.iter().skip(1).collect(); + let extraction_path = tmp_extract_dir.path().join(&collected_path); + if let Some(parent) = extraction_path.parent() { + std::fs::create_dir_all(parent)?; + } + if let Err(err) = entry.unpack(&extraction_path) { + std::fs::remove_dir_all(tmp_extract_dir.path()).ok(); + return Err(err.into()); + } + } + + let move_result = std::fs::rename(extract_path, tmp_backup_dir.path().join("current_app")); + let need_authorization = match move_result { + Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => true, + Err(err) => { + std::fs::remove_dir_all(tmp_extract_dir.path()).ok(); + return Err(err.into()); + } + Ok(()) => false, + }; + + if need_authorization { + let apple_script = format!( + "do shell script \"rm -rf '{src}' && mv -f '{new}' '{src}'\" with administrator privileges", + src = extract_path.display(), + new = tmp_extract_dir.path().display() + ); + let (tx, rx) = std::sync::mpsc::channel(); + let res = main_thread(Box::new(move || { + let mut script = + osakit::Script::new_from_source(osakit::Language::AppleScript, &apple_script); + let result = script + .compile() + .map_err(|e| e.to_string()) + .and_then(|()| script.execute().map(|_| ()).map_err(|e| e.to_string())); + let _ = tx.send(result); + })); + let result = rx.recv().unwrap_or_else(|_| Err("no answer".into())); + if res.is_err() || result.is_err() { + std::fs::remove_dir_all(tmp_extract_dir.path()).ok(); + return Err(UpdaterError::Io(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "Failed to move the new app into place", + ))); + } + } else { + if extract_path.exists() { + std::fs::remove_dir_all(extract_path)?; + } + std::fs::rename(tmp_extract_dir.path(), extract_path)?; + } + + let _ = std::process::Command::new("touch") + .arg(extract_path) + .status(); + Ok(()) +} diff --git a/crates/accshift-app/tests/lifecycle_flow.rs b/crates/accshift-app/tests/lifecycle_flow.rs new file mode 100644 index 0000000..4161f5a --- /dev/null +++ b/crates/accshift-app/tests/lifecycle_flow.rs @@ -0,0 +1,882 @@ +//! The lifecycle state machines: boot and shutdown order (recorded by a +//! fake host), the update flow (`useAppUpdater.test.ts`), the external +//! refresh (`useAppLifecycle.test.ts`), the boot helpers, the UI scale and +//! the process control (a fake never relaunches or exits). + +mod support; + +use accshift_app::accounts::{Toast, ToastKind}; +use accshift_app::lifecycle::app_shell::{ + first_run_platforms, initial_load, migration_toast, plan_external_refresh, semver_core, + settings_snapshot, ExternalRefreshGate, InitialLoad, RefreshStep, SHELL_INIT_ORDER, +}; +use accshift_app::lifecycle::boot::{ + log_app_event, mask_value, redact_object, remember_boot_locale, serialize_log_value, + track_update, truncate_log, BootMarks, CloseRequest, BOOT_FAILSAFE, +}; +use accshift_app::lifecycle::process::{self, ProcessControl}; +use accshift_app::lifecycle::ui_scale::{ + backdrop_available, clamp_ui_scale_percent, liquid_backdrop_active, UiScale, WheelDeltaMode, + PERSIST_DELAY, +}; +use accshift_app::lifecycle::update_flow::{ + classify_update_check_error, AppUpdater, LogLevel, PendingUpdate, UpdateCommand, + UpdateErrorCode, UpdateOutput, UpdateStage, UpdateState, +}; +use accshift_app::lifecycle::{ + BootOutcome, BootStep, Lifecycle, LifecycleHost, ShutdownStep, BOOT_ORDER, SHUTDOWN_ORDER, + UPDATE_CHECK_DELAY, +}; +use accshift_app::platforms::{PlatformRegistry, RuntimeOs}; +use accshift_app::settings::{sanitize_settings, AppSettings}; +use accshift_app::storage::{ + CLIENT_STORE_PERSONAS, CLIENT_STORE_SETTINGS, STORAGE_TARGET_APP_CONFIG_LOCAL, + STORAGE_TARGET_CUSTOM_THEMES, +}; +use accshift_app::stores::StoreContext; +use accshift_app::text::Text; +use serde_json::{json, Value}; +use std::cell::RefCell; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +// --------------------------------------------------------------------------- +// Boot and shutdown order + +#[derive(Default)] +struct RecordingHost { + calls: Vec, + forwarded: bool, + flush_error: Option, +} + +impl LifecycleHost for RecordingHost { + fn acquire_single_instance(&mut self) -> bool { + self.calls.push("single_instance".into()); + !self.forwarded + } + fn launch_url(&mut self, url: Option) { + self.calls.push(format!( + "launch_url:{}", + url.map(|u| u.to_string()).unwrap_or_default() + )); + } + fn read_legacy_storage(&mut self) { + self.calls.push("legacy_read".into()); + } + fn initialize_storage(&mut self) { + self.calls.push("storage_initialize".into()); + } + fn apply_theme(&mut self) { + self.calls.push("theme".into()); + } + fn start_telemetry(&mut self) { + self.calls.push("telemetry_start".into()); + } + fn begin_session(&mut self) { + self.calls.push("session_begin".into()); + } + fn spawn_maintenance(&mut self) { + self.calls.push("maintenance".into()); + } + fn register_schemes(&mut self) { + self.calls.push("schemes".into()); + } + fn arm_boot_failsafe(&mut self, after: Duration) { + self.calls.push(format!("failsafe:{}", after.as_millis())); + } + fn schedule_update_check(&mut self, after: Duration) { + self.calls.push(format!("updater:{}", after.as_millis())); + } + fn start_geometry_save(&mut self) { + self.calls.push("geometry_start".into()); + } + fn hide_window(&mut self) { + self.calls.push("hide".into()); + } + fn end_telemetry_session(&mut self) { + self.calls.push("telemetry_end".into()); + } + fn wait_geometry_save(&mut self, bound: Duration) { + self.calls + .push(format!("geometry_wait:{}", bound.as_secs())); + } + fn flush_saves(&mut self) -> Result<(), String> { + self.calls.push("flush".into()); + match &self.flush_error { + Some(error) => Err(error.clone()), + None => Ok(()), + } + } + fn release_single_instance(&mut self) { + self.calls.push("single_instance_release".into()); + } + fn exit(&mut self, code: i32) { + self.calls.push(format!("exit:{code}")); + } + fn log(&mut self, level: &str, source: &str, message: &str) { + self.calls.push(format!("log:{level}:{source}:{message}")); + } +} + +#[test] +fn boot_runs_every_step_in_the_documented_order() { + let mut host = RecordingHost::default(); + let mut lifecycle = Lifecycle::new(vec![ + "accshift.exe".into(), + "accshift://switch/steam/alice".into(), + ]); + assert_eq!(lifecycle.boot(&mut host), BootOutcome::Running); + assert_eq!( + host.calls, + [ + "single_instance", + "launch_url:accshift://switch/steam/alice", + "legacy_read", + "storage_initialize", + "theme", + "telemetry_start", + "session_begin", + "maintenance", + "schemes", + "failsafe:5000", + "updater:3500", + ] + ); + assert_eq!(BOOT_ORDER.len(), 11); + assert_eq!(BOOT_ORDER[0], BootStep::SingleInstance); + assert_eq!(BOOT_ORDER[10], BootStep::UpdaterCheck); + assert_eq!(UPDATE_CHECK_DELAY, Duration::from_millis(3500)); + assert_eq!(BOOT_FAILSAFE, Duration::from_millis(5000)); + + // Boot runs once. + host.calls.clear(); + assert_eq!(lifecycle.boot(&mut host), BootOutcome::Running); + assert!(host.calls.is_empty()); +} + +#[test] +fn a_forwarded_launch_exits_before_touching_anything() { + let mut host = RecordingHost { + forwarded: true, + ..Default::default() + }; + let mut lifecycle = Lifecycle::new(vec!["accshift.exe".into()]); + assert_eq!(lifecycle.boot(&mut host), BootOutcome::Forwarded); + assert_eq!(host.calls, ["single_instance", "exit:0"]); +} + +#[test] +fn a_launch_without_a_link_has_no_launch_url() { + let mut host = RecordingHost::default(); + // Two arguments: the plugin only reads a lone URL. + Lifecycle::new(vec![ + "accshift.exe".into(), + "--minimized".into(), + "accshift://switch/steam/a".into(), + ]) + .boot(&mut host); + assert_eq!(host.calls[1], "launch_url:"); +} + +#[test] +fn shutdown_runs_once_in_the_documented_order() { + let mut host = RecordingHost::default(); + let mut lifecycle = Lifecycle::new(vec!["accshift.exe".into()]); + assert!(!lifecycle.is_closing()); + assert!(lifecycle.shutdown(&mut host)); + assert_eq!( + host.calls, + [ + "geometry_start", + "hide", + "telemetry_end", + "geometry_wait:5", + "flush", + "single_instance_release", + "exit:0", + ] + ); + assert_eq!(SHUTDOWN_ORDER[0], ShutdownStep::StartGeometrySave); + assert!(lifecycle.is_closing()); + host.calls.clear(); + assert!(!lifecycle.shutdown(&mut host)); + assert!(host.calls.is_empty()); +} + +#[test] +fn a_failed_flush_is_logged_and_the_close_goes_on() { + let mut host = RecordingHost { + flush_error: Some("disk full".into()), + ..Default::default() + }; + Lifecycle::new(Vec::new()).shutdown(&mut host); + assert_eq!( + &host.calls[4..], + [ + "flush", + "log:error:frontend.app:Failed to flush pending saves on close: disk full", + "single_instance_release", + "exit:0", + ] + ); +} + +// --------------------------------------------------------------------------- +// Update flow (useAppUpdater.test.ts) + +#[derive(Debug, Clone, PartialEq)] +struct FakeUpdate(&'static str); + +impl PendingUpdate for FakeUpdate { + fn version(&self) -> String { + self.0.to_string() + } +} + +const TARGET_NOT_FOUND: &str = + "the platform `linux-x86_64` was not found in the response `platforms` object"; +const TARGETS_NOT_FOUND: &str = "None of the fallback platforms `[\"darwin-aarch64-app\", \"darwin-aarch64\"]` were found in the response `platforms` object"; + +fn failed_check(error: &str) -> Vec> { + let mut updater = AppUpdater::::new(false); + assert_eq!(updater.start(), [UpdateOutput::Run(UpdateCommand::Check)]); + let outputs = updater.on_check_result(Err(error.into())); + assert_eq!(updater.state(), UpdateState::Idle); + outputs +} + +fn tracked( + outputs: &[UpdateOutput], +) -> Vec<(UpdateStage, Option, Option<&'static str>)> { + outputs + .iter() + .filter_map(|output| match output { + UpdateOutput::Track { + stage, + target_version, + error_code, + } => Some(( + *stage, + target_version.clone(), + error_code.map(UpdateErrorCode::as_str), + )), + _ => None, + }) + .collect() +} + +fn logged(outputs: &[UpdateOutput], level: LogLevel) -> usize { + outputs + .iter() + .filter(|output| matches!(output, UpdateOutput::Log { level: l, .. } if *l == level)) + .count() +} + +#[test] +fn a_missing_platform_entry_has_its_own_code_and_no_error_log() { + let outputs = failed_check(TARGET_NOT_FOUND); + assert_eq!( + tracked(&outputs), + [(UpdateStage::Failed, None, Some("update_target_missing"))] + ); + assert_eq!(logged(&outputs, LogLevel::Error), 0); + assert_eq!(logged(&outputs, LogLevel::Info), 1); +} + +#[test] +fn an_unreadable_manifest_is_told_apart_from_a_dead_endpoint() { + let outputs = failed_check("Could not fetch a valid release JSON from the remote"); + assert_eq!( + tracked(&outputs), + [(UpdateStage::Failed, None, Some("update_manifest_invalid"))] + ); +} + +#[test] +fn a_network_failure_is_still_check_failed() { + let outputs = failed_check("error sending request for url (https://github.com)"); + assert_eq!( + tracked(&outputs), + [(UpdateStage::Failed, None, Some("check_failed"))] + ); + assert_eq!(logged(&outputs, LogLevel::Error), 1); +} + +#[test] +fn no_update_stays_quiet() { + let mut updater = AppUpdater::::new(false); + updater.start(); + assert!(updater.on_check_result(Ok(None)).is_empty()); + assert_eq!(updater.state(), UpdateState::Idle); +} + +#[test] +fn the_check_errors_classify_as_the_ts_tests() { + let code = |text: Option<&str>| classify_update_check_error(text).as_str(); + assert_eq!(code(Some(TARGET_NOT_FOUND)), "update_target_missing"); + assert_eq!(code(Some(TARGETS_NOT_FOUND)), "update_target_missing"); + assert_eq!( + code(Some("missing field `version` at line 1 column 42")), + "update_manifest_invalid" + ); + assert_eq!( + code(Some( + "The signature ...= could not be decoded, please check if it is a valid base64 string." + )), + "update_manifest_invalid" + ); + assert_eq!(code(Some("operation timed out")), "check_failed"); + assert_eq!(code(None), "check_failed"); + // The rest of MANIFEST_INVALID_PATTERNS, case-insensitive. + for text in [ + "INVALID UPDATER BINARY FORMAT", + "expected value at line 1 column 1", + "invalid type: integer `1`, expected a string", + "trailing characters at line 1 column 9", + "unexpected character 'x' while parsing Minor version number", + ] { + assert_eq!(code(Some(text)), "update_manifest_invalid", "{text}"); + } +} + +#[test] +fn the_whole_flow_downloads_then_applies() { + let mut updater = AppUpdater::::new(false); + assert_eq!(updater.cta_label(), None); + assert_eq!(updater.cta_title(), Text::key("update.restartToApply")); + updater.start(); + // A second start while checking does nothing. + assert!(updater.start().is_empty()); + let outputs = updater.on_check_result(Ok(Some(FakeUpdate("1.2.0")))); + assert_eq!( + outputs, + [ + UpdateOutput::Track { + stage: UpdateStage::Available, + target_version: Some("1.2.0".into()), + error_code: None, + }, + UpdateOutput::Run(UpdateCommand::Download(FakeUpdate("1.2.0"))), + ] + ); + assert_eq!(updater.state(), UpdateState::Downloading); + // Not ready yet: apply does nothing. + assert!(updater.apply().is_empty()); + + let outputs = updater.on_download_result(Ok(vec![1, 2, 3])); + assert_eq!( + outputs[1], + UpdateOutput::Toast(Text::with( + "update.readyToastVersion", + [("version", "1.2.0".to_string())] + )) + ); + assert_eq!(updater.state(), UpdateState::Ready); + assert_eq!(updater.cta_label(), Some(Text::key("update.ctaAvailable"))); + assert_eq!( + updater.cta_title(), + Text::with( + "update.restartToApplyVersion", + [("version", "1.2.0".to_string())] + ) + ); + assert!(!updater.cta_disabled()); + + let outputs = updater.apply(); + assert_eq!( + outputs, + [UpdateOutput::Run(UpdateCommand::Install { + update: FakeUpdate("1.2.0"), + bytes: vec![1, 2, 3], + })] + ); + assert!(updater.cta_disabled()); + assert_eq!(updater.cta_label(), Some(Text::key("update.ctaInstalling"))); + assert!(updater.apply().is_empty()); + + let outputs = updater.on_install_result(Ok(())); + assert_eq!( + tracked(&outputs), + [(UpdateStage::Applied, Some("1.2.0".into()), None)] + ); + assert_eq!(outputs[1], UpdateOutput::Run(UpdateCommand::Relaunch)); +} + +#[test] +fn a_failed_download_resets_and_tracks_the_version() { + let mut updater = AppUpdater::::new(false); + updater.start(); + updater.on_check_result(Ok(Some(FakeUpdate("1.2.0")))); + let outputs = updater.on_download_result(Err("`Download request failed`".into())); + assert_eq!( + tracked(&outputs), + [( + UpdateStage::Failed, + Some("1.2.0".into()), + Some("download_failed") + )] + ); + assert_eq!(updater.state(), UpdateState::Idle); + assert_eq!(updater.version(), ""); + // The flow can start again. + assert_eq!(updater.start(), [UpdateOutput::Run(UpdateCommand::Check)]); +} + +#[test] +fn a_failed_install_or_relaunch_toasts_and_checks_again() { + for relaunch in [false, true] { + let mut updater = AppUpdater::::new(false); + updater.start(); + updater.on_check_result(Ok(Some(FakeUpdate("1.2.0")))); + updater.on_download_result(Ok(Vec::new())); + updater.apply(); + let outputs = if relaunch { + updater.on_install_result(Ok(())); + updater.on_relaunch_failed("spawn failed".into()) + } else { + updater.on_install_result(Err("flush failed".into())) + }; + let code = if relaunch { + "relaunch_failed" + } else { + "install_failed" + }; + assert_eq!( + tracked(&outputs), + [(UpdateStage::Failed, Some("1.2.0".into()), Some(code))] + ); + assert!( + matches!(&outputs[0], UpdateOutput::Log { level: LogLevel::Error, message } if message.starts_with("Failed to restart for update: ")) + ); + assert_eq!( + &outputs[2..], + [ + UpdateOutput::Toast(Text::key("update.restartFailed")), + UpdateOutput::Run(UpdateCommand::Check), + ] + ); + assert_eq!(updater.state(), UpdateState::Checking); + } +} + +#[test] +fn a_dev_build_never_checks() { + let mut updater = AppUpdater::::new(true); + assert!(updater.start().is_empty()); + assert_eq!(updater.state(), UpdateState::Idle); +} + +#[test] +fn update_telemetry_reaches_the_backend() { + let backend = support::scripted("demo"); + track_update(&backend, "failed", None, Some("check_failed")); + assert_eq!(backend.calls("telemetry_track_update").len(), 1); + let call = &backend.calls("telemetry_track_update")[0]; + assert!( + call.contains("failed") && call.contains("check_failed"), + "{call}" + ); +} + +// --------------------------------------------------------------------------- +// App shell (useAppLifecycle.test.ts) + +fn settings(patch: Value) -> AppSettings { + sanitize_settings(&patch, &StoreContext::shipped()) +} + +fn refresh(changed: &[&str], enabled: &[&str], active_tab: &str) -> Vec { + let changed: Vec = changed.iter().map(|c| c.to_string()).collect(); + let settings = settings(json!({ + "enabledPlatforms": enabled, + "defaultPlatformId": enabled[0], + })); + plan_external_refresh( + &changed, + &settings, + active_tab, + &PlatformRegistry::shipped(), + RuntimeOs::Windows, + false, + ) +} + +#[test] +fn the_personas_list_reloads_when_another_process_rewrote_it() { + let steps = refresh(&[CLIENT_STORE_PERSONAS], &["steam"], "steam"); + assert_eq!( + steps + .iter() + .filter(|step| **step == RefreshStep::RefreshPersonas) + .count(), + 1 + ); +} + +#[test] +fn unrelated_stores_leave_the_personas_alone() { + let steps = refresh(&["something-else"], &["steam"], "steam"); + assert!(!steps.contains(&RefreshStep::RefreshPersonas)); + // Only the log line. + assert_eq!(steps.len(), 1); + assert_eq!( + steps[0], + RefreshStep::Log { + details: r#"{"changed":["something-else"],"activeTab":"steam"}"#.into() + } + ); +} + +/// The steps the TS test records as `calls`. +fn recorded_calls(steps: &[RefreshStep]) -> Vec { + steps + .iter() + .filter_map(|step| match step { + RefreshStep::ClearLoaderForPlatformChange => Some("clear".to_string()), + RefreshStep::SetActiveTab(tab) => Some(format!("tab:{tab}")), + RefreshStep::LoadAccounts { .. } => Some("load".to_string()), + _ => None, + }) + .collect() +} + +#[test] +fn an_external_change_that_disabled_the_tab_moves_off_it_and_reloads() { + let steps = refresh(&[CLIENT_STORE_SETTINGS], &["riot"], "steam"); + assert_eq!(recorded_calls(&steps), ["clear", "tab:riot", "load"]); + assert!(steps.contains(&RefreshStep::LoadAccounts { + force: true, + warnings: false + })); + assert!(steps.contains(&RefreshStep::ReplaceHistory("riot".into()))); +} + +#[test] +fn the_grid_is_left_alone_while_the_tab_stays_enabled() { + let steps = refresh(&[CLIENT_STORE_SETTINGS], &["steam", "riot"], "steam"); + assert!(recorded_calls(&steps).is_empty()); +} + +#[test] +fn app_config_and_platform_stores_reload_the_accounts() { + let steps = refresh(&[STORAGE_TARGET_APP_CONFIG_LOCAL], &["steam"], "steam"); + assert!(matches!( + steps.last(), + Some(RefreshStep::LoadAccounts { force: true, .. }) + )); + let steps = refresh(&[STORAGE_TARGET_CUSTOM_THEMES], &["steam"], "steam"); + assert_eq!(steps[1], RefreshStep::LoadCustomThemes); + assert_eq!(steps[2], RefreshStep::RefreshSettings); + assert!(refresh(&[], &["steam"], "steam").is_empty()); +} + +#[test] +fn the_shell_init_helpers_match_the_ts() { + assert_eq!(semver_core("1.0.5"), "1.0.5"); + assert_eq!(semver_core("v1.2.3-beta.1+build"), "1.2.3"); + assert_eq!(semver_core("12.34.56.78"), "12.34.56"); + assert_eq!(semver_core("dev"), "dev"); + assert_eq!(semver_core("1.2"), "1.2"); + + assert_eq!( + migration_toast("migrated"), + Some(Toast::new( + Text::key("toast.legacyConfigMigrated"), + ToastKind::Default + )) + ); + assert_eq!( + migration_toast("error:denied").map(|toast| toast.text), + Some(Text::key("toast.legacyConfigMigrationFailed")) + ); + assert_eq!(migration_toast("none"), None); + + let registry = PlatformRegistry::shipped(); + let detected = first_run_platforms( + &["riot".to_string(), "nope".to_string(), "steam".to_string()], + ®istry, + RuntimeOs::Windows, + ); + assert_eq!(detected.detected, ["riot", "steam"]); + assert_eq!( + detected.enable, + Some(( + vec!["riot".to_string(), "steam".to_string()], + "riot".to_string() + )) + ); + assert_eq!( + first_run_platforms(&[], ®istry, RuntimeOs::Windows).enable, + None + ); + + assert_eq!( + initial_load(®istry, "steam", RuntimeOs::Windows), + InitialLoad::LoadAccounts + ); + assert_eq!( + initial_load(®istry, "steam", RuntimeOs::Unknown), + InitialLoad::RefreshItems + ); + assert_eq!(SHELL_INIT_ORDER.len(), 10); + + let snapshot = settings_snapshot(&settings(json!({"enabledPlatforms": ["steam"]}))); + assert_eq!(snapshot.enabled_platforms, ["steam"]); + assert_eq!(snapshot.ui_language, "en"); + assert!(!snapshot.streamer_mode.is_empty()); + assert!(!snapshot.animations.is_empty()); + + let mut gate = ExternalRefreshGate::default(); + assert!(gate.begin()); + assert!(!gate.begin()); + gate.end(); + assert!(gate.begin()); +} + +// --------------------------------------------------------------------------- +// Boot helpers + +#[test] +fn boot_marks_keep_the_first_write_and_round_to_a_tenth() { + let origin = Instant::now(); + let mut marks = BootMarks::new(origin); + marks.mark("app-start", origin + Duration::from_micros(12_345)); + marks.mark("app-start", origin + Duration::from_millis(50)); + assert_eq!(marks.to_value(), json!({"app-start": 12.3})); + assert_eq!( + marks.since("app-start", origin + Duration::from_micros(22_345)), + Some(10.0) + ); + assert_eq!(marks.since("missing", origin), None); +} + +#[test] +fn the_boot_locale_is_written_only_when_it_changed() { + assert_eq!(remember_boot_locale(Some("fr"), "fr"), None); + assert_eq!( + remember_boot_locale(Some("en"), "fr").as_deref(), + Some("fr") + ); + assert_eq!(remember_boot_locale(None, "en").as_deref(), Some("en")); +} + +#[test] +fn log_details_are_redacted_as_app_logger() { + assert_eq!(mask_value(&json!("ab")), json!("***")); + assert_eq!(mask_value(&json!("alice")), json!("al***")); + assert_eq!(mask_value(&json!(["a", "b"])), json!("[2 items]")); + assert_eq!(mask_value(&json!(76561198000000000u64)), json!("***")); + + let details = json!({ + "Username": "alice", + "steamId": "76561198000000000", + "nested": {"email": "a@b.c", "keep": 1}, + "list": [{"username": "kept in arrays"}], + "platform": "steam", + }); + let redacted = redact_object(details.as_object().unwrap()); + assert_eq!( + Value::Object(redacted), + json!({ + "Username": "al***", + "steamId": "76***", + "nested": {"email": "a@***", "keep": 1}, + "list": [{"username": "kept in arrays"}], + "platform": "steam", + }) + ); + assert_eq!(serialize_log_value(&json!("plain text")), "plain text"); + assert_eq!(serialize_log_value(&json!(42)), "42"); + // `typeof [] === "object"`: a top-level array goes through redactObject. + assert_eq!( + serialize_log_value(&json!([{"uuid": "abcdef"}, "x"])), + r#"{"0":{"uuid":"ab***"},"1":"x"}"# + ); + + let backend = support::scripted("demo"); + log_app_event( + &backend, + "info", + "frontend.test", + "hello", + Some(&json!({"userId": "1234"})), + ); + log_app_event( + &backend, + "info", + "frontend.test", + "no details", + Some(&Value::Null), + ); + let calls = backend.calls("log_app_event"); + assert_eq!(calls.len(), 2); + assert!(calls[0].contains("12***"), "{}", calls[0]); + assert!(calls[1].contains("None"), "{}", calls[1]); +} + +#[test] +fn log_lines_are_capped_in_utf16_units() { + let long = "é".repeat(600); + let (message, details) = truncate_log(&long, Some(&"x".repeat(20_000))); + assert_eq!(message.chars().count(), 512); + assert_eq!(details.unwrap().len(), 16_384); + let (short, none) = truncate_log("ok", None); + assert_eq!((short.as_str(), none), ("ok", None)); +} + +#[test] +fn the_close_request_runs_once() { + let mut close = CloseRequest::default(); + assert!(close.request()); + assert!(close.is_closing()); + assert!(!close.request()); +} + +// --------------------------------------------------------------------------- +// UI scale and backdrop + +#[test] +fn the_ui_scale_rounds_to_the_step_and_clamps() { + assert_eq!(clamp_ui_scale_percent(100.0), 100); + assert_eq!(clamp_ui_scale_percent(102.4), 100); + assert_eq!(clamp_ui_scale_percent(102.5), 105); + assert_eq!(clamp_ui_scale_percent(10.0), 75); + assert_eq!(clamp_ui_scale_percent(400.0), 150); +} + +#[test] +fn the_ui_scale_follows_shortcuts_and_the_ctrl_wheel_then_saves_late() { + let now = Instant::now(); + let mut scale = UiScale::new(); + assert_eq!(scale.zoom_in(100, now), Some(105)); + assert_eq!(scale.zoom_out(100, now), Some(95)); + assert_eq!(scale.reset(100, now), None); + assert_eq!(scale.zoom_in(150, now), None); + + // Pixels accumulate up to 80 before a step; up is zoom in. + assert_eq!( + scale.wheel(true, -50.0, WheelDeltaMode::Pixel, 800.0, 100, now), + None + ); + assert_eq!( + scale.wheel(true, -50.0, WheelDeltaMode::Pixel, 800.0, 100, now), + Some(105) + ); + // Lines count 16 px, pages the page height. + assert_eq!( + scale.wheel(true, 5.0, WheelDeltaMode::Line, 800.0, 100, now), + Some(95) + ); + assert_eq!( + scale.wheel(true, -0.1, WheelDeltaMode::Page, 800.0, 100, now), + Some(105) + ); + // Without ctrl the wheel scrolls and the sum resets. + assert_eq!( + scale.wheel(true, -50.0, WheelDeltaMode::Pixel, 800.0, 100, now), + None + ); + assert_eq!( + scale.wheel(false, -50.0, WheelDeltaMode::Pixel, 800.0, 100, now), + None + ); + assert_eq!( + scale.wheel(true, -50.0, WheelDeltaMode::Pixel, 800.0, 100, now), + None + ); + + let start = Instant::now(); + let mut scale = UiScale::new(); + scale.set(100, 120.0, start); + assert_eq!(scale.next_persist(), Some(start + PERSIST_DELAY)); + assert_eq!(scale.take_due(start + Duration::from_millis(100)), None); + assert_eq!(scale.take_due(start + PERSIST_DELAY), Some(120)); + assert_eq!(scale.take_due(start + PERSIST_DELAY), None); + + scale.set(120, 130.0, start); + assert_eq!(scale.destroy(125), Some(125)); + assert_eq!(scale.destroy(125), None); +} + +#[test] +fn the_backdrop_decisions_match_use_theme_application() { + assert!(liquid_backdrop_active(true, RuntimeOs::Windows)); + assert!(!liquid_backdrop_active(true, RuntimeOs::Macos)); + assert!(!liquid_backdrop_active(false, RuntimeOs::Windows)); + assert!(!backdrop_available(RuntimeOs::Linux, false, true)); + assert!(backdrop_available(RuntimeOs::Windows, false, false)); + assert!(!backdrop_available(RuntimeOs::Windows, true, false)); + assert!(backdrop_available(RuntimeOs::Windows, true, true)); +} + +// --------------------------------------------------------------------------- +// Process control + +#[derive(Default)] +struct FakeProcess { + calls: RefCell>, + fail_spawn: bool, +} + +impl ProcessControl for FakeProcess { + fn spawn(&self, program: &Path, args: &[OsString]) -> std::io::Result<()> { + self.calls.borrow_mut().push(format!( + "spawn:{}:{}", + program.display(), + args.iter() + .map(|a| a.to_string_lossy().to_string()) + .collect::>() + .join(",") + )); + if self.fail_spawn { + Err(std::io::Error::other("denied")) + } else { + Ok(()) + } + } + + fn exit(&self, code: i32) { + self.calls.borrow_mut().push(format!("exit:{code}")); + } +} + +#[test] +fn relaunch_releases_spawns_with_the_same_arguments_then_exits() { + let control = FakeProcess::default(); + let args: Vec = vec!["accshift.exe".into(), "--flag".into(), "x y".into()]; + let before = RefCell::new(false); + let error = process::restart(&control, Ok(PathBuf::from("app-binary")), &args, || { + *before.borrow_mut() = true + }); + assert!(error.is_none()); + assert!(*before.borrow()); + assert_eq!( + *control.calls.borrow(), + ["spawn:app-binary:--flag,x y", "exit:0"] + ); + + // A failed spawn is reported and the process still exits. + let failing = FakeProcess { + fail_spawn: true, + ..Default::default() + }; + let error = process::restart(&failing, Ok(PathBuf::from("app-binary")), &args, || {}); + assert_eq!(error.unwrap().to_string(), "denied"); + assert_eq!(failing.calls.borrow().last().unwrap(), "exit:0"); + + // No binary: no spawn, exit anyway. + let control = FakeProcess::default(); + process::restart(&control, Err(std::io::Error::other("gone")), &args, || {}); + assert_eq!(*control.calls.borrow(), ["exit:0"]); + + let control = FakeProcess::default(); + process::exit(&control, 3, || {}); + assert_eq!(*control.calls.borrow(), ["exit:3"]); + + // The real binary resolves to this test executable. + let binary = process::current_binary().unwrap(); + assert!(binary.is_file()); + assert_eq!(process::relaunch_program(&binary), binary); +} diff --git a/crates/accshift-app/tests/lifecycle_links.rs b/crates/accshift-app/tests/lifecycle_links.rs new file mode 100644 index 0000000..047a304 --- /dev/null +++ b/crates/accshift-app/tests/lifecycle_links.rs @@ -0,0 +1,808 @@ +//! Deep links (`deepLinkUrl.test.ts`, `useDeepLink.test.ts`, the boot.rs +//! tests, the plugin's argument and registry handling) and the single +//! instance service, run with a test identifier only. + +use accshift_app::lifecycle::deep_link::{ + decode_uri_component, deep_link_log_shape, find_account, handle_cli_arguments, is_registered, + on_open_url, parse_deep_link, register_all, registered_command, registration_entries, + schemes_need_registration, second_launch, wire_scheme_registration, DeepLinkAction, + DeepLinkAnswer, DeepLinkController, DeepLinkSwitchRequest, DeepLinkView, RegistryValue, + SchemeRegistry, BOOT_TIMEOUT, SCHEMES, +}; +use accshift_app::lifecycle::single_instance::{ + self, dbus_name_and_path, macos_socket_path, parse_windows_payload, windows_names, + windows_payload, Launch, SecondInstance, +}; +use accshift_app::platforms::{PlatformAccount, PlatformRegistry, RuntimeOs}; +use accshift_app::settings::{sanitize_settings, AppSettings}; +use accshift_app::stores::StoreContext; +use accshift_app::text::Text; +use serde_json::json; +use std::collections::BTreeMap; +use std::time::{Duration, Instant}; +use url::Url; + +// --------------------------------------------------------------------------- +// parseDeepLink (deepLinkUrl.test.ts) + +fn parsed(url: &str) -> Option<(String, String)> { + parse_deep_link(url).map(|r| (r.platform_id, r.account_ref)) +} + +fn account_ref(url: &str) -> Option { + parse_deep_link(url).map(|r| r.account_ref) +} + +#[test] +fn parses_a_switch_link() { + assert_eq!( + parse_deep_link("accshift://switch/steam/76561198000000001"), + Some(DeepLinkSwitchRequest { + platform_id: "steam".into(), + account_ref: "76561198000000001".into() + }) + ); +} + +#[test] +fn is_case_insensitive_on_the_scheme_action_and_platform() { + assert_eq!( + parsed("ACCSHIFT://Switch/Steam/bob"), + Some(("steam".into(), "bob".into())) + ); +} + +#[test] +fn keeps_the_account_ref_case_as_given() { + assert_eq!( + account_ref("accshift://switch/steam/BobTheBuilder").as_deref(), + Some("BobTheBuilder") + ); +} + +#[test] +fn decodes_percent_encoded_segments() { + assert_eq!( + account_ref("accshift://switch/riot/main%20smurf").as_deref(), + Some("main smurf") + ); + assert_eq!( + account_ref("accshift://switch/battle-net/user%40example.com").as_deref(), + Some("user@example.com") + ); +} + +#[test] +fn tolerates_trailing_slash_query_and_fragment() { + for url in [ + "accshift://switch/steam/bob/", + "accshift://switch/steam/bob?source=streamdeck", + "accshift://switch/steam/bob#x", + ] { + assert_eq!(account_ref(url).as_deref(), Some("bob"), "{url}"); + } +} + +#[test] +fn survives_invalid_percent_encoding() { + assert_eq!( + account_ref("accshift://switch/steam/100%").as_deref(), + Some("100%") + ); + assert_eq!(decode_uri_component("100%"), None); + assert_eq!(decode_uri_component("%E9"), None); + assert_eq!( + decode_uri_component("%C3%A9t%C3%A9").as_deref(), + Some("été") + ); +} + +#[test] +fn rejects_other_schemes() { + assert_eq!(parse_deep_link("https://switch/steam/bob"), None); + assert_eq!(parse_deep_link("steam://switch/steam/bob"), None); +} + +#[test] +fn rejects_unknown_actions() { + assert_eq!(parse_deep_link("accshift://launch/steam/bob"), None); +} + +#[test] +fn rejects_missing_or_extra_segments() { + assert_eq!(parse_deep_link("accshift://switch"), None); + assert_eq!(parse_deep_link("accshift://switch/steam"), None); + assert_eq!(parse_deep_link("accshift://switch/steam/bob/extra"), None); +} + +#[test] +fn rejects_empty_and_whitespace_only_refs() { + assert_eq!(parse_deep_link("accshift://switch/steam/%20"), None); + assert_eq!(parse_deep_link(""), None); +} + +#[test] +fn rejects_urls_above_the_maximum_accepted_length() { + let long = format!("accshift://switch/steam/{}", "a".repeat(2049)); + assert_eq!(parse_deep_link(&long), None); + let fits = format!("accshift://switch/steam/{}", "a".repeat(2048 - 24)); + assert!(parse_deep_link(&fits).is_some()); +} + +// --------------------------------------------------------------------------- +// boot.rs: log shape and registration decision (boot/tests.rs, windows_tests.rs) + +#[test] +fn the_log_shape_hides_the_account_and_extras() { + let shape = |url: &str| deep_link_log_shape(&Url::parse(url).unwrap()); + assert_eq!( + shape("accshift://switch/battle-net/user%40example.com"), + "accshift://switch/battle-net/" + ); + assert_eq!( + shape("accshift://switch/steam/login?x=1"), + "accshift://switch/steam/<+extra>" + ); + assert_eq!(shape("accshift://switch"), "accshift://switch"); + assert_eq!( + shape(&format!("accshift://switch/{}/a", "p".repeat(40))), + format!("accshift://switch/{}/", "p".repeat(32)) + ); +} + +#[test] +fn registration_runs_unless_every_scheme_points_here() { + let schemes = vec!["accshift".to_string()]; + assert!(!schemes_need_registration(&schemes, &[true])); + assert!(schemes_need_registration(&schemes, &[false])); + // A failed check arrives as false: fail open. + assert!(schemes_need_registration(&schemes, &[])); + let two = vec!["accshift".to_string(), "other".to_string()]; + assert!(schemes_need_registration(&two, &[true, false])); + assert!(!schemes_need_registration(&two, &[true, true])); +} + +// --------------------------------------------------------------------------- +// The plugin: launch arguments, second launch, open-url, registry + +#[test] +fn only_a_lone_configured_url_argument_is_a_launch_link() { + let lone = handle_cli_arguments(&["accshift.exe", "accshift://switch/steam/a"], SCHEMES); + if cfg!(any(windows, target_os = "linux")) { + assert_eq!(lone.unwrap().as_str(), "accshift://switch/steam/a"); + } else { + assert!(lone.is_none()); + } + assert!(handle_cli_arguments(&["accshift.exe"], SCHEMES).is_none()); + assert!(handle_cli_arguments(&["accshift.exe", "https://example.com"], SCHEMES).is_none()); + assert!(handle_cli_arguments(&["accshift.exe", "not a url"], SCHEMES).is_none()); + assert!(handle_cli_arguments( + &["accshift.exe", "--flag", "accshift://switch/steam/a"], + SCHEMES + ) + .is_none()); +} + +#[test] +fn a_second_launch_logs_forwards_its_link_and_shows_after_boot() { + let launch = second_launch(&["C:\\accshift.exe", "accshift://switch/steam/bob"], true); + assert_eq!(launch.log_source, "backend.single-instance"); + assert_eq!( + launch.log_message, + "Second instance launch redirected to the running app" + ); + assert!(launch.show_window); + assert_eq!( + launch.url.is_some(), + cfg!(any(windows, target_os = "linux")) + ); + assert!(!second_launch(&["C:\\accshift.exe"], false).show_window); + + let urls = [ + Url::parse("accshift://switch/steam/bob").unwrap(), + Url::parse("accshift://switch/riot/x?y").unwrap(), + ]; + let effects = on_open_url(&urls, false); + assert_eq!( + effects.log_details, + "accshift://switch/steam/ accshift://switch/riot/<+extra>" + ); + assert!(effects.track); + assert!(!effects.show_window); +} + +#[derive(Default)] +struct FakeRegistry { + hkcu: BTreeMap<(String, String), String>, + fail_writes: bool, +} + +impl SchemeRegistry for FakeRegistry { + fn set_string(&mut self, key: &str, name: &str, value: &str) -> Result<(), String> { + if self.fail_writes { + return Err("access denied".into()); + } + self.hkcu + .insert((key.to_string(), name.to_string()), value.to_string()); + Ok(()) + } + + fn classes_root_default(&self, key: &str) -> Result, String> { + Ok(self + .hkcu + .get(&(format!("Software\\Classes\\{key}"), String::new())) + .cloned()) + } +} + +const TEST_EXE: &str = "C:\\Program Files\\Accshift\\accshift.exe"; + +#[test] +fn the_registry_entries_are_the_plugin_ones() { + assert_eq!( + registration_entries("accshift", "com.accshift.desktop", TEST_EXE), + [ + RegistryValue { + key: "Software\\Classes\\accshift".into(), + name: String::new(), + value: "URL:com.accshift.desktop protocol".into(), + }, + RegistryValue { + key: "Software\\Classes\\accshift".into(), + name: "URL Protocol".into(), + value: String::new(), + }, + RegistryValue { + key: "Software\\Classes\\accshift\\DefaultIcon".into(), + name: String::new(), + value: format!("{TEST_EXE},0"), + }, + RegistryValue { + key: "Software\\Classes\\accshift\\shell\\open\\command".into(), + name: String::new(), + value: format!("\"{TEST_EXE}\" \"%1\""), + }, + ] + ); + assert_eq!(registered_command("a.exe"), "\"a.exe\" \"%1\""); +} + +#[test] +fn registration_is_skipped_when_it_already_points_here() { + let mut registry = FakeRegistry::default(); + assert!(!is_registered(®istry, "accshift", TEST_EXE).unwrap()); + assert_eq!( + wire_scheme_registration(&mut registry, SCHEMES, "com.accshift.m5test", TEST_EXE), + Ok(true) + ); + assert!(is_registered(®istry, "accshift", TEST_EXE).unwrap()); + assert_eq!(registry.hkcu.len(), 4); + assert_eq!( + wire_scheme_registration(&mut registry, SCHEMES, "com.accshift.m5test", TEST_EXE), + Ok(false) + ); + // Another install path takes the scheme back. + assert_eq!( + wire_scheme_registration( + &mut registry, + SCHEMES, + "com.accshift.m5test", + "D:\\other.exe" + ), + Ok(true) + ); + assert!(is_registered(®istry, "accshift", "D:\\other.exe").unwrap()); + + let mut denied = FakeRegistry { + fail_writes: true, + ..Default::default() + }; + let failure = wire_scheme_registration(&mut denied, SCHEMES, "com.accshift.m5test", TEST_EXE) + .unwrap_err(); + assert_eq!(failure.source, "backend.deep-link"); + assert_eq!(failure.message, "Failed to register accshift:// scheme"); + assert_eq!(failure.details, "access denied"); +} + +/// The real registry, with a throwaway scheme that is never `accshift`, +/// removed at the end. +#[cfg(windows)] +#[test] +fn the_windows_registry_round_trips_a_test_scheme() { + use accshift_app::lifecycle::deep_link::WindowsRegistry; + let scheme = format!("accshift-m5test-{}", std::process::id()); + assert_ne!(scheme, "accshift"); + let exe = "C:\\m5test\\accshift-m5test.exe"; + let mut registry = WindowsRegistry; + struct Cleanup(String); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = windows_registry::CURRENT_USER + .remove_tree(format!("Software\\Classes\\{}", self.0)); + } + } + let _cleanup = Cleanup(scheme.clone()); + assert!(!is_registered(®istry, &scheme, exe).unwrap()); + register_all( + &mut registry, + &[scheme.as_str()], + "com.accshift.m5test", + exe, + ) + .unwrap(); + assert!(is_registered(®istry, &scheme, exe).unwrap()); + assert!(!is_registered(®istry, &scheme, "C:\\elsewhere.exe").unwrap()); + let key = windows_registry::CURRENT_USER + .open(format!("Software\\Classes\\{scheme}")) + .unwrap(); + assert_eq!( + key.get_string("").unwrap(), + "URL:com.accshift.m5test protocol" + ); + assert_eq!(key.get_string("URL Protocol").unwrap(), ""); +} + +// --------------------------------------------------------------------------- +// findAccount and the controller (useDeepLink.test.ts) + +fn account(id: &str, username: &str, display_name: &str) -> PlatformAccount { + PlatformAccount { + id: id.into(), + display_name: display_name.into(), + username: username.into(), + last_login_at_sec: None, + } +} + +#[test] +fn find_account_prefers_the_id_then_a_unique_name() { + let accounts = [ + account("account-1", "alice", "Alice"), + account("account-2", "bob", "Shared"), + account("account-3", "carol", "Shared"), + ]; + assert_eq!( + find_account(&accounts, " ACCOUNT-1 ").unwrap().id, + "account-1" + ); + assert_eq!(find_account(&accounts, "Bob").unwrap().id, "account-2"); + assert_eq!(find_account(&accounts, "alice").unwrap().id, "account-1"); + assert!(find_account(&accounts, "shared").is_none()); + assert!(find_account(&accounts, "nobody").is_none()); +} + +fn settings(enabled: bool) -> AppSettings { + sanitize_settings( + &json!({"deepLinksEnabled": enabled, "enabledPlatforms": ["steam", "riot"]}), + &StoreContext::shipped(), + ) +} + +/// The app as the controller sees it, answering each action like the TS +/// mocks, and recording them. +struct App { + settings: AppSettings, + registry: PlatformRegistry, + active_tab: String, + /// What `getAccounts` returns per tab once loaded. + loaded: BTreeMap>, + visible: Vec, + confirm: bool, + switched: bool, + events: Vec, + boot_ready: bool, + pin_locked: bool, + /// changeTab does not load the new tab (the TS "has not read it yet" case). + lazy_tab_load: bool, +} + +impl App { + fn new(active_tab: &str) -> Self { + let alice = account("account-1", "alice", "Alice"); + Self { + settings: settings(true), + registry: PlatformRegistry::shipped(), + active_tab: active_tab.into(), + loaded: BTreeMap::from([("steam".to_string(), vec![alice.clone()])]), + visible: if active_tab == "steam" { + vec![alice] + } else { + Vec::new() + }, + confirm: true, + switched: true, + events: Vec::new(), + boot_ready: true, + pin_locked: false, + lazy_tab_load: false, + } + } + + fn view(&self) -> DeepLinkView<'_> { + DeepLinkView { + boot_ready: self.boot_ready, + pin_locked: self.pin_locked, + settings: &self.settings, + registry: &self.registry, + runtime_os: RuntimeOs::Windows, + active_tab: &self.active_tab, + loader_loading: false, + accounts: &self.visible, + } + } + + /// Runs one URL to the end. + fn run(&mut self, controller: &mut DeepLinkController, url: &str) { + let now = Instant::now(); + controller.on_urls(&[url], now); + let mut actions = controller.poll(&self.view(), now); + let mut guard = 0; + while let Some(action) = actions.first().cloned() { + actions.remove(0); + guard += 1; + assert!(guard < 50, "the controller loops"); + let answer = match action { + DeepLinkAction::Toast(text) => { + self.events.push(format!("toast:{}", text_key(&text))); + continue; + } + DeepLinkAction::LoadPlatformAccounts(id) => { + self.events.push(format!("loadPlatform:{id}")); + DeepLinkAnswer::PlatformAccounts( + self.loaded.get(&id).cloned().unwrap_or_default(), + ) + } + DeepLinkAction::LoadAccounts => { + self.events.push("loadAccounts".into()); + self.visible = self + .loaded + .get(&self.active_tab) + .cloned() + .unwrap_or_default(); + DeepLinkAnswer::AccountsLoaded + } + DeepLinkAction::Confirm { + account, + platform_name, + } => { + self.events + .push(format!("confirm:{}:{platform_name}", account.id)); + DeepLinkAnswer::Confirmed(self.confirm) + } + DeepLinkAction::ChangeTab(tab) => { + self.events.push(format!("tab:{tab}")); + self.active_tab = tab.clone(); + self.visible = if self.lazy_tab_load { + Vec::new() + } else { + self.loaded.get(&tab).cloned().unwrap_or_default() + }; + DeepLinkAnswer::TabChanged + } + DeepLinkAction::Switch(account) => { + self.events.push(format!("switch:{}", account.id)); + DeepLinkAnswer::Switched(self.switched) + } + }; + let next = controller.answer(answer, &self.view(), now); + actions.extend(next); + } + assert!(!controller.is_busy()); + } +} + +fn text_key(text: &Text) -> String { + format!("{text:?}") +} + +fn started() -> DeepLinkController { + let mut controller = DeepLinkController::new(); + controller.start(); + controller +} + +#[test] +fn success_is_not_announced_when_the_switch_returns_false() { + let mut app = App::new("steam"); + app.switched = false; + app.run(&mut started(), "accshift://switch/steam/account-1"); + assert_eq!(app.events, ["confirm:account-1:Steam", "switch:account-1"]); +} + +#[test] +fn success_is_announced_only_after_a_confirmed_switch() { + let mut app = App::new("steam"); + app.run(&mut started(), "accshift://switch/steam/account-1"); + assert_eq!(app.events.len(), 3); + assert!( + app.events[2].contains("toast.deepLinkSwitched"), + "{:?}", + app.events + ); + assert!(app.events[2].contains("Alice"), "{:?}", app.events); +} + +#[test] +fn a_stopped_controller_drops_links_and_a_restart_listens_again() { + let mut controller = DeepLinkController::new(); + controller.start(); + controller.stop(); + controller.on_urls(&["accshift://switch/steam/account-1"], Instant::now()); + assert!(!controller.is_busy()); + controller.start(); + let mut app = App::new("steam"); + app.run(&mut controller, "accshift://switch/steam/account-1"); + assert_eq!(app.events.len(), 3); + // Links that came before `start` are not replayed. + assert!(!controller.is_busy()); +} + +#[test] +fn it_asks_before_it_changes_the_visible_tab() { + let mut app = App::new("riot"); + app.run(&mut started(), "accshift://switch/steam/account-1"); + assert_eq!( + &app.events[..4], + [ + "loadPlatform:steam", + "confirm:account-1:Steam", + "tab:steam", + "switch:account-1", + ] + ); +} + +#[test] +fn it_loads_the_new_tab_itself_when_the_tab_change_has_not_read_it_yet() { + let mut app = App::new("riot"); + app.lazy_tab_load = true; + app.run(&mut started(), "accshift://switch/steam/account-1"); + assert_eq!( + app.events.iter().filter(|e| *e == "loadAccounts").count(), + 1 + ); + assert!(app.events.contains(&"switch:account-1".to_string())); + assert!(!app + .events + .iter() + .any(|e| e.contains("toast.deepLinkAccountNotFound"))); +} + +#[test] +fn it_leaves_the_tab_alone_when_the_switch_is_declined() { + let mut app = App::new("riot"); + app.confirm = false; + app.run(&mut started(), "accshift://switch/steam/account-1"); + assert_eq!( + app.events, + ["loadPlatform:steam", "confirm:account-1:Steam"] + ); +} + +#[test] +fn the_gates_toast_and_stop() { + let toasts = |app: &mut App, url: &str| { + app.events.clear(); + app.run(&mut started(), url); + app.events.clone() + }; + let mut app = App::new("steam"); + assert!(toasts(&mut app, "accshift://nope")[0].contains("toast.deepLinkInvalid")); + assert!(toasts(&mut app, "accshift://switch/nowhere/a")[0] + .contains("toast.deepLinkUnknownPlatform")); + assert!(toasts(&mut app, "accshift://switch/steam/ghost") + .iter() + .any(|e| e.contains("toast.deepLinkAccountNotFound"))); + + app.settings = sanitize_settings( + &json!({"deepLinksEnabled": true, "enabledPlatforms": ["riot"]}), + &StoreContext::shipped(), + ); + assert!( + toasts(&mut app, "accshift://switch/steam/a")[0].contains("toast.deepLinkPlatformDisabled") + ); + + app.settings = settings(false); + let events = toasts(&mut app, "accshift://switch/steam/a"); + assert!(events[0].contains("toast.deepLinkDisabled"), "{events:?}"); + // Disabled links do not even toast an invalid URL. + assert!(toasts(&mut app, "accshift://nope").is_empty()); + + let mut locked = App::new("steam"); + locked.pin_locked = true; + assert!(toasts(&mut locked, "accshift://switch/steam/account-1").is_empty()); +} + +#[test] +fn a_link_waits_for_boot_and_the_latest_one_queues() { + let mut app = App::new("steam"); + app.boot_ready = false; + let mut controller = started(); + let start = Instant::now(); + controller.on_urls( + &[ + "accshift://switch/steam/first", + "accshift://switch/steam/second", + "accshift://switch/steam/account-1", + ], + start, + ); + assert!(controller.poll(&app.view(), start).is_empty()); + assert!(controller.is_busy()); + // Boot never comes: the URL step goes on after 20 s, the switch step + // gives up 20 s later, then the queued (latest) link runs. + let late = start + BOOT_TIMEOUT + Duration::from_millis(1); + assert!(controller.poll(&app.view(), late).is_empty()); + let later = late + BOOT_TIMEOUT + Duration::from_millis(1); + assert!(controller.poll(&app.view(), later).is_empty()); + app.boot_ready = true; + let actions = controller.poll(&app.view(), later); + assert!( + matches!(&actions[..], [DeepLinkAction::Confirm { account, .. }] if account.id == "account-1"), + "{actions:?}" + ); +} + +// --------------------------------------------------------------------------- +// Single instance + +#[test] +fn the_names_match_the_plugin_for_the_app_identifier() { + // Strings only: no mutex or window is created for the real identifier. + assert_eq!( + windows_names("com.accshift.desktop"), + ( + "com.accshift.desktop-sic".into(), + "com.accshift.desktop-siw".into(), + "com.accshift.desktop-sim".into() + ) + ); + assert_eq!( + dbus_name_and_path("com.accshift.desktop"), + ( + "com.accshift.desktop.SingleInstance".into(), + "/com/accshift/desktop/SingleInstance".into() + ) + ); + assert_eq!( + dbus_name_and_path("my-app.test"), + ( + "my-app.test.SingleInstance".into(), + "/my_app/test/SingleInstance".into() + ) + ); + assert_eq!( + macos_socket_path("com.accshift.desktop"), + std::path::PathBuf::from("/tmp/com_accshift_desktop_si.sock") + ); +} + +#[test] +fn the_windows_payload_round_trips() { + let args = vec![ + "C:\\accshift.exe".to_string(), + "accshift://switch/steam/a".to_string(), + ]; + let payload = windows_payload(&args, "C:\\Users\\me"); + assert_eq!( + payload, + "C:\\Users\\me|C:\\accshift.exe|accshift://switch/steam/a\0" + ); + let (cwd, parsed) = parse_windows_payload(payload.as_bytes()); + assert_eq!(cwd, "C:\\Users\\me"); + assert_eq!(parsed, args); + // A `|` inside an argument splits it, as upstream. + let (_, split) = parse_windows_payload(b"C:\\|a|b|c\0junk"); + assert_eq!(split, ["a", "b", "c"]); +} + +fn test_identifier(tag: &str) -> String { + let id = format!("com.accshift.m5test.{tag}.{}", std::process::id()); + assert_ne!(id, "com.accshift.desktop"); + id +} + +#[cfg(any(windows, target_os = "macos"))] +#[test] +fn a_second_instance_forwards_its_arguments_to_the_first() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + let identifier = test_identifier("inproc"); + let woken = Arc::new(AtomicUsize::new(0)); + let wake = { + let woken = woken.clone(); + Arc::new(move || { + woken.fetch_add(1, Ordering::SeqCst); + }) + }; + let first = + match single_instance::acquire(&identifier, &["first.exe".into()], "C:\\one", Some(wake)) { + Launch::Primary(service) => service, + other => panic!("first launch: {other:?}"), + }; + let args = vec![ + "second.exe".to_string(), + "accshift://switch/steam/bob".to_string(), + ]; + let second = single_instance::acquire(&identifier, &args, "C:\\two", None); + assert!(matches!(second, Launch::Forwarded), "{second:?}"); + let received = first + .receiver() + .recv_timeout(Duration::from_secs(10)) + .expect("forwarded launch"); + assert_eq!( + received, + SecondInstance { + args, + cwd: "C:\\two".into() + } + ); + assert_eq!(woken.load(Ordering::SeqCst), 1); + assert!(first.try_recv().is_none()); + + // Releasing it (the plugin's `destroy`) lets a new primary start. + drop(first); + match single_instance::acquire(&identifier, &["third.exe".into()], "", None) { + Launch::Primary(_) => {} + other => panic!("after release: {other:?}"), + } +} + +/// The other process of [`a_second_process_reaches_the_first`]; it only runs +/// when that test starts it. +#[test] +#[ignore = "child process of a_second_process_reaches_the_first"] +fn single_instance_child() { + let Ok(identifier) = std::env::var("ACCSHIFT_M5_SI_ID") else { + return; + }; + let args = vec![ + "child.exe".to_string(), + "accshift://switch/riot/main%20smurf".to_string(), + ]; + match single_instance::acquire(&identifier, &args, "child-cwd", None) { + Launch::Forwarded => println!("M5-CHILD:forwarded"), + other => println!("M5-CHILD:{other:?}"), + } +} + +#[cfg(any(windows, target_os = "macos"))] +#[test] +fn a_second_process_reaches_the_first() { + let identifier = test_identifier("process"); + let first = match single_instance::acquire(&identifier, &["first.exe".into()], "", None) { + Launch::Primary(service) => service, + other => panic!("first launch: {other:?}"), + }; + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "single_instance_child", + "--ignored", + "--nocapture", + "--test-threads=1", + ]) + .env("ACCSHIFT_M5_SI_ID", &identifier) + .output() + .expect("child test process"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success(), "{stdout}"); + assert!(stdout.contains("M5-CHILD:forwarded"), "{stdout}"); + let received = first + .receiver() + .recv_timeout(Duration::from_secs(10)) + .unwrap(); + assert_eq!( + received.args, + ["child.exe", "accshift://switch/riot/main%20smurf"] + ); + assert_eq!(received.cwd, "child-cwd"); + drop(first); +} + +#[test] +fn the_current_arguments_and_folder_are_read() { + assert!(!single_instance::current_args().is_empty()); + assert!(!single_instance::current_cwd().is_empty()); +} diff --git a/crates/accshift-app/tests/lifecycle_updater.rs b/crates/accshift-app/tests/lifecycle_updater.rs new file mode 100644 index 0000000..f9f1965 --- /dev/null +++ b/crates/accshift-app/tests/lifecycle_updater.rs @@ -0,0 +1,1223 @@ +//! The updater against a local HTTP server on 127.0.0.1: a `latest.json` +//! and an artifact signed by a key generated here, as `tauri signer` does. +//! The install side effects go to a recording host; no installer runs. + +use accshift_app::lifecycle::update_flow::{run_command, ApplyHost, CommandResult, UpdateCommand}; +use accshift_app::lifecycle::updater::{ + bundle_type, bundle_type_from_marker, escape_msi_property_arg, escape_nsis_current_exe_arg, + fallback_targets, installer_for_bundle_type, parse_manifest, updater_arch, updater_os, + verify_signature, BundleType, DownloadEvent, Installer, InstallerHost, RemoteReleaseInner, + Update, Updater, UpdaterConfig, UpdaterError, WindowsUpdateInstallMode, WindowsUpdaterType, + PUBKEY, UPDATER_USER_AGENT, +}; +use base64::Engine; +use semver::Version; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::ffi::{OsStr, OsString}; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::Duration; +use url::Url; + +// --------------------------------------------------------------------------- +// Local HTTP server + +#[derive(Debug, Clone)] +struct Request { + path: String, + headers: HashMap, +} + +type Route = dyn Fn(&str) -> (u16, Vec) + Send + Sync; + +/// One-request-per-connection HTTP/1.1 server; stops on drop. +struct Server { + base: String, + requests: Arc>>, + stop: Arc, + thread: Option>, +} + +impl Server { + fn start(route: impl Fn(&str) -> (u16, Vec) + Send + Sync + 'static) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind 127.0.0.1"); + let base = format!("http://{}", listener.local_addr().unwrap()); + let requests = Arc::new(Mutex::new(Vec::new())); + let stop = Arc::new(AtomicBool::new(false)); + let route: Arc = Arc::new(route); + let thread = { + let requests = requests.clone(); + let stop = stop.clone(); + std::thread::spawn(move || { + for stream in listener.incoming() { + if stop.load(Ordering::SeqCst) { + break; + } + if let Ok(stream) = stream { + serve(stream, &route, &requests); + } + } + }) + }; + Self { + base, + requests, + stop, + thread: Some(thread), + } + } + + fn url(&self, path: &str) -> String { + format!("{}{path}", self.base) + } + + fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } +} + +impl Drop for Server { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + let _ = TcpStream::connect(self.base.trim_start_matches("http://")); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +fn serve(mut stream: TcpStream, route: &Arc, requests: &Mutex>) { + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut raw = Vec::new(); + let mut buffer = [0u8; 4096]; + while !raw.windows(4).any(|window| window == b"\r\n\r\n") { + match stream.read(&mut buffer) { + Ok(0) | Err(_) => return, + Ok(read) => raw.extend_from_slice(&buffer[..read]), + } + } + let text = String::from_utf8_lossy(&raw).to_string(); + let mut lines = text.split("\r\n"); + let path = lines + .next() + .and_then(|line| line.split(' ').nth(1)) + .unwrap_or_default() + .to_string(); + let headers = lines + .take_while(|line| !line.is_empty()) + .filter_map(|line| line.split_once(':')) + .map(|(name, value)| (name.trim().to_lowercase(), value.trim().to_string())) + .collect(); + requests.lock().unwrap().push(Request { + path: path.clone(), + headers, + }); + let (status, body) = route(&path); + let reason = match status { + 200 => "OK", + 204 => "No Content", + 404 => "Not Found", + _ => "Internal Server Error", + }; + let head = if status == 204 { + format!("HTTP/1.1 {status} {reason}\r\nConnection: close\r\n\r\n") + } else { + format!( + "HTTP/1.1 {status} {reason}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + }; + let _ = stream.write_all(head.as_bytes()); + if status != 204 { + let _ = stream.write_all(&body); + } + let _ = stream.flush(); +} + +// --------------------------------------------------------------------------- +// Signing, as `tauri signer sign`: base64 of the key box and of the `.sig`. + +struct TestKey { + pair: minisign::KeyPair, +} + +impl TestKey { + fn new() -> Self { + Self { + pair: minisign::KeyPair::generate_unencrypted_keypair().expect("keypair"), + } + } + + fn pubkey(&self) -> String { + let text = self.pair.pk.to_box().unwrap().to_string(); + base64::engine::general_purpose::STANDARD.encode(text) + } + + fn sign(&self, data: &[u8]) -> String { + let signature = minisign::sign( + Some(&self.pair.pk), + &self.pair.sk, + std::io::Cursor::new(data), + Some("timestamp:0\tfile:accshift-m5test.exe"), + Some("signature from tauri secret key"), + ) + .expect("sign"); + base64::engine::general_purpose::STANDARD.encode(signature.to_string()) + } +} + +/// An NSIS setup as `infer` sees it: `MZ` and anything. +fn fake_setup() -> Vec { + let mut bytes = b"MZ".to_vec(); + bytes.extend((0..200_000u32).map(|i| (i % 251) as u8)); + bytes +} + +fn os_arch() -> (String, String) { + ( + updater_os().unwrap().to_string(), + updater_arch().unwrap().to_string(), + ) +} + +fn config( + server: &Server, + pubkey: &str, + current: &str, + bundle: Option, +) -> UpdaterConfig { + UpdaterConfig { + pubkey: pubkey.to_string(), + endpoints: vec![Url::parse(&server.url("/latest.json")).unwrap()], + install_mode: WindowsUpdateInstallMode::Passive, + installer_args: Vec::new(), + current_version: Version::parse(current).unwrap(), + app_name: "accshift-m5test".into(), + target: None, + timeout: Some(Duration::from_secs(20)), + headers: Vec::new(), + current_exe_args: vec!["accshift.exe".into(), "--flag".into(), "some space".into()], + restart_after_install: true, + bundle_type: bundle, + executable_path: None, + allow_downgrades: false, + } +} + +/// Serves `manifest` at `/latest.json` and `artifact` at `/artifact`. +fn release_server( + manifest: impl Fn(&str) -> Value + Send + Sync + 'static, + artifact: Vec, +) -> Server { + Server::start(move |path| { + if path.starts_with("/latest.json") { + (200, manifest(path).to_string().into_bytes()) + } else if path.starts_with("/artifact") { + (200, artifact.clone()) + } else { + (404, Vec::new()) + } + }) +} + +fn static_manifest(version: &str, base: &str, platforms: &[(&str, &str)]) -> Value { + let platforms: serde_json::Map = platforms + .iter() + .map(|(key, signature)| { + ( + key.to_string(), + json!({"url": format!("{base}/artifact/{key}"), "signature": signature}), + ) + }) + .collect(); + json!({ + "version": version, + "notes": "Bug fixes", + "pub_date": "2026-10-01T12:00:00Z", + "platforms": platforms, + }) +} + +/// A server whose base URL is known before the manifest is built. +fn server_with(build: impl Fn(&str) -> Value + Send + Sync + 'static, artifact: Vec) -> Server { + let base = Arc::new(Mutex::new(String::new())); + let server = { + let base = base.clone(); + release_server(move |_| build(&base.lock().unwrap()), artifact) + }; + *base.lock().unwrap() = server.base.clone(); + server +} + +fn check(config: UpdaterConfig) -> Result, UpdaterError> { + Updater::new(config).expect("updater builds").check() +} + +// --------------------------------------------------------------------------- +// Check and download + +#[test] +fn a_good_signature_is_accepted_and_progress_is_reported() { + let key = TestKey::new(); + let artifact = fake_setup(); + let signature = key.sign(&artifact); + let (os, arch) = os_arch(); + let platform = format!("{os}-{arch}"); + let server = server_with( + move |base| static_manifest("v1.2.0", base, &[(platform.as_str(), signature.as_str())]), + artifact.clone(), + ); + + let update = check(config(&server, &key.pubkey(), "1.0.5", None)) + .unwrap() + .expect("1.2.0 is newer than 1.0.5"); + assert_eq!(update.version, "1.2.0"); + assert_eq!(update.current_version, "1.0.5"); + assert_eq!(update.body.as_deref(), Some("Bug fixes")); + assert_eq!( + update.date_rfc3339().as_deref(), + Some("2026-10-01T12:00:00Z") + ); + assert_eq!(update.target, os); + assert_eq!(update.raw_json["version"], "v1.2.0"); + + let mut events = Vec::new(); + let bytes = update + .download_with_events(|event| events.push(event)) + .unwrap(); + assert_eq!(bytes, artifact); + assert_eq!( + events.first(), + Some(&DownloadEvent::Started { + content_length: Some(artifact.len() as u64) + }) + ); + assert_eq!(events.last(), Some(&DownloadEvent::Finished)); + let received: usize = events + .iter() + .map(|event| match event { + DownloadEvent::Progress { chunk_length } => *chunk_length, + _ => 0, + }) + .sum(); + assert_eq!(received, artifact.len()); + + let requests = server.requests(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].headers["user-agent"], UPDATER_USER_AGENT); + assert_eq!(requests[0].headers["accept"], "application/json"); + assert_eq!(requests[1].headers["accept"], "application/octet-stream"); + assert_eq!(requests[1].path, format!("/artifact/{os}-{arch}")); +} + +#[test] +fn a_bad_signature_is_refused() { + let key = TestKey::new(); + let artifact = fake_setup(); + let signature = key.sign(b"some other file"); + let (os, arch) = os_arch(); + let platform = format!("{os}-{arch}"); + let server = server_with( + move |base| static_manifest("1.2.0", base, &[(platform.as_str(), signature.as_str())]), + artifact, + ); + let update = check(config(&server, &key.pubkey(), "1.0.5", None)) + .unwrap() + .unwrap(); + let error = update.download(|_, _| {}, || {}).unwrap_err(); + assert!(matches!(error, UpdaterError::Minisign(_)), "{error:?}"); +} + +#[test] +fn a_signature_from_another_key_is_refused() { + let signer = TestKey::new(); + let trusted = TestKey::new(); + let artifact = fake_setup(); + let signature = signer.sign(&artifact); + let (os, arch) = os_arch(); + let platform = format!("{os}-{arch}"); + let server = server_with( + move |base| static_manifest("1.2.0", base, &[(platform.as_str(), signature.as_str())]), + artifact, + ); + let update = check(config(&server, &trusted.pubkey(), "1.0.5", None)) + .unwrap() + .unwrap(); + let error = update.download(|_, _| {}, || {}).unwrap_err(); + assert!(matches!(error, UpdaterError::Minisign(_)), "{error:?}"); +} + +#[test] +fn a_missing_or_undecodable_signature_is_refused() { + let key = TestKey::new(); + let (os, arch) = os_arch(); + let platform = format!("{os}-{arch}"); + + // Static shape without `signature`: the manifest does not parse. + let server = server_with( + { + let platform = platform.clone(); + move |base| { + json!({ + "version": "1.2.0", + "platforms": { platform.as_str(): { "url": format!("{base}/artifact") } } + }) + } + }, + fake_setup(), + ); + let error = check(config(&server, &key.pubkey(), "1.0.5", None)).unwrap_err(); + assert!( + matches!(&error, UpdaterError::Serialization(text) if text.contains("missing field `signature`")), + "{error:?}" + ); + + // Dynamic shape without `signature`. + let server = server_with( + |base| json!({"version": "1.2.0", "url": format!("{base}/artifact")}), + fake_setup(), + ); + let error = check(config(&server, &key.pubkey(), "1.0.5", None)).unwrap_err(); + assert_eq!( + error.to_string(), + "the `signature` field was not set on the updater response" + ); + + // An empty signature string: refused at download. + let server = server_with( + move |base| static_manifest("1.2.0", base, &[(platform.as_str(), "")]), + fake_setup(), + ); + let update = check(config(&server, &key.pubkey(), "1.0.5", None)) + .unwrap() + .unwrap(); + let error = update.download(|_, _| {}, || {}).unwrap_err(); + assert!(matches!(error, UpdaterError::Minisign(_)), "{error:?}"); + + // Not base64 at all. + let error = verify_signature(b"data", "%%%", &key.pubkey()).unwrap_err(); + assert!(matches!(error, UpdaterError::Base64(_)), "{error:?}"); +} + +#[test] +fn an_older_or_equal_version_is_ignored() { + let key = TestKey::new(); + let artifact = fake_setup(); + let signature = key.sign(&artifact); + let (os, arch) = os_arch(); + for (remote, current) in [("1.0.5", "1.0.5"), ("1.0.4", "1.0.5"), ("0.9.0", "1.0.5")] { + let platform = format!("{os}-{arch}"); + let signature = signature.clone(); + let server = server_with( + move |base| static_manifest(remote, base, &[(platform.as_str(), signature.as_str())]), + artifact.clone(), + ); + assert!( + check(config(&server, &key.pubkey(), current, None)) + .unwrap() + .is_none(), + "{remote} against {current}" + ); + // `allowDowngrades`: anything different counts. + let mut downgrade = config(&server, &key.pubkey(), current, None); + downgrade.allow_downgrades = true; + assert_eq!(check(downgrade).unwrap().is_some(), remote != current); + } +} + +#[test] +fn a_prerelease_and_a_build_suffix_compare_as_semver() { + let key = TestKey::new(); + let (os, arch) = os_arch(); + let platform = format!("{os}-{arch}"); + let signature = key.sign(b"x"); + let server = server_with( + move |base| { + static_manifest( + "1.1.0-beta.1", + base, + &[(platform.as_str(), signature.as_str())], + ) + }, + Vec::new(), + ); + assert!(check(config(&server, &key.pubkey(), "1.0.5", None)) + .unwrap() + .is_some()); + assert!(check(config(&server, &key.pubkey(), "1.1.0", None)) + .unwrap() + .is_none()); +} + +#[test] +fn the_installer_key_wins_over_the_plain_platform_key() { + let key = TestKey::new(); + let (os, arch) = os_arch(); + let with_installer = format!("{os}-{arch}-nsis"); + let plain = format!("{os}-{arch}"); + let server = server_with( + { + let (with_installer, plain) = (with_installer.clone(), plain.clone()); + move |base| { + static_manifest( + "1.2.0", + base, + &[(with_installer.as_str(), "c2ln"), (plain.as_str(), "c2ln")], + ) + } + }, + Vec::new(), + ); + let update = check(config( + &server, + &key.pubkey(), + "1.0.5", + Some(BundleType::Nsis), + )) + .unwrap() + .unwrap(); + assert_eq!( + update.download_url.path(), + format!("/artifact/{with_installer}") + ); + + // An unpatched binary (no bundle type) asks for the plain key. + let update = check(config(&server, &key.pubkey(), "1.0.5", None)) + .unwrap() + .unwrap(); + assert_eq!(update.download_url.path(), format!("/artifact/{plain}")); + + // An MSI build falls back to the plain key when its own is missing. + let update = check(config( + &server, + &key.pubkey(), + "1.0.5", + Some(BundleType::Msi), + )) + .unwrap() + .unwrap(); + assert_eq!(update.download_url.path(), format!("/artifact/{plain}")); +} + +#[test] +fn every_platform_key_of_the_release_matrix_resolves() { + // The keys `tauri-action` writes into accshift's latest.json. + let keys = [ + "windows-x86_64", + "windows-x86_64-nsis", + "windows-x86_64-msi", + "windows-aarch64-nsis", + "darwin-aarch64", + "darwin-aarch64-app", + "darwin-x86_64", + "darwin-x86_64-app", + "linux-x86_64", + "linux-x86_64-appimage", + "linux-x86_64-deb", + "linux-x86_64-rpm", + ]; + let manifest = static_manifest( + "1.2.0", + "https://example.invalid", + &keys.iter().map(|key| (*key, "c2ln")).collect::>(), + ); + let release = parse_manifest(manifest).unwrap(); + let mut sorted = keys.map(String::from).to_vec(); + sorted.sort(); + assert_eq!(release.platform_keys(), sorted); + for (os, arch, installer, expected) in [ + ( + "windows", + "x86_64", + Some(Installer::Nsis), + "windows-x86_64-nsis", + ), + ( + "windows", + "x86_64", + Some(Installer::Msi), + "windows-x86_64-msi", + ), + ("windows", "x86_64", None, "windows-x86_64"), + ( + "windows", + "aarch64", + Some(Installer::Nsis), + "windows-aarch64-nsis", + ), + ( + "darwin", + "aarch64", + Some(Installer::App), + "darwin-aarch64-app", + ), + ("darwin", "x86_64", None, "darwin-x86_64"), + ( + "linux", + "x86_64", + Some(Installer::AppImage), + "linux-x86_64-appimage", + ), + ("linux", "x86_64", Some(Installer::Deb), "linux-x86_64-deb"), + ("linux", "x86_64", Some(Installer::Rpm), "linux-x86_64-rpm"), + ] { + let targets = fallback_targets(os, arch, installer); + let found = targets + .iter() + .find(|target| release.download_url(target).is_ok()) + .unwrap(); + assert_eq!(found, expected); + assert_eq!( + release.download_url(found).unwrap().as_str(), + format!("https://example.invalid/artifact/{expected}") + ); + } + assert_eq!( + fallback_targets("windows", "x86_64", Some(Installer::Nsis)), + ["windows-x86_64-nsis", "windows-x86_64"] + ); + assert_eq!( + fallback_targets("linux", "riscv64", None), + ["linux-riscv64"] + ); +} + +#[test] +fn a_manifest_without_this_platform_fails_even_when_nothing_is_newer() { + let key = TestKey::new(); + let server = server_with( + |base| static_manifest("1.0.0", base, &[("plan9-mips", "c2ln")]), + Vec::new(), + ); + let (os, arch) = os_arch(); + let error = check(config( + &server, + &key.pubkey(), + "1.0.5", + Some(BundleType::Nsis), + )) + .unwrap_err(); + assert_eq!( + error.to_string(), + format!( + "None of the fallback platforms `[\"{os}-{arch}-nsis\", \"{os}-{arch}\"]` were found in the response `platforms` object" + ) + ); + + let mut custom = config(&server, &key.pubkey(), "1.0.5", None); + custom.target = Some("custom-target".into()); + let error = check(custom).unwrap_err(); + assert_eq!( + error.to_string(), + "the platform `custom-target` was not found in the response `platforms` object" + ); +} + +#[test] +fn the_dynamic_shape_and_the_name_alias_parse() { + let release = parse_manifest(json!({ + "name": "v2.0.0", + "url": "https://example.invalid/setup.exe", + "signature": "c2ln", + })) + .unwrap(); + assert_eq!(release.version, Version::new(2, 0, 0)); + assert!(matches!(release.data, RemoteReleaseInner::Dynamic(_))); + assert_eq!( + release.download_url("anything").unwrap().as_str(), + "https://example.invalid/setup.exe" + ); + assert!(release.platform_keys().is_empty()); + + let error = parse_manifest(json!({"version": "1.0.0", "signature": "c2ln"})).unwrap_err(); + assert_eq!( + error.to_string(), + "the `url` field was not set on the updater response" + ); + let error = parse_manifest(json!({"version": "1.0.0", "pub_date": "yesterday", "url": "https://a.invalid", "signature": "c2ln"})).unwrap_err(); + assert!( + error + .to_string() + .starts_with("invalid value for `pub_date`: "), + "{error}" + ); + let error = + parse_manifest(json!({"version": "one", "url": "https://a.invalid", "signature": "c2ln"})) + .unwrap_err(); + assert!( + error + .to_string() + .contains("unexpected character 'o' while parsing major version number"), + "{error}" + ); + let error = parse_manifest(json!({"url": "https://a.invalid"})).unwrap_err(); + assert!( + error.to_string().contains("missing field `version`"), + "{error}" + ); +} + +#[test] +fn a_204_means_no_update() { + let server = Server::start(|_| (204, Vec::new())); + let key = TestKey::new(); + assert!(check(config(&server, &key.pubkey(), "1.0.5", None)) + .unwrap() + .is_none()); +} + +#[test] +fn a_failing_endpoint_falls_through_to_the_next_one() { + let key = TestKey::new(); + let (os, arch) = os_arch(); + let platform = format!("{os}-{arch}"); + let good = server_with( + move |base| static_manifest("1.2.0", base, &[(platform.as_str(), "c2ln")]), + Vec::new(), + ); + let broken = Server::start(|_| (500, b"oops".to_vec())); + let mut both = config(&good, &key.pubkey(), "1.0.5", None); + both.endpoints = vec![ + Url::parse(&broken.url("/latest.json")).unwrap(), + Url::parse(&good.url("/latest.json")).unwrap(), + ]; + assert!(check(both).unwrap().is_some()); + assert_eq!(broken.requests().len(), 1); + + // Only failing statuses: no last error is kept, so the release is "not found". + let missing = Server::start(|_| (404, Vec::new())); + let error = check(config(&missing, &key.pubkey(), "1.0.5", None)).unwrap_err(); + assert_eq!( + error.to_string(), + "Could not fetch a valid release JSON from the remote" + ); +} + +#[test] +fn an_unreachable_endpoint_is_a_request_error() { + // A port nothing listens on: bind, read the port, close. + let port = { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.local_addr().unwrap().port() + }; + let server = Server::start(|_| (404, Vec::new())); + let key = TestKey::new(); + let mut unreachable = config(&server, &key.pubkey(), "1.0.5", None); + unreachable.endpoints = + vec![Url::parse(&format!("http://127.0.0.1:{port}/latest.json")).unwrap()]; + let error = check(unreachable).unwrap_err(); + assert!( + matches!(&error, UpdaterError::Reqwest(text) if text.contains("error sending request")), + "{error:?}" + ); +} + +#[test] +fn a_failed_download_reports_its_status() { + let key = TestKey::new(); + let (os, arch) = os_arch(); + let platform = format!("{os}-{arch}"); + let base = Arc::new(Mutex::new(String::new())); + let server = { + let base = base.clone(); + Server::start(move |path| { + if path.starts_with("/latest.json") { + let manifest = static_manifest( + "1.2.0", + &base.lock().unwrap(), + &[(platform.as_str(), "c2ln")], + ); + (200, manifest.to_string().into_bytes()) + } else { + (500, Vec::new()) + } + }) + }; + *base.lock().unwrap() = server.base.clone(); + let update = check(config(&server, &key.pubkey(), "1.0.5", None)) + .unwrap() + .unwrap(); + let error = update.download(|_, _| {}, || {}).unwrap_err(); + assert_eq!( + error.to_string(), + "`Download request failed with status: 500 Internal Server Error`" + ); +} + +#[test] +fn the_endpoint_variables_are_replaced_raw_and_encoded() { + let server = Server::start(|_| (204, Vec::new())); + let key = TestKey::new(); + let mut templated = config( + &server, + &key.pubkey(), + "1.0.5+build.7", + Some(BundleType::Nsis), + ); + templated.endpoints = vec![Url::parse( + &server.url("/{{target}}/{{arch}}/{{bundle_type}}/latest.json?v={{current_version}}"), + ) + .unwrap()]; + // The path braces are percent-encoded by the URL parser, the query ones are not. + assert!(templated.endpoints[0] + .as_str() + .contains("%7B%7Btarget%7D%7D")); + assert!(check(templated).unwrap().is_none()); + let (os, arch) = os_arch(); + assert_eq!( + server.requests()[0].path, + format!("/{os}/{arch}/nsis/latest.json?v=1.0.5%2Bbuild.7") + ); + + let updater = Updater::new(config(&server, &key.pubkey(), "1.0.5", None)).unwrap(); + let url = updater + .endpoint_url( + &Url::parse("https://example.invalid/{{bundle_type}}/x").unwrap(), + "windows", + ) + .unwrap(); + assert_eq!(url.as_str(), "https://example.invalid/unknown/x"); +} + +#[test] +fn the_config_is_validated_and_matches_tauri_conf() { + let accshift = UpdaterConfig::accshift(); + assert_eq!( + accshift.endpoints[0].as_str(), + "https://github.com/klNuno/accshift/releases/latest/download/latest.json" + ); + assert_eq!(accshift.pubkey, PUBKEY); + assert_eq!(accshift.install_mode, WindowsUpdateInstallMode::Passive); + // The shipped key decodes: a wrong signature fails on the signature, not the key. + let key = TestKey::new(); + let error = verify_signature(b"data", &key.sign(b"data"), PUBKEY).unwrap_err(); + assert!(matches!(error, UpdaterError::Minisign(_)), "{error:?}"); + + let mut empty = accshift.clone(); + empty.endpoints.clear(); + assert!(matches!( + Updater::new(empty).err(), + Some(UpdaterError::EmptyEndpoints) + )); +} + +#[test] +fn the_bundle_marker_maps_to_the_installer() { + for (marker, bundle, installer) in [ + ( + "__TAURI_BUNDLE_TYPE_VAR_NSS", + BundleType::Nsis, + Installer::Nsis, + ), + ( + "__TAURI_BUNDLE_TYPE_VAR_MSI", + BundleType::Msi, + Installer::Msi, + ), + ( + "__TAURI_BUNDLE_TYPE_VAR_DEB", + BundleType::Deb, + Installer::Deb, + ), + ( + "__TAURI_BUNDLE_TYPE_VAR_RPM", + BundleType::Rpm, + Installer::Rpm, + ), + ( + "__TAURI_BUNDLE_TYPE_VAR_APP", + BundleType::AppImage, + Installer::AppImage, + ), + ] { + assert_eq!(bundle_type_from_marker(marker), Some(bundle)); + assert_eq!(installer_for_bundle_type(Some(bundle)), Some(installer)); + } + let unpatched = bundle_type_from_marker("__TAURI_BUNDLE_TYPE_VAR_UNK"); + assert_eq!( + unpatched, + cfg!(target_os = "macos").then_some(BundleType::App) + ); + // A test binary is never patched by the bundler. + assert_eq!(bundle_type(), unpatched); +} + +// --------------------------------------------------------------------------- +// Install + +#[derive(Default)] +struct RecordingHost { + calls: Mutex>, + launched: Mutex>, +} + +impl InstallerHost for RecordingHost { + fn before_exit(&self) { + self.calls.lock().unwrap().push("before_exit".into()); + } + + fn shell_execute(&self, file: &OsStr, parameters: &OsStr) -> std::io::Result<()> { + self.calls.lock().unwrap().push("shell_execute".into()); + let exists = std::path::Path::new(file).exists(); + *self.launched.lock().unwrap() = Some((file.to_owned(), parameters.to_owned(), exists)); + Ok(()) + } + + fn exit(&self, code: i32) { + self.calls.lock().unwrap().push(format!("exit({code})")); + } +} + +fn offline_update(version: &str, args: &[&str], mode: WindowsUpdateInstallMode) -> Update { + let server = Server::start(|_| (404, Vec::new())); + let key = TestKey::new(); + let mut config = config(&server, &key.pubkey(), "1.0.5", Some(BundleType::Nsis)); + config.install_mode = mode; + config.current_exe_args = args.iter().map(OsString::from).collect(); + let (os, arch) = os_arch(); + let platform = format!("{os}-{arch}"); + let release = parse_manifest(static_manifest( + version, + "https://example.invalid", + &[(platform.as_str(), "c2ln")], + )) + .unwrap(); + let updater = Updater::new(config.clone()).unwrap(); + let (url, signature) = updater.get_urls(&release, None).unwrap(); + Update { + body: None, + current_version: "1.0.5".into(), + version: version.into(), + date: None, + target: os, + download_url: url.clone(), + signature: signature.clone(), + raw_json: Value::Null, + timeout: None, + headers: Vec::new(), + install: accshift_app::lifecycle::updater::InstallContext { + app_name: config.app_name, + install_mode: config.install_mode, + installer_args: config.installer_args, + current_exe_args: config.current_exe_args, + restart_after_install: config.restart_after_install, + bundle_type: config.bundle_type, + executable_path: None, + pubkey: config.pubkey, + }, + } +} + +#[cfg(windows)] +#[test] +fn the_windows_install_launches_the_nsis_setup_then_exits() { + let update = offline_update( + "1.2.0", + &["accshift.exe", "--flag", "some space", "/start"], + WindowsUpdateInstallMode::Passive, + ); + let host = RecordingHost::default(); + update.install(&fake_setup(), &host).unwrap(); + assert_eq!( + *host.calls.lock().unwrap(), + ["before_exit", "shell_execute", "exit(0)"] + ); + let (file, parameters, existed) = host.launched.lock().unwrap().take().unwrap(); + let file = std::path::PathBuf::from(file); + assert!(existed, "the setup is on disk when it is launched"); + assert_eq!( + file.file_name().unwrap(), + "accshift-m5test-1.2.0-installer.exe" + ); + let folder = file.parent().unwrap().to_path_buf(); + assert!(folder + .file_name() + .unwrap() + .to_string_lossy() + .starts_with("accshift-m5test-1.2.0-updater-")); + assert_eq!( + parameters, + "/P /UPDATE /R /ARGS --flag \"some space\" \"/start\"" + ); + // The kept folder (upstream keeps it for the setup); removed here. + std::fs::remove_dir_all(folder).unwrap(); +} + +#[cfg(windows)] +#[test] +fn the_windows_install_takes_the_setup_out_of_a_zip() { + let update = offline_update("1.3.0", &["accshift.exe"], WindowsUpdateInstallMode::Quiet); + let mut zipped = std::io::Cursor::new(Vec::new()); + { + let mut writer = zip::ZipWriter::new(&mut zipped); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + writer + .start_file("Accshift_1.3.0_x64-setup.exe", options) + .unwrap(); + writer.write_all(&fake_setup()).unwrap(); + writer.finish().unwrap(); + } + let host = RecordingHost::default(); + update.install(zipped.get_ref(), &host).unwrap(); + let (file, parameters, existed) = host.launched.lock().unwrap().take().unwrap(); + let file = std::path::PathBuf::from(file); + assert!(existed); + assert_eq!(file.file_name().unwrap(), "Accshift_1.3.0_x64-setup.exe"); + assert_eq!(parameters, "/S /UPDATE /R /ARGS"); + std::fs::remove_dir_all(file.parent().unwrap()).unwrap(); +} + +#[test] +fn an_unknown_payload_is_refused_before_anything_runs() { + let update = offline_update( + "1.2.0", + &["accshift.exe"], + WindowsUpdateInstallMode::Passive, + ); + let host = RecordingHost::default(); + let error = update.extract(b"not an installer").unwrap_err(); + assert_eq!(error.to_string(), "invalid updater binary format"); + if cfg!(windows) { + assert!(update.install(b"not an installer", &host).is_err()); + assert!(host.calls.lock().unwrap().is_empty()); + } +} + +#[test] +fn the_installer_command_lines_match_the_plugin() { + let args = ["accshift.exe", "--flag", "some space"]; + let nsis = WindowsUpdaterType::nsis("C:\\t\\setup.exe".into(), None); + let msi = WindowsUpdaterType::msi("C:\\t\\Accshift 1.2.0.msi".into(), None); + assert_eq!( + msi.path(), + std::path::Path::new("\"C:\\t\\Accshift 1.2.0.msi\"") + ); + + let passive = offline_update("1.2.0", &args, WindowsUpdateInstallMode::Passive); + assert_eq!( + passive.updater_parameters(&nsis), + "/P /UPDATE /R /ARGS --flag \"some space\"" + ); + assert_eq!( + passive.updater_parameters(&msi), + "/i \"C:\\t\\Accshift 1.2.0.msi\" /passive /promptrestart AUTOLAUNCHAPP=True LAUNCHAPPARGS=\"--flag \"\"some space\"\"\"" + ); + let basic = offline_update("1.2.0", &args, WindowsUpdateInstallMode::BasicUi); + assert_eq!( + basic.updater_parameters(&nsis), + "/UPDATE /ARGS --flag \"some space\"" + ); + let mut quiet = offline_update("1.2.0", &args, WindowsUpdateInstallMode::Quiet); + quiet.install.restart_after_install = false; + quiet.install.installer_args = vec!["/D=C:\\Apps".into()]; + assert_eq!(quiet.updater_parameters(&nsis), "/S /UPDATE /D=C:\\Apps"); + assert_eq!( + quiet.updater_parameters(&msi), + "/i \"C:\\t\\Accshift 1.2.0.msi\" /quiet /promptrestart /D=C:\\Apps" + ); + + use accshift_app::lifecycle::updater::windows_installer_file; + assert_eq!(windows_installer_file(&nsis, None), "C:\\t\\setup.exe"); + assert_eq!( + windows_installer_file(&msi, Some("C:\\Windows".into())), + "C:\\Windows\\System32\\msiexec.exe" + ); + assert_eq!(windows_installer_file(&msi, None), "msiexec.exe"); +} + +/// updater.rs `it_escapes_correctly_for_msi`. +#[test] +fn msi_arguments_escape_as_the_plugin_tests() { + let cases = [ + "something", + "--flag", + "--empty=", + "--arg=value", + "some space", + "--arg value", + "--arg=unwrapped space", + "--arg=\"wrapped\"", + "--arg=\"wrapped space\"", + "--arg=midword\"wrapped space\"", + "", + ]; + let cases_escaped = [ + "something", + "--flag", + "--empty=", + "--arg=value", + "\"\"some space\"\"", + "\"\"--arg value\"\"", + "--arg=\"\"unwrapped space\"\"", + r#"--arg=""""""wrapped"""""""#, + r#"--arg=""""""wrapped space"""""""#, + r#"--arg=""midword""""wrapped space"""""""#, + "\"\"\"\"", + ]; + for (orig, escaped) in cases.iter().zip(cases_escaped) { + assert_eq!(escape_msi_property_arg(orig), escaped); + } +} + +/// updater.rs `it_escapes_correctly_for_nsis`. +#[test] +fn nsis_arguments_escape_as_the_plugin_tests() { + let cases = [ + "something", + "--flag", + "--empty=", + "--arg=value", + "some space", + "--arg value", + "--arg=unwrapped space", + "--arg=\"wrapped\"", + "--arg=\"wrapped space\"", + "--arg=midword\"wrapped space\"", + "", + ]; + let cases_escaped = [ + "something", + "--flag", + "--empty=", + "--arg=value", + "\"some space\"", + "\"--arg value\"", + "\"--arg=unwrapped space\"", + "--arg=\\\"wrapped\\\"", + "\"--arg=\\\"wrapped space\\\"\"", + "\"--arg=midword\\\"wrapped space\\\"\"", + "\"\"", + ]; + for (orig, escaped) in cases.iter().zip(cases_escaped) { + assert_eq!(escape_nsis_current_exe_arg(OsStr::new(orig)), escaped); + } + // A trailing backslash inside quotes is doubled, `/` forces quotes. + assert_eq!(escape_nsis_current_exe_arg("C:\\a b\\"), "\"C:\\a b\\\\\""); + assert_eq!(escape_nsis_current_exe_arg("/S"), "\"/S\""); +} + +// --------------------------------------------------------------------------- +// run_command: the update flow on the real updater + +struct ApplyRecorder { + inner: RecordingHost, + flush: Result<(), String>, + relaunch: Result<(), String>, +} + +impl InstallerHost for ApplyRecorder { + fn before_exit(&self) { + self.inner.before_exit(); + } + + fn shell_execute(&self, file: &OsStr, parameters: &OsStr) -> std::io::Result<()> { + self.inner.shell_execute(file, parameters) + } + + fn exit(&self, code: i32) { + self.inner.exit(code); + } +} + +impl ApplyHost for ApplyRecorder { + fn before_relaunch(&self) -> Result<(), String> { + self.inner + .calls + .lock() + .unwrap() + .push("before_relaunch".into()); + self.flush.clone() + } + + fn relaunch(&self) -> Result<(), String> { + self.inner.calls.lock().unwrap().push("relaunch".into()); + self.relaunch.clone() + } +} + +#[test] +fn run_command_drives_the_real_updater_and_the_host() { + let key = TestKey::new(); + let artifact = fake_setup(); + let signature = key.sign(&artifact); + let (os, arch) = os_arch(); + let platform = format!("{os}-{arch}"); + let server = server_with( + move |base| static_manifest("1.2.0", base, &[(platform.as_str(), signature.as_str())]), + artifact.clone(), + ); + let updater = Updater::new(config(&server, &key.pubkey(), "1.0.5", None)).unwrap(); + let host = ApplyRecorder { + inner: RecordingHost::default(), + flush: Err("settings flush failed".into()), + relaunch: Err("spawn failed".into()), + }; + + let Some(CommandResult::Checked(checked)) = run_command(&updater, UpdateCommand::Check, &host) + else { + panic!("check result"); + }; + let update = (*checked).unwrap().unwrap(); + assert_eq!(update.version, "1.2.0"); + + let Some(CommandResult::Downloaded(bytes)) = + run_command(&updater, UpdateCommand::Download(update.clone()), &host) + else { + panic!("download result"); + }; + assert_eq!(bytes.unwrap(), artifact); + + // A failed flush stops before the installer. + let Some(CommandResult::Installed(installed)) = run_command( + &updater, + UpdateCommand::Install { + update, + bytes: artifact, + }, + &host, + ) else { + panic!("install result"); + }; + assert_eq!(installed, Err("settings flush failed".to_string())); + assert_eq!(*host.inner.calls.lock().unwrap(), ["before_relaunch"]); + + let Some(CommandResult::RelaunchFailed(error)) = + run_command(&updater, UpdateCommand::Relaunch, &host) + else { + panic!("relaunch result"); + }; + assert_eq!(error, "spawn failed"); +} + +/// Acceptance check against the real endpoint of tauri.conf.json: one GET of +/// `latest.json`, no download, no install. Ignored; runs only with +/// `ACCSHIFT_M5_REAL_ENDPOINT=1 cargo test --test lifecycle_updater -- --ignored real_endpoint`. +#[test] +#[ignore = "network: the real release manifest"] +fn real_endpoint_manifest_reads() { + if std::env::var("ACCSHIFT_M5_REAL_ENDPOINT").as_deref() != Ok("1") { + return; + } + let mut config = UpdaterConfig::accshift(); + config.timeout = Some(Duration::from_secs(30)); + let updater = Updater::new(config).unwrap(); + let release = updater.fetch_release().unwrap().expect("a release"); + println!("M5-REAL version={}", release.version); + println!("M5-REAL pub_date={:?}", release.pub_date); + println!("M5-REAL platforms={:?}", release.platform_keys()); + let current = Version::parse(accshift_app::lifecycle::updater::APP_VERSION).unwrap(); + println!("M5-REAL newer_than_{current}={}", release.version > current); + match updater.get_urls(&release, Some(Installer::Nsis)) { + Ok((url, signature)) => { + println!("M5-REAL nsis_url={url} signature_chars={}", signature.len()) + } + Err(error) => println!("M5-REAL nsis_error={error}"), + } +} From cd8649f2ad5a35f6f05cc16a19b8a593203bc2e2 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:34:18 +0200 Subject: [PATCH 030/106] feat(app): port the boot payload cache and the remaining telemetry calls --- crates/accshift-app/src/lifecycle/boot.rs | 63 ++++++++++++++++++++- crates/accshift-app/tests/lifecycle_flow.rs | 52 +++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/crates/accshift-app/src/lifecycle/boot.rs b/crates/accshift-app/src/lifecycle/boot.rs index 3d02a59..5fc16b9 100644 --- a/crates/accshift-app/src/lifecycle/boot.rs +++ b/crates/accshift-app/src/lifecycle/boot.rs @@ -4,8 +4,11 @@ //! close request of `src/lib/app/useDocumentListeners.ts`. use crate::backend::Backend; +use crate::error::BackendResult; use crate::js; -use crate::types::SettingsSnapshot; +use crate::platforms::descriptors::PlatformDescriptor; +use crate::platforms::PlatformRegistry; +use crate::types::{BootPayload, SettingsSnapshot}; use serde_json::Value; use std::collections::BTreeMap; use std::time::{Duration, Instant}; @@ -172,6 +175,64 @@ pub fn log_app_event( let _ = backend.log_app_event(level, source, message, details.as_deref()); } +/// `bootPayload.ts`: `fetchBootPayload` asks once (`get_boot_payload`) and +/// fills the registry with the user platforms; `getBootPayload` reads the +/// cached answer. The document-side prefetch has no egui equivalent. +#[derive(Debug, Default)] +pub struct BootPayloadCache { + payload: Option, +} + +impl BootPayloadCache { + /// `fetchBootPayload`. A later call asks again, as TS. + pub fn fetch( + &mut self, + backend: &dyn Backend, + registry: &mut PlatformRegistry, + ) -> BackendResult<&BootPayload> { + let payload = backend.get_boot_payload()?; + let descriptors: Vec = payload + .user_platforms + .loaded + .iter() + .filter_map(PlatformDescriptor::from_core) + .collect(); + registry.register_user_platforms(&descriptors); + Ok(self.payload.insert(payload)) + } + + /// `getBootPayload`. + pub fn get(&self) -> Option<&BootPayload> { + self.payload.as_ref() + } +} + +/// telemetryClient.ts `trackAccountAddStarted`, fire and forget (the mock +/// rejects every `telemetry_` call; the error is dropped, as TS). +pub fn track_account_add_started(backend: &dyn Backend, platform_id: &str) { + let _ = backend.telemetry_track_account_add_started(platform_id); +} + +/// telemetryClient.ts `trackAccountAddCancelled`. +pub fn track_account_add_cancelled(backend: &dyn Backend, platform_id: &str) { + let _ = backend.telemetry_track_account_add_cancelled(platform_id); +} + +/// telemetryClient.ts `trackAccountAdded`. +pub fn track_account_added(backend: &dyn Backend, platform_id: &str) { + let _ = backend.telemetry_track_account_added(platform_id); +} + +/// telemetryClient.ts `trackOperationFailed`. +pub fn track_operation_failed( + backend: &dyn Backend, + operation: &str, + error_code: &str, + platform_id: Option<&str>, +) { + let _ = backend.telemetry_track_operation_failed(operation, error_code, platform_id); +} + /// telemetryClient.ts `trackUpdate`, fire and forget. pub fn track_update( backend: &dyn Backend, diff --git a/crates/accshift-app/tests/lifecycle_flow.rs b/crates/accshift-app/tests/lifecycle_flow.rs index 4161f5a..3a24cfd 100644 --- a/crates/accshift-app/tests/lifecycle_flow.rs +++ b/crates/accshift-app/tests/lifecycle_flow.rs @@ -880,3 +880,55 @@ fn relaunch_releases_spawns_with_the_same_arguments_then_exits() { assert!(binary.is_file()); assert_eq!(process::relaunch_program(&binary), binary); } + +#[test] +fn the_boot_payload_is_fetched_cached_and_fills_the_registry() { + use accshift_app::lifecycle::boot::BootPayloadCache; + let backend = support::scripted("demo"); + let mut registry = PlatformRegistry::shipped(); + let mut cache = BootPayloadCache::default(); + assert!(cache.get().is_none()); + let runtime_os = cache + .fetch(&backend, &mut registry) + .unwrap() + .runtime_os + .clone(); + assert!(!runtime_os.is_empty()); + assert_eq!(cache.get().unwrap().runtime_os, runtime_os); + assert_eq!(backend.calls("get_boot_payload").len(), 1); + // Every loaded user platform is in the registry afterwards. + for descriptor in &cache.get().unwrap().user_platforms.loaded { + let id = serde_json::to_value(descriptor).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + assert!(registry.definition(&id).is_some(), "{id}"); + } + // A failing call keeps the earlier answer. + backend.push("get_boot_payload", Err("boom")); + assert!(cache.fetch(&backend, &mut registry).is_err()); + assert!(cache.get().is_some()); +} + +#[test] +fn the_other_telemetry_calls_are_fire_and_forget() { + use accshift_app::lifecycle::boot::{ + track_account_add_cancelled, track_account_add_started, track_account_added, + track_operation_failed, + }; + let backend = support::scripted("demo"); + track_account_add_started(&backend, "steam"); + track_account_add_cancelled(&backend, "steam"); + track_account_added(&backend, "riot"); + track_operation_failed(&backend, "switch", "timeout", Some("steam")); + assert_eq!( + backend.calls("telemetry_track_account_add_started").len(), + 1 + ); + assert_eq!( + backend.calls("telemetry_track_account_add_cancelled").len(), + 1 + ); + assert_eq!(backend.calls("telemetry_track_account_added").len(), 1); + assert_eq!(backend.calls("telemetry_track_operation_failed").len(), 1); +} From d8591b1947f174ce1f084b374346edfdbc768a2c Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:55:49 +0200 Subject: [PATCH 031/106] feat(egui): rasterize glyphs with DirectWrite like Skia --- crates/accshift-egui/Cargo.lock | 3 +- crates/accshift-egui/Cargo.toml | 14 + .../parity/fragments/glyph-check.html | 81 + .../parity/scenes/glyph-check-100.json | 22 + .../parity/scenes/glyph-check-150.json | 22 + crates/accshift-egui/src/ui/text.rs | 38 +- crates/accshift-egui/src/ui/text/dwrite.rs | 537 ++++ crates/accshift-egui/src/ui/text/gamma.rs | 41 +- .../accshift-egui/src/ui/text/glyph_check.rs | 154 ++ crates/accshift-egui/vendor/epaint/Cargo.toml | 345 +++ crates/accshift-egui/vendor/epaint/README.md | 11 + .../accshift-egui/vendor/epaint/src/brush.rs | 19 + .../accshift-egui/vendor/epaint/src/color.rs | 49 + .../vendor/epaint/src/corner_radius.rs | 250 ++ .../vendor/epaint/src/corner_radius_f32.rs | 236 ++ .../vendor/epaint/src/direction.rs | 27 + .../accshift-egui/vendor/epaint/src/image.rs | 496 ++++ crates/accshift-egui/vendor/epaint/src/lib.rs | 161 ++ .../accshift-egui/vendor/epaint/src/margin.rs | 280 ++ .../vendor/epaint/src/margin_f32.rs | 287 ++ .../accshift-egui/vendor/epaint/src/mesh.rs | 359 +++ .../accshift-egui/vendor/epaint/src/mutex.rs | 277 ++ .../accshift-egui/vendor/epaint/src/shadow.rs | 85 + .../vendor/epaint/src/shape_transform.rs | 136 + .../vendor/epaint/src/shapes/bezier_shape.rs | 1137 ++++++++ .../vendor/epaint/src/shapes/circle_shape.rs | 52 + .../vendor/epaint/src/shapes/ellipse_shape.rs | 78 + .../vendor/epaint/src/shapes/mod.rs | 19 + .../epaint/src/shapes/paint_callback.rs | 104 + .../vendor/epaint/src/shapes/path_shape.rs | 81 + .../vendor/epaint/src/shapes/rect_shape.rs | 223 ++ .../vendor/epaint/src/shapes/shape.rs | 578 ++++ .../vendor/epaint/src/shapes/text_shape.rs | 213 ++ .../accshift-egui/vendor/epaint/src/stats.rs | 244 ++ .../accshift-egui/vendor/epaint/src/stroke.rs | 243 ++ .../vendor/epaint/src/tessellator.rs | 2421 +++++++++++++++++ .../vendor/epaint/src/text/cursor.rs | 111 + .../vendor/epaint/src/text/font.rs | 1013 +++++++ .../vendor/epaint/src/text/fonts.rs | 1541 +++++++++++ .../vendor/epaint/src/text/index.rs | 202 ++ .../vendor/epaint/src/text/mod.rs | 67 + .../vendor/epaint/src/text/text_layout.rs | 1938 +++++++++++++ .../epaint/src/text/text_layout_types.rs | 1452 ++++++++++ .../vendor/epaint/src/texture_atlas.rs | 279 ++ .../vendor/epaint/src/texture_handle.rs | 138 + .../vendor/epaint/src/textures.rs | 380 +++ .../vendor/epaint/src/util/mod.rs | 12 + .../vendor/epaint/src/viewport.rs | 54 + 48 files changed, 16489 insertions(+), 21 deletions(-) create mode 100644 crates/accshift-egui/parity/fragments/glyph-check.html create mode 100644 crates/accshift-egui/parity/scenes/glyph-check-100.json create mode 100644 crates/accshift-egui/parity/scenes/glyph-check-150.json create mode 100644 crates/accshift-egui/src/ui/text/dwrite.rs create mode 100644 crates/accshift-egui/src/ui/text/glyph_check.rs create mode 100644 crates/accshift-egui/vendor/epaint/Cargo.toml create mode 100644 crates/accshift-egui/vendor/epaint/README.md create mode 100644 crates/accshift-egui/vendor/epaint/src/brush.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/color.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/corner_radius.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/corner_radius_f32.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/direction.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/image.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/lib.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/margin.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/margin_f32.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/mesh.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/mutex.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/shadow.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/shape_transform.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/shapes/bezier_shape.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/shapes/circle_shape.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/shapes/ellipse_shape.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/shapes/mod.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/shapes/paint_callback.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/shapes/path_shape.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/shapes/rect_shape.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/shapes/shape.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/shapes/text_shape.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/stats.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/stroke.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/tessellator.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/text/cursor.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/text/font.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/text/fonts.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/text/index.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/text/mod.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/text/text_layout.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/text/text_layout_types.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/texture_atlas.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/texture_handle.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/textures.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/util/mod.rs create mode 100644 crates/accshift-egui/vendor/epaint/src/viewport.rs diff --git a/crates/accshift-egui/Cargo.lock b/crates/accshift-egui/Cargo.lock index fa5ed6d..15c2f9d 100644 --- a/crates/accshift-egui/Cargo.lock +++ b/crates/accshift-egui/Cargo.lock @@ -176,6 +176,7 @@ dependencies = [ "skrifa", "softbuffer", "window-vibrancy", + "windows", "windows-sys 0.61.2", ] @@ -1292,8 +1293,6 @@ dependencies = [ [[package]] name = "epaint" version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" dependencies = [ "ahash", "bytemuck", diff --git a/crates/accshift-egui/Cargo.toml b/crates/accshift-egui/Cargo.toml index 79d7cc4..80c78fb 100644 --- a/crates/accshift-egui/Cargo.toml +++ b/crates/accshift-egui/Cargo.toml @@ -79,6 +79,20 @@ windows-sys = { version = "0.61", features = [ "Win32_UI_Accessibility", "Win32_UI_WindowsAndMessaging", ] } +# DirectWrite glyph coverage (ui::text::dwrite): COM interfaces, which +# windows-sys does not bind. Same version accesskit_windows and sysinfo +# already pull in, so no second binding version. +windows = { version = "0.62", default-features = false, features = [ + "Win32_Foundation", + "Win32_Graphics_DirectWrite", +] } + +# epaint with one patch: a font can supply its glyph coverage +# (FontData::rasterizer), so ui::text rasterizes with DirectWrite like +# WebView2. See the header of vendor/epaint/Cargo.toml and +# vendor/epaint/src/text/font.rs. +[patch.crates-io] +epaint = { path = "vendor/epaint" } # The root workspace profile, except the size: a smaller exe reaches `main` # sooner (build.rs), so everything is built for size but the per-frame hot diff --git a/crates/accshift-egui/parity/fragments/glyph-check.html b/crates/accshift-egui/parity/fragments/glyph-check.html new file mode 100644 index 0000000..0beb2e6 --- /dev/null +++ b/crates/accshift-egui/parity/fragments/glyph-check.html @@ -0,0 +1,81 @@ + +

+ + +
+

+ W e g h С м н к 账 号 设 置 +

+

+ W e g h С м н к 账 号 设 置 +

+

+ W e g h С м н к 账 号 设 置 +

+

+ W e g h С м н к 账 号 设 置 +

+
diff --git a/crates/accshift-egui/parity/scenes/glyph-check-100.json b/crates/accshift-egui/parity/scenes/glyph-check-100.json new file mode 100644 index 0000000..a3f90ee --- /dev/null +++ b/crates/accshift-egui/parity/scenes/glyph-check-100.json @@ -0,0 +1,22 @@ +{ + "id": "glyph-check-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 400, + "height": 130, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/glyph-check.html", + "probe": true, + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/glyph-check-150.json b/crates/accshift-egui/parity/scenes/glyph-check-150.json new file mode 100644 index 0000000..d3b652f --- /dev/null +++ b/crates/accshift-egui/parity/scenes/glyph-check-150.json @@ -0,0 +1,22 @@ +{ + "id": "glyph-check-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 400, + "height": 130, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "html": "../fragments/glyph-check.html", + "probe": true, + "steps": [ + { + "wait": 300 + }, + { + "shot": "frame" + } + ] +} diff --git a/crates/accshift-egui/src/ui/text.rs b/crates/accshift-egui/src/ui/text.rs index eeaf14a..99181ff 100644 --- a/crates/accshift-egui/src/ui/text.rs +++ b/crates/accshift-egui/src/ui/text.rs @@ -19,8 +19,12 @@ //! `typography-*` scenes (the probe in `parity/ref.mjs` records the face, line //! box and baseline of every line). +#[cfg(windows)] +pub mod dwrite; pub mod faces; pub mod gamma; +#[cfg(all(test, windows))] +mod glyph_check; use std::collections::{BTreeMap, HashMap}; use std::path::PathBuf; @@ -428,6 +432,8 @@ pub fn layout(ctx: &Context, text: &str, style: &TextStyle, rect: Rect) -> Optio let row = Arc::make_mut(&mut placed.row); // The pen in device px: egui puts every CJK glyph on a whole pixel // (no sub-pixel bins for them), Skia on a quarter pixel like the rest. + // With a glyph rasterizer the vendored epaint bins them already. + let bin_han = !glyph_rasterizer(); let mut pen = if i == 0 { lead } else { 0.0 }; for (k, glyph) in row.glyphs.iter_mut().enumerate() { if k > 0 { @@ -435,7 +441,7 @@ pub fn layout(ctx: &Context, text: &str, style: &TextStyle, rect: Rect) -> Optio } let exact = pen; pen += glyph.advance_width * ppp; - let dx = if is_han(glyph.chr) { + let dx = if bin_han && is_han(glyph.chr) { ((exact * 4.0).round() / 4.0 - glyph.pos.x * ppp) / ppp } else { 0.0 @@ -502,6 +508,14 @@ pub fn ready(ctx: &Context, style: &TextStyle) -> bool { .is_some_and(|r| ctx.fonts(|f| f.definitions().families.contains_key(&r.family))) } +/// Whether faces carry a glyph rasterizer (DirectWrite on Windows). +fn glyph_rasterizer() -> bool { + #[cfg(windows)] + return dwrite::enabled(); + #[cfg(not(windows))] + false +} + /// Advances of a row plus the letter spacing after each character. fn row_width(row: &egui::epaint::text::Row, spacing: f32) -> f32 { row.glyphs.iter().map(|g| g.advance_width + spacing).sum() @@ -615,6 +629,12 @@ impl Book { let mut font = FontData::from_static(data); font.index = choice.entry.index; font.tweak = tweak; + // WebView2's glyph bitmaps come from DirectWrite (`dwrite`); epaint + // draws the outline itself when DirectWrite cannot. + #[cfg(windows)] + if let Some(rasterizer) = dwrite::rasterizer(choice, data) { + font = font.rasterizer(rasterizer); + } self.faces.insert(key.clone(), Arc::new(font)); self.dirty = true; Some((key, metrics)) @@ -817,14 +837,16 @@ pub fn ui_family(ctx: &Context, weight: u16) -> FontFamily { /// Text options as measured against WebView2 (typography scenes, PSNR): /// -/// - hinting on: skrifa runs the faces' TrueType instructions like -/// DirectWrite does (off: -3.5 dB, the monochrome target: -4.5 dB), light, -/// with the vertical smoothing of each face's `gasp` table at the drawn -/// size (+0.6 dB over one fixed choice); +/// - on Windows every face carries the DirectWrite rasterizer of +/// `dwrite`, so glyphs never reach skrifa (55 to 68 dB against WebView2, +/// from 29 to 35 dB); the hinting below is only for Linux, macOS and the +/// fallback: skrifa runs the faces' TrueType instructions, light, with the +/// vertical smoothing of each face's `gasp` table at the drawn size; /// - sub-pixel binning on: Skia positions glyphs on quarter pixels too (off: -/// -2.3 dB); +/// -2.3 dB), CJK included when DirectWrite rasterizes; /// - with the software renderer the atlas keeps raw coverage and the backend -/// applies [`coverage_tables`]; glow gets the curve of white text. +/// applies [`coverage_tables`] (the sRGB curve of Chromium on Windows); glow gets +/// a power curve close to it for white text. pub fn text_options() -> TextOptions { TextOptions { font_hinting: true, @@ -832,7 +854,7 @@ pub fn text_options() -> TextOptions { color_transfer_function: if cfg!(feature = "software") { egui::epaint::FontColorTransferFunction::Off } else { - egui::epaint::FontColorTransferFunction::Gamma(1.0 / gamma::CHROMIUM.gamma) + egui::epaint::FontColorTransferFunction::Gamma(1.0 / gamma::POWER_APPROX) }, ..TextOptions::default() } diff --git a/crates/accshift-egui/src/ui/text/dwrite.rs b/crates/accshift-egui/src/ui/text/dwrite.rs new file mode 100644 index 0000000..b9eb759 --- /dev/null +++ b/crates/accshift-egui/src/ui/text/dwrite.rs @@ -0,0 +1,537 @@ +//! Glyph coverage from DirectWrite, asked for the way WebView2 asks for it. +//! +//! WebView2 draws text with Skia, and Skia rasterizes glyphs on Windows with +//! DirectWrite (`SkScalerContext_DW`). This module ports that glyph path for +//! the configuration Chromium uses here, and hands the coverage to epaint's +//! atlas through the vendored `GlyphRasterizer` hook. Sources: Skia at +//! `2466dcf3937437e217e7f284afe0e1aae15891ce`, the `skia_revision` of +//! Chromium 154.0.8037.99 (WebView2 154); line numbers below are in +//! `src/ports/SkScalerContext_win_dw.cpp` unless another file is named. +//! +//! - Blink asks for `kSubpixelAntiAlias` edging (ClearType is on) and sub-pixel +//! positioning (`font_platform_data_win.cc`, `CreateSkFont`). The app's +//! text sits on a composited translucent layer, which cc rasters with +//! `kUnknown_SkPixelGeometry` (`skia/ext/legacy_display_globals.cc`, +//! `ComputeSurfaceProps`), so Skia turns the LCD mask into A8 with +//! `kGenA8FromLCD_Flag` (`src/core/SkScalerContext.cpp` 1159-1162). +//! - Hinting stays `kNormal` (`src/ports/SkTypeface_win_dw.cpp` 740-745), +//! so the grid fit is on (407). +//! - Rendering mode, render size and measuring mode by `gasp` and `maxp` +//! (358-392), see [`Mode::for_size`]. None of the app's faces has embedded +//! bitmaps (no `EBLC`), so the bitmap branches (312-356) never apply. +//! - With `kGenA8FromLCD_Flag` the antialias mode stays ClearType and the +//! texture `DWRITE_TEXTURE_CLEARTYPE_3x1` (396-404), which sends the run +//! through `IDWriteFactory::CreateGlyphRunAnalysis` (2092-2117) with the +//! identity transform plus the sub-pixel offset in `dx` (2068-2069). +//! - The 3x1 texture is averaged to one coverage value, `(r + g + b) / 3` +//! (`RGBToA8`, 1998-2017); the gamma pre-blend is applied later by the +//! software backend (`gamma`). +//! - Empty bounds: retry aliased (1823-1838); still empty: epaint draws the +//! outline itself, as Skia falls back to the path (1840-1843). + +use std::collections::HashMap; +use std::mem::ManuallyDrop; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Instant; + +use egui::epaint::text::{GlyphImage, GlyphRasterizer, GlyphRasterizerRef, GlyphRequest}; +use skrifa::raw::TableProvider as _; +use windows::core::{Interface as _, HSTRING}; +use windows::Win32::Foundation::RECT; +use windows::Win32::Graphics::DirectWrite::{ + DWRITE_TEXTURE_ALIASED_1x1, DWRITE_TEXTURE_CLEARTYPE_3x1, DWriteCreateFactory, IDWriteFactory, + IDWriteFactory5, IDWriteFontFace, IDWriteFontFace5, IDWriteFontFile, IDWriteGlyphRunAnalysis, + IDWriteInMemoryFontFileLoader, DWRITE_FACTORY_TYPE_SHARED, DWRITE_FONT_AXIS_TAG_WEIGHT, + DWRITE_FONT_AXIS_VALUE, DWRITE_FONT_FACE_TYPE, DWRITE_FONT_FILE_TYPE, + DWRITE_FONT_SIMULATIONS_NONE, DWRITE_GLYPH_OFFSET, DWRITE_GLYPH_RUN, DWRITE_MATRIX, + DWRITE_MEASURING_MODE, DWRITE_MEASURING_MODE_NATURAL, DWRITE_RENDERING_MODE, + DWRITE_RENDERING_MODE_ALIASED, DWRITE_RENDERING_MODE_NATURAL, + DWRITE_RENDERING_MODE_NATURAL_SYMMETRIC, DWRITE_TEXTURE_TYPE, +}; + +use super::faces::{Choice, Source}; + +/// `GASP_SYMMETRIC_SMOOTHING`. +const SYMMETRIC_SMOOTHING: u16 = 0x0008; + +/// The shared factory, created on first use. +struct Factory { + v1: IDWriteFactory, +} + +fn factory() -> Option<&'static Factory> { + static FACTORY: OnceLock> = OnceLock::new(); + FACTORY + .get_or_init(|| { + let started = Instant::now(); + // Skia's `sk_get_dwrite_factory` makes a shared factory too. + let v1: IDWriteFactory = + unsafe { DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED) }.ok()?; + crate::bench::mark_with( + "dwrite_factory", + &format!("\"ms\":{:.3}", started.elapsed().as_secs_f64() * 1000.0), + ); + Some(Factory { v1 }) + }) + .as_ref() +} + +/// Creates the factory now (call off the UI thread to keep it off the first +/// frame). +pub fn warm_up() { + let _ = factory(); +} + +/// The loader for bundled faces (`IDWriteFactory5`, Windows 10 1703). +fn memory_loader(f: &Factory) -> Option<&'static (IDWriteFactory5, IDWriteInMemoryFontFileLoader)> { + static LOADER: OnceLock> = + OnceLock::new(); + LOADER + .get_or_init(|| unsafe { + let f5 = f.v1.cast::().ok()?; + let loader = f5.CreateInMemoryFontFileLoader().ok()?; + f5.RegisterFontFileLoader(&loader).ok()?; + Some((f5, loader)) + }) + .as_ref() +} + +/// Same face, same DirectWrite object: the egui fonts of one face (one per +/// gasp behaviour) share it. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +enum FaceId { + File(PathBuf, u32, Option), + Bundled(usize, u32, Option), +} + +fn open_face( + id: &FaceId, + source: &Source, + index: u32, + wght: Option, +) -> Option { + type Faces = Mutex>>; + static FACES: OnceLock = OnceLock::new(); + let mut faces = FACES.get_or_init(Default::default).lock().ok()?; + faces + .entry(id.clone()) + .or_insert_with(|| create_face(source, index, wght)) + .clone() +} + +fn create_face(source: &Source, index: u32, wght: Option) -> Option { + let f = factory()?; + unsafe { + let file: IDWriteFontFile = match source { + Source::File(path) => { + f.v1.CreateFontFileReference(&HSTRING::from(path.as_os_str()), None) + .ok()? + } + Source::Bundled(_, bytes) => { + let (f5, loader) = memory_loader(f)?; + loader + .CreateInMemoryFontFileReference( + f5, + bytes.as_ptr().cast(), + u32::try_from(bytes.len()).ok()?, + None, + ) + .ok()? + } + }; + let mut supported = false.into(); + let mut file_type = DWRITE_FONT_FILE_TYPE::default(); + let mut face_type = DWRITE_FONT_FACE_TYPE::default(); + let mut count = 0; + file.Analyze( + &mut supported, + &mut file_type, + Some(&mut face_type), + &mut count, + ) + .ok()?; + if !supported.as_bool() || index >= count { + return None; + } + let face = + f.v1.CreateFontFace( + face_type, + &[Some(file)], + index, + DWRITE_FONT_SIMULATIONS_NONE, + ) + .ok()?; + match wght { + // A variable face at a weight: what the system collection hands + // Chromium for a named instance. + Some(w) => { + let resource = face + .cast::() + .ok()? + .GetFontResource() + .ok()?; + let axes = [DWRITE_FONT_AXIS_VALUE { + axisTag: DWRITE_FONT_AXIS_TAG_WEIGHT, + value: w, + }]; + resource + .CreateFontFace(DWRITE_FONT_SIMULATIONS_NONE, &axes) + .ok()? + .cast::() + .ok() + } + None => Some(face), + } + } +} + +/// What Skia reads from the face to pick a rendering mode. +#[derive(Clone, Debug, Default, PartialEq)] +struct Tables { + /// `gasp` version and ranges (highest ppem, behaviour flags). + gasp_version: u16, + gasp: Vec<(u16, u16)>, + /// `is_hinted` (109-122): TrueType `maxp` with instructions. + hinted: bool, +} + +impl Tables { + fn read(data: &[u8], index: u32) -> Self { + let Ok(font) = skrifa::FontRef::from_index(data, index) else { + return Self::default(); + }; + let (gasp_version, gasp) = font.gasp().map_or((0, Vec::new()), |g| { + ( + g.version(), + g.gasp_ranges() + .iter() + .map(|r| (r.range_max_ppem(), r.range_gasp_behavior().bits())) + .collect(), + ) + }); + let hinted = font + .maxp() + .ok() + .and_then(|m| m.max_size_of_instructions()) + .is_some_and(|n| n != 0); + Self { + gasp_version, + gasp, + hinted, + } + } + + /// `get_gasp_range` (135-172): the flags of the range holding `size`. + fn gasp_flags(&self, size: i32) -> Option { + if self.gasp_version > 1 { + return None; + } + let mut min = -1; + for &(max, flags) in &self.gasp { + let max = i32::from(max); + if min < size && size <= max { + return Some(flags); + } + min = max; + } + None + } +} + +/// `SkScalarRoundToScalar`. +fn sk_round(v: f32) -> f32 { + (v + 0.5).floor() +} + +/// The size, rendering mode and measuring mode Skia renders a face with. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Mode { + size: f32, + rendering: DWRITE_RENDERING_MODE, + measuring: DWRITE_MEASURING_MODE, +} + +impl Mode { + /// The constructor's choice (268-412) for an A8 mask at `real` device px + /// with no embedded bitmaps. + fn for_size(tables: &Tables, real: f32) -> Self { + // 303-310: the GDI size is the real size rounded to 1/64. + let mut gdi = sk_round(real * 64.0) / 64.0; + if gdi == 0.0 { + gdi = 1.0; + } + // 358-369: a version 1 `gasp` decides symmetric smoothing. + if tables.gasp_version >= 1 { + if let Some(flags) = tables.gasp_flags(sk_round(gdi) as i32) { + return Self { + size: real, + rendering: if flags & SYMMETRIC_SMOOTHING != 0 { + DWRITE_RENDERING_MODE_NATURAL_SYMMETRIC + } else { + DWRITE_RENDERING_MODE_NATURAL + }, + measuring: DWRITE_MEASURING_MODE_NATURAL, + }; + } + } + // 371-376: above 20 px or no hints, symmetric. + if real > 20.0 || !tables.hinted { + return Self { + size: real, + rendering: DWRITE_RENDERING_MODE_NATURAL_SYMMETRIC, + measuring: DWRITE_MEASURING_MODE_NATURAL, + }; + } + // 378-392: hinted, small, no `gasp` v1: asymmetric at the GDI size. + Self { + size: gdi, + rendering: DWRITE_RENDERING_MODE_NATURAL, + measuring: DWRITE_MEASURING_MODE_NATURAL, + } + } +} + +/// One face's glyphs through DirectWrite. +pub struct Glyphs { + id: FaceId, + source: Source, + index: u32, + wght: Option, + tables: Tables, +} + +impl Glyphs { + fn face(&self) -> Option { + open_face(&self.id, &self.source, self.index, self.wght) + } + + /// One glyph run analysis, and its texture bounds when not empty + /// (`generateDWMetrics`, 1540-1617, and `getDWMaskBits`, 2051-2136). + fn analyze( + &self, + face: &IDWriteFontFace, + request: &GlyphRequest, + mode: Mode, + rendering: DWRITE_RENDERING_MODE, + texture: DWRITE_TEXTURE_TYPE, + ) -> Option<(IDWriteGlyphRunAnalysis, RECT)> { + let f = factory()?; + let index = request.glyph_id; + let advance = 0.0f32; + let offset = DWRITE_GLYPH_OFFSET::default(); + let run = DWRITE_GLYPH_RUN { + fontFace: ManuallyDrop::new(Some(face.clone())), + fontEmSize: mode.size, + glyphCount: 1, + glyphIndices: &index, + glyphAdvances: &advance, + glyphOffsets: &offset, + isSideways: false.into(), + bidiLevel: 0, + }; + // 293-300 and 2068-2069: the font matrix is the identity for upright + // text at the device size; the sub-pixel position goes in `dx`. + let matrix = DWRITE_MATRIX { + m11: 1.0, + m12: 0.0, + m21: 0.0, + m22: 1.0, + dx: request.x_offset, + dy: 0.0, + }; + // 1572-1597 and 2107-2117: with grid fit on and ClearType + // antialiasing Skia takes the version 1 factory, 1 pixel per dip. + let analysis = unsafe { + f.v1.CreateGlyphRunAnalysis( + &run, + 1.0, + Some(&matrix), + rendering, + mode.measuring, + 0.0, + 0.0, + ) + }; + drop(ManuallyDrop::into_inner(run.fontFace)); + let analysis = analysis.ok()?; + let bounds = unsafe { analysis.GetAlphaTextureBounds(texture) }.ok()?; + // 1607-1612: empty, or the 0x80000000 bounds DirectWrite returns for + // some tiny and some huge glyphs. + if bounds.left >= bounds.right || bounds.top >= bounds.bottom { + return None; + } + Some((analysis, bounds)) + } +} + +impl GlyphRasterizer for Glyphs { + fn rasterize(&self, request: &GlyphRequest) -> Option { + let face = self.face()?; + let mode = Mode::for_size(&self.tables, request.ppem); + // 396-404: with `kGenA8FromLCD_Flag` the mask is the ClearType 3x1 + // texture averaged down. + let texture = DWRITE_TEXTURE_CLEARTYPE_3x1; + let (analysis, bounds, texture, aliased) = + match self.analyze(&face, request, mode, mode.rendering, texture) { + Some((a, b)) => (a, b, texture, false), + // 1823-1838: no glyph of that texture type (or too big to + // smooth): aliased, 1 bit. + None => { + let (a, b) = self.analyze( + &face, + request, + mode, + DWRITE_RENDERING_MODE_ALIASED, + DWRITE_TEXTURE_ALIASED_1x1, + )?; + (a, b, DWRITE_TEXTURE_ALIASED_1x1, true) + } + }; + let width = u32::try_from(bounds.right - bounds.left).ok()?; + let height = u32::try_from(bounds.bottom - bounds.top).ok()?; + let pixels = width as usize * height as usize; + let channels = if texture == DWRITE_TEXTURE_CLEARTYPE_3x1 { + 3 + } else { + 1 + }; + let mut bits = vec![0u8; pixels * channels]; + unsafe { analysis.CreateAlphaTexture(texture, &bounds, &mut bits) }.ok()?; + let coverage = if aliased { + // `BilevelToBW` (1928-1975) keeps the top bit of each byte. + bits.iter() + .map(|&b| if b & 0x80 != 0 { 255 } else { 0 }) + .collect() + } else if channels == 3 { + // `RGBToA8` (1998-2017). + bits.as_chunks::<3>() + .0 + .iter() + .map(|p| ((u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2])) / 3) as u8) + .collect() + } else { + // `GrayscaleToA8` (1978-1995). + bits + }; + Some(GlyphImage { + left: bounds.left, + top: bounds.top, + width, + height, + coverage, + }) + } +} + +/// Whether faces get a DirectWrite rasterizer (`ACCSHIFT_GLYPHS_EPAINT` set: +/// epaint's own, for comparisons). +pub fn enabled() -> bool { + static ON: OnceLock = OnceLock::new(); + *ON.get_or_init(|| std::env::var_os("ACCSHIFT_GLYPHS_EPAINT").is_none()) +} + +/// The DirectWrite rasterizer of a face, or `None` when the face cannot be +/// read. The DirectWrite face itself is opened on the first glyph. +pub fn rasterizer(choice: &Choice, data: &[u8]) -> Option { + if !enabled() { + return None; + } + let index = choice.entry.index; + let wght_bits = choice.wght.map(f32::to_bits); + let id = match &choice.entry.source { + Source::File(path) => FaceId::File(path.clone(), index, wght_bits), + Source::Bundled(_, bytes) => FaceId::Bundled(bytes.as_ptr() as usize, index, wght_bits), + }; + Some(GlyphRasterizerRef(Arc::new(Glyphs { + id, + source: choice.entry.source.clone(), + index, + wght: choice.wght, + tables: Tables::read(data, index), + }))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn segoe_ui() -> Tables { + // segoeui.ttf: gasp v1, (8, 0b1010), (19, 0b0111), (65535, 0b1111). + Tables { + gasp_version: 1, + gasp: vec![(8, 0b1010), (19, 0b0111), (65535, 0b1111)], + hinted: true, + } + } + + #[test] + fn gasp_picks_the_rendering_mode() { + let t = segoe_ui(); + assert_eq!( + Mode::for_size(&t, 8.0).rendering, + DWRITE_RENDERING_MODE_NATURAL_SYMMETRIC + ); + assert_eq!( + Mode::for_size(&t, 13.0).rendering, + DWRITE_RENDERING_MODE_NATURAL + ); + // 19.5 rounds to 20 ppem for the gasp lookup. + assert_eq!( + Mode::for_size(&t, 19.5).rendering, + DWRITE_RENDERING_MODE_NATURAL_SYMMETRIC + ); + assert_eq!( + Mode::for_size(&t, 19.4).rendering, + DWRITE_RENDERING_MODE_NATURAL + ); + assert_eq!(Mode::for_size(&t, 19.5).size, 19.5); + } + + #[test] + fn gasp_v0_falls_back_on_size_and_hints() { + // seguibl.ttf: gasp v0. + let t = Tables { + gasp_version: 0, + gasp: vec![(8, 0b10), (65535, 0b11)], + hinted: true, + }; + let small = Mode::for_size(&t, 13.0 + 1.0 / 200.0); + assert_eq!(small.rendering, DWRITE_RENDERING_MODE_NATURAL); + assert_eq!(small.size, 13.0); + assert_eq!( + Mode::for_size(&t, 21.0).rendering, + DWRITE_RENDERING_MODE_NATURAL_SYMMETRIC + ); + } + + #[test] + fn rasterizes_segoe_ui() { + let path = PathBuf::from(r"C:\Windows\Fonts\segoeui.ttf"); + let Ok(data) = std::fs::read(&path) else { + return; + }; + let choice = Choice { + entry: super::super::faces::Entry { + source: Source::File(path), + index: 0, + weight: 400, + }, + wght: None, + }; + let r = rasterizer(&choice, &data).expect("rasterizer"); + let font = skrifa::FontRef::new(&data).unwrap(); + use skrifa::MetadataProvider as _; + let gid = font.charmap().map('H').unwrap().to_u32() as u16; + let image = + r.0.rasterize(&GlyphRequest { + glyph_id: gid, + ppem: 13.0, + x_offset: 0.0, + }) + .expect("coverage"); + assert!(image.width >= 7 && image.height >= 9, "{image:?}"); + assert!(image.top < 0); + assert!(image.coverage.contains(&255)); + } +} diff --git a/crates/accshift-egui/src/ui/text/gamma.rs b/crates/accshift-egui/src/ui/text/gamma.rs index 83aa316..e18d032 100644 --- a/crates/accshift-egui/src/ui/text/gamma.rs +++ b/crates/accshift-egui/src/ui/text/gamma.rs @@ -18,25 +18,30 @@ pub const CLASSES: usize = 8; /// Contrast and gamma of the pre-blend. [`CHROMIUM`] holds the values -/// measured against WebView2. +/// WebView2 draws with. A gamma of 0 is the sRGB transfer curve, as in Skia's +/// `SkColorSpaceLuminance::Fetch`. #[derive(Clone, Copy, Debug, PartialEq)] pub struct MaskGamma { pub contrast: f32, pub gamma: f32, } -/// What WebView2 on Windows draws with. Chromium hands Skia the gamma and -/// enhanced contrast of the DirectWrite rendering parameters, 2.2 and 1.0 on -/// a stock Windows (no ClearType tuning). Fitted on the typography shots -/// (`parity/fragments/typography*.html`): matching each line's coverage -/// histogram to egui's raw coverage gives c^(1/2.2) for white text, and the -/// grey classes need the full contrast; Skia's own defaults (0.2, 1.2) lose -/// 1.5 dB. +/// What WebView2 on Windows draws with: Chromium builds Skia with +/// `SK_GAMMA_SRGB` and `SK_GAMMA_CONTRAST=1.0` on Windows (`skia/BUILD.gn` +/// 139-143 at Chromium 154.0.8037.99) and hands those to Skia as the surface's +/// text gamma and contrast (`skia/ext/legacy_display_globals.cc`). With the +/// DirectWrite coverage (`dwrite`), every glyph pixel of the typography +/// shots then lands within 2 levels of WebView2's value; the power curve 2.2 +/// that matched the old outline coverage is 1 to 9 levels off on faint pixels. pub const CHROMIUM: MaskGamma = MaskGamma { contrast: 1.0, - gamma: 2.2, + gamma: 0.0, }; +/// The single power curve closest to [`CHROMIUM`] for white text, for +/// renderers that take one gamma (glow). +pub const POWER_APPROX: f32 = 2.2; + /// Skia's `sk_t_scale255<3>`: a 3-bit class back to a 0..255 luminance. fn class_luminance(class: usize) -> u8 { let i = class as u32; @@ -48,16 +53,30 @@ fn apply_contrast(srca: f32, contrast: f32) -> f32 { } impl MaskGamma { + /// `toLuma` of `SkMaskGamma.cpp`. fn linear(self, v: f32) -> f32 { - if self.gamma == 1.0 { + if self.gamma == 0.0 { + if v <= 0.04045 { + v / 12.92 + } else { + ((v + 0.055) / 1.055).powf(2.4) + } + } else if self.gamma == 1.0 { v } else { v.powf(self.gamma) } } + /// `fromLuma` of `SkMaskGamma.cpp`. fn encoded(self, v: f32) -> f32 { - if self.gamma == 1.0 { + if self.gamma == 0.0 { + if v <= 0.0031308 { + v * 12.92 + } else { + 1.055 * v.powf(1.0 / 2.4) - 0.055 + } + } else if self.gamma == 1.0 { v } else { v.powf(1.0 / self.gamma) diff --git a/crates/accshift-egui/src/ui/text/glyph_check.rs b/crates/accshift-egui/src/ui/text/glyph_check.rs new file mode 100644 index 0000000..23df40f --- /dev/null +++ b/crates/accshift-egui/src/ui/text/glyph_check.rs @@ -0,0 +1,154 @@ +//! Glyph-level check of the DirectWrite rasterizer against WebView2: every +//! glyph of `parity/fragments/glyph-check.html` (Latin, Cyrillic and +//! Han, regular and bold, 12 and 16 px), at scales 1 and 1.5, compared pixel +//! by pixel inside its own bitmap box in the reference frame and the egui +//! frame. Run after `parity/ref.mjs` and the egui runner captured the +//! `glyph-check-*` scenes: +//! +//! `ACCSHIFT_GLYPH_REF= ACCSHIFT_GLYPH_EGUI= cargo test --release -- --ignored --nocapture glyph_level` + +use egui::{pos2, vec2, Rect}; + +use super::{install, layout, TextStyle}; + +/// The fragment's lines: top, size, weight. +const LINES: [(f32, f32, u16); 4] = [ + (12.0, 12.0, 400), + (36.0, 12.0, 700), + (60.0, 16.0, 400), + (92.0, 16.0, 700), +]; +const TEXT: &str = "W e g h С м н к 账 号 设 置"; +const FG: [i32; 3] = [0xf4, 0xf4, 0xf6]; + +struct Frame { + width: usize, + rgb: Vec<[u8; 3]>, +} + +impl Frame { + fn read(path: &std::path::Path) -> Self { + let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + let decoder = png::Decoder::new(std::io::Cursor::new(bytes)); + let mut reader = decoder.read_info().unwrap(); + let mut buf = vec![0; reader.output_buffer_size().unwrap()]; + let info = reader.next_frame(&mut buf).unwrap(); + let channels = info.color_type.samples(); + let rgb = buf[..info.buffer_size()] + .chunks(channels) + .map(|p| [p[0], p[1], p[2]]) + .collect(); + Self { + width: info.width as usize, + rgb, + } + } + + fn at(&self, x: i32, y: i32) -> [u8; 3] { + self.rgb[y as usize * self.width + x as usize] + } +} + +#[test] +#[ignore = "needs the glyph-check shots of parity/ref.mjs and the egui runner"] +fn glyph_level() { + let dir = |var: &str| { + std::path::PathBuf::from(std::env::var(var).unwrap_or_else(|_| panic!("{var}="))) + }; + let (ref_dir, egui_dir) = (dir("ACCSHIFT_GLYPH_REF"), dir("ACCSHIFT_GLYPH_EGUI")); + let (mut equal, mut total, mut worst) = (0, 0, (0, String::new())); + for pct in [100, 150] { + let scale = pct as f32 / 100.0; + let shot = format!("glyph-check-{pct}/frame.png"); + let (web, ours) = ( + Frame::read(&ref_dir.join(&shot)), + Frame::read(&egui_dir.join(&shot)), + ); + let ctx = egui::Context::default(); + ctx.set_zoom_factor(scale); + install(&ctx, true); + let mut laid = Vec::new(); + for _ in 0..4 { + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { + laid = LINES + .iter() + .map(|&(top, size, weight)| { + let rect = Rect::from_min_size(pos2(16.0, top), vec2(f32::INFINITY, 0.0)); + layout(ui.ctx(), TEXT, &TextStyle::new(size, weight), rect) + }) + .collect(); + }); + } + for (&(_, size, weight), laid) in LINES.iter().zip(&laid) { + let laid = laid.as_ref().expect("laid out"); + let row = &laid.galley.rows[0]; + let mesh = &row.row.visuals.mesh; + let bg = web.at( + (16.0 * scale) as i32 - 4, + ((laid.pos.y + 4.0) * scale) as i32, + ); + let mut cells = Vec::new(); + for glyph in row.row.glyphs.iter().filter(|g| !g.chr.is_whitespace()) { + let first = glyph.first_vertex as usize; + let quad = &mesh.vertices[first..first + 4]; + let min = quad + .iter() + .fold(pos2(f32::MAX, f32::MAX), |m, v| m.min(v.pos)); + let max = quad + .iter() + .fold(pos2(f32::MIN, f32::MIN), |m, v| m.max(v.pos)); + let offset = laid.pos.to_vec2() + row.pos.to_vec2(); + let (x0, y0) = ( + ((min.x + offset.x) * scale).round() as i32, + ((min.y + offset.y) * scale).round() as i32, + ); + let (x1, y1) = ( + ((max.x + offset.x) * scale).round() as i32, + ((max.y + offset.y) * scale).round() as i32, + ); + let (mut diff_px, mut max_delta) = (0, 0); + for y in y0..y1 { + for x in x0..x1 { + let (a, b) = (web.at(x, y), ours.at(x, y)); + let d = (0..3) + .map(|c| (i32::from(a[c]) - i32::from(b[c])).abs()) + .max() + .unwrap(); + if d > 0 { + diff_px += 1; + } + max_delta = max_delta.max(d); + } + } + // In coverage levels after the gamma table: a pixel moves + // by (fg - bg) / 255 per level. + let span = (FG[1] - i32::from(bg[1])).max(1); + let levels = (max_delta * 255 + span / 2) / span; + total += 1; + if levels == 0 { + equal += 1; + } + let name = format!("{pct}% {size}px {weight} '{}'", glyph.chr); + if levels > worst.0 { + worst = (levels, name.clone()); + } + cells.push(format!( + "'{}' {}x{} {}", + glyph.chr, + x1 - x0, + y1 - y0, + if levels == 0 { + "=".to_string() + } else { + format!("{levels} lv / {diff_px} px") + } + )); + } + println!("{pct}% {size}px {weight}: {}", cells.join(", ")); + } + } + println!( + "glyphs: {equal}/{total} equal, worst {} levels ({})", + worst.0, worst.1 + ); +} diff --git a/crates/accshift-egui/vendor/epaint/Cargo.toml b/crates/accshift-egui/vendor/epaint/Cargo.toml new file mode 100644 index 0000000..1f70299 --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/Cargo.toml @@ -0,0 +1,345 @@ +# Vendored epaint 0.36.2 (crates.io, egui commit 49682f8b), wired in through +# [patch.crates-io] in crates/accshift-egui/Cargo.toml. One patch, marked +# "accshift patch" in src/text/font.rs and src/text/fonts.rs: a font can carry +# a GlyphRasterizer (FontData::rasterizer) that supplies the glyph coverage +# instead of skrifa plus vello_cpu, so ui::text can rasterize with DirectWrite +# like WebView2. Layout, advances and the atlas are untouched. The bench and +# dev-dependencies are dropped so they stay out of the lockfile. + +[package] +edition = "2024" +rust-version = "1.95" +name = "epaint" +version = "0.36.2" +authors = ["Emil Ernerfeldt "] +build = false +include = ["../../LICENSE-APACHE", "../../LICENSE-MIT", "**/*.rs", "Cargo.toml"] +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Minimal 2D graphics library for GUI work" +homepage = "https://github.com/emilk/egui/tree/main/crates/epaint" +readme = "README.md" +keywords = ["graphics", "gui", "egui"] +categories = ["graphics", "gui"] +license = "MIT OR Apache-2.0" +repository = "https://github.com/emilk/egui/tree/main/crates/epaint" +resolver = "2" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--generate-link-to-definition"] + +[features] +_override_unity = [] +bytemuck = ["dep:bytemuck", "emath/bytemuck", "ecolor/bytemuck"] +cint = ["ecolor/cint"] +color-hex = ["ecolor/color-hex"] +default = ["default_fonts"] +default_fonts = ["epaint_default_fonts"] +mint = ["emath/mint"] +rayon = ["dep:rayon"] +serde = [ + "dep:serde", + "ahash/serde", + "ecolor/serde", + "emath/serde", + "font-types/serde", + "smallvec/serde", +] +unity = [] + +[lib] +name = "epaint" +path = "src/lib.rs" + + +[dependencies.ahash] +version = "0.8.12" +features = ["no-rng", "std"] +default-features = false + +[dependencies.bytemuck] +version = "1.25" +features = ["derive"] +optional = true + +[dependencies.document-features] +version = "0.2.12" +optional = true + +[dependencies.ecolor] +version = "0.36.2" +default-features = false + +[dependencies.emath] +version = "0.36.2" +default-features = false + +[dependencies.epaint_default_fonts] +version = "0.36.2" +optional = true + +[dependencies.font-types] +version = "0.12.2" +features = ["std"] +default-features = false + +[dependencies.harfrust] +version = "0.12.0" + +[dependencies.log] +version = "0.4.33" +features = ["std"] + +[dependencies.nohash-hasher] +version = "0.2.0" + +[dependencies.parking_lot] +version = "0.12.5" + +[dependencies.profiling] +version = "1.0" +default-features = false + +[dependencies.rayon] +version = "1.12" +optional = true + +[dependencies.self_cell] +version = "1.3" + +[dependencies.serde] +version = "1.0" +features = ["derive", "derive", "rc"] +optional = true + +[dependencies.skrifa] +version = "0.44.0" +features = ["std", "autohint_shaping"] +default-features = false + +[dependencies.smallvec] +version = "1.15" + +[dependencies.unicode-general-category] +version = "1.1" + +[dependencies.unicode-segmentation] +version = "1.13" + +[dependencies.vello_cpu] +version = "0.1.0" +features = ["std", "u8_pipeline", "f32_pipeline"] +default-features = false + +[lints.clippy] +allow_attributes = "warn" +as_ptr_cast_mut = "warn" +assigning_clones = "allow" +bool_to_int_with_if = "warn" +branches_sharing_code = "warn" +cast_possible_truncation = "allow" +cast_possible_wrap = "allow" +checked_conversions = "warn" +clear_with_drain = "warn" +clone_on_ref_ptr = "warn" +cloned_instead_of_copied = "warn" +coerce_container_to_any = "warn" +comparison_chain = "allow" +dbg_macro = "warn" +debug_assert_with_mut_call = "warn" +decimal_bitwise_operands = "warn" +default_union_representation = "warn" +derive_partial_eq_without_eq = "warn" +disallowed_script_idents = "warn" +doc_broken_link = "warn" +doc_comment_double_space_linebreaks = "warn" +doc_include_without_cfg = "warn" +doc_link_with_quotes = "warn" +doc_markdown = "warn" +duration_suboptimal_units = "warn" +elidable_lifetime_names = "warn" +empty_enum_variants_with_brackets = "warn" +empty_enums = "warn" +enum_glob_use = "warn" +equatable_if_let = "warn" +exit = "warn" +expl_impl_clone_on_copy = "warn" +explicit_deref_methods = "warn" +explicit_into_iter_loop = "warn" +explicit_iter_loop = "warn" +fallible_impl_from = "warn" +filter_map_next = "warn" +flat_map_option = "warn" +float_cmp_const = "warn" +fn_params_excessive_bools = "warn" +fn_to_numeric_cast_any = "warn" +format_push_string = "warn" +from_iter_instead_of_collect = "warn" +get_unwrap = "warn" +ignore_without_reason = "warn" +ignored_unit_patterns = "warn" +implicit_clone = "warn" +imprecise_flops = "warn" +inconsistent_struct_constructor = "warn" +index_refutable_slice = "warn" +inefficient_to_string = "warn" +infinite_loop = "warn" +into_iter_without_iter = "warn" +invalid_upcast_comparisons = "warn" +ip_constant = "warn" +iter_filter_is_ok = "warn" +iter_filter_is_some = "warn" +iter_not_returning_iterator = "warn" +iter_on_empty_collections = "warn" +iter_on_single_items = "warn" +iter_over_hash_type = "warn" +iter_without_into_iter = "warn" +large_digit_groups = "warn" +large_futures = "warn" +large_include_file = "warn" +large_stack_arrays = "warn" +large_stack_frames = "warn" +large_types_passed_by_value = "warn" +let_underscore_must_use = "allow" +let_underscore_untyped = "allow" +linkedlist = "warn" +literal_string_with_formatting_args = "warn" +lossy_float_literal = "warn" +macro_use_imports = "warn" +manual_assert = "warn" +manual_ilog2 = "warn" +manual_instant_elapsed = "warn" +manual_is_power_of_two = "warn" +manual_is_variant_and = "warn" +manual_let_else = "warn" +manual_midpoint = "warn" +manual_range_contains = "allow" +manual_string_new = "warn" +map_err_ignore = "warn" +map_unwrap_or = "allow" +match_bool = "warn" +match_same_arms = "warn" +match_wild_err_arm = "warn" +match_wildcard_for_single_variants = "warn" +mem_forget = "warn" +mismatching_type_param_order = "warn" +missing_assert_message = "warn" +missing_errors_doc = "warn" +missing_fields_in_debug = "warn" +mut_mut = "warn" +mutex_integer = "warn" +needless_continue = "warn" +needless_for_each = "warn" +needless_pass_by_ref_mut = "warn" +needless_pass_by_value = "warn" +needless_raw_string_hashes = "warn" +needless_type_cast = "warn" +negative_feature_names = "warn" +non_std_lazy_statics = "warn" +non_zero_suggestions = "warn" +nonstandard_macro_braces = "warn" +option_as_ref_cloned = "warn" +option_option = "warn" +or_fun_call = "warn" +path_buf_push_overwrite = "warn" +pathbuf_init_then_push = "warn" +precedence_bits = "warn" +print_stderr = "warn" +print_stdout = "warn" +ptr_as_ptr = "warn" +ptr_cast_constness = "warn" +pub_underscore_fields = "warn" +pub_without_shorthand = "warn" +rc_mutex = "warn" +redundant_type_annotations = "warn" +ref_as_ptr = "warn" +ref_option = "warn" +ref_option_ref = "warn" +ref_patterns = "warn" +rest_pat_in_fully_bound_structs = "warn" +return_and_then = "warn" +same_functions_in_if_condition = "warn" +same_length_and_capacity = "warn" +self_named_module_files = "allow" +self_only_used_in_recursion = "warn" +semicolon_if_nothing_returned = "warn" +set_contains_or_insert = "warn" +should_panic_without_expect = "allow" +significant_drop_tightening = "allow" +single_char_pattern = "warn" +single_match_else = "warn" +single_option_map = "warn" +std_instead_of_core = "warn" +str_split_at_newline = "warn" +str_to_string = "warn" +string_add = "warn" +string_add_assign = "warn" +string_lit_as_bytes = "warn" +string_lit_chars_any = "warn" +suspicious_xor_used_as_pow = "warn" +todo = "warn" +too_long_first_doc_paragraph = "warn" +too_many_lines = "allow" +trailing_empty_array = "warn" +trait_duplication_in_bounds = "warn" +transmute_ptr_to_ptr = "warn" +tuple_array_conversions = "warn" +unchecked_time_subtraction = "warn" +undocumented_unsafe_blocks = "warn" +unimplemented = "warn" +uninhabited_references = "warn" +uninlined_format_args = "warn" +unnecessary_box_returns = "warn" +unnecessary_debug_formatting = "warn" +unnecessary_literal_bound = "warn" +unnecessary_safety_comment = "warn" +unnecessary_safety_doc = "warn" +unnecessary_self_imports = "warn" +unnecessary_semicolon = "warn" +unnecessary_struct_initialization = "warn" +unnecessary_trailing_comma = "warn" +unnecessary_wraps = "warn" +unnested_or_patterns = "warn" +unused_async = "warn" +unused_peekable = "warn" +unused_rounding = "warn" +unused_self = "warn" +unused_trait_names = "warn" +unwrap_used = "warn" +use_self = "warn" +useless_let_if_seq = "warn" +verbose_file_reads = "warn" +wildcard_dependencies = "warn" +wildcard_imports = "allow" +zero_sized_map_values = "warn" + +[lints.clippy.all] +level = "warn" +priority = -1 + +[lints.rust] +trivial_casts = "allow" +trivial_numeric_casts = "warn" +unsafe_code = "deny" +unused_import_braces = "warn" +unused_lifetimes = "warn" +unused_qualifications = "allow" + +[lints.rust.future_incompatible] +level = "warn" +priority = -1 + +[lints.rust.nonstandard_style] +level = "warn" +priority = -1 + +[lints.rust.rust_2018_idioms] +level = "warn" +priority = -1 + +[lints.rustdoc] +all = "warn" diff --git a/crates/accshift-egui/vendor/epaint/README.md b/crates/accshift-egui/vendor/epaint/README.md new file mode 100644 index 0000000..fb5d9c1 --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/README.md @@ -0,0 +1,11 @@ +# epaint - egui paint library + +[![Latest version](https://img.shields.io/crates/v/epaint.svg)](https://crates.io/crates/epaint) +[![Documentation](https://docs.rs/epaint/badge.svg)](https://docs.rs/epaint) +[![unsafe forbidden](https://img.shields.io/badge/unsafe-forbidden-success.svg)](https://github.com/rust-secure-code/safety-dance/) +![MIT](https://img.shields.io/badge/license-MIT-blue.svg) +![Apache](https://img.shields.io/badge/license-Apache-blue.svg) + +A bare-bones 2D graphics library for turning simple 2D shapes and text into textured triangles. + +Made for [`egui`](https://github.com/emilk/egui/). diff --git a/crates/accshift-egui/vendor/epaint/src/brush.rs b/crates/accshift-egui/vendor/epaint/src/brush.rs new file mode 100644 index 0000000..a414194 --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/brush.rs @@ -0,0 +1,19 @@ +use crate::{Rect, TextureId}; + +/// Controls texturing of a [`crate::RectShape`]. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct Brush { + /// If the rect should be filled with a texture, which one? + /// + /// The texture is multiplied with [`crate::RectShape::fill`]. + pub fill_texture_id: TextureId, + + /// What UV coordinates to use for the texture? + /// + /// To display a texture, set [`Self::fill_texture_id`], + /// and set this to `Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0))`. + /// + /// Use [`Rect::ZERO`] to turn off texturing. + pub uv: Rect, +} diff --git a/crates/accshift-egui/vendor/epaint/src/color.rs b/crates/accshift-egui/vendor/epaint/src/color.rs new file mode 100644 index 0000000..8aa4f56 --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/color.rs @@ -0,0 +1,49 @@ +use core::fmt::Debug; +use std::sync::Arc; + +use ecolor::Color32; +use emath::{Pos2, Rect}; + +/// How paths will be colored. +#[derive(Clone)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum ColorMode { + /// The entire path is one solid color, this is the default. + Solid(Color32), + + /// Provide a callback which takes in the path's bounding box and a position and converts it to a color. + /// When used with a path, the bounding box will have a margin of [`TessellationOptions::feathering_size_in_pixels`](`crate::tessellator::TessellationOptions::feathering_size_in_pixels`) + /// + /// **This cannot be serialized** + #[cfg_attr(feature = "serde", serde(skip))] + UV(Arc Color32 + Send + Sync>), +} + +impl Default for ColorMode { + fn default() -> Self { + Self::Solid(Color32::TRANSPARENT) + } +} + +impl Debug for ColorMode { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Solid(arg0) => f.debug_tuple("Solid").field(arg0).finish(), + Self::UV(_arg0) => f.debug_tuple("UV").field(&"").finish(), + } + } +} + +impl PartialEq for ColorMode { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Solid(l0), Self::Solid(r0)) => l0 == r0, + (Self::UV(_l0), Self::UV(_r0)) => false, + _ => false, + } + } +} + +impl ColorMode { + pub const TRANSPARENT: Self = Self::Solid(Color32::TRANSPARENT); +} diff --git a/crates/accshift-egui/vendor/epaint/src/corner_radius.rs b/crates/accshift-egui/vendor/epaint/src/corner_radius.rs new file mode 100644 index 0000000..dd0b432 --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/corner_radius.rs @@ -0,0 +1,250 @@ +/// How rounded the corners of things should be. +/// +/// This specific the _corner radius_ of the underlying geometric shape (e.g. rectangle). +/// If there is a stroke, then the stroke will have an inner and outer corner radius +/// which will depends on its width and [`crate::StrokeKind`]. +/// +/// The rounding uses `u8` to save space, +/// so the amount of rounding is limited to integers in the range `[0, 255]`. +/// +/// For calculations, you may want to use [`crate::CornerRadiusF32`] instead, which uses `f32`. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct CornerRadius { + /// Radius of the rounding of the North-West (left top) corner. + pub nw: u8, + + /// Radius of the rounding of the North-East (right top) corner. + pub ne: u8, + + /// Radius of the rounding of the South-West (left bottom) corner. + pub sw: u8, + + /// Radius of the rounding of the South-East (right bottom) corner. + pub se: u8, +} + +impl Default for CornerRadius { + #[inline] + fn default() -> Self { + Self::ZERO + } +} + +impl From for CornerRadius { + #[inline] + fn from(radius: u8) -> Self { + Self::same(radius) + } +} + +impl From for CornerRadius { + #[inline] + fn from(radius: f32) -> Self { + Self::same(radius.round() as u8) + } +} + +impl CornerRadius { + /// No rounding on any corner. + pub const ZERO: Self = Self { + nw: 0, + ne: 0, + sw: 0, + se: 0, + }; + + /// Same rounding on all four corners. + #[inline] + pub const fn same(radius: u8) -> Self { + Self { + nw: radius, + ne: radius, + sw: radius, + se: radius, + } + } + + /// Do all corners have the same rounding? + #[inline] + pub fn is_same(self) -> bool { + self.nw == self.ne && self.nw == self.sw && self.nw == self.se + } + + /// Make sure each corner has a rounding of at least this. + #[inline] + pub fn at_least(self, min: u8) -> Self { + Self { + nw: self.nw.max(min), + ne: self.ne.max(min), + sw: self.sw.max(min), + se: self.se.max(min), + } + } + + /// Make sure each corner has a rounding of at most this. + #[inline] + pub fn at_most(self, max: u8) -> Self { + Self { + nw: self.nw.min(max), + ne: self.ne.min(max), + sw: self.sw.min(max), + se: self.se.min(max), + } + } + + /// Average rounding of the corners. + pub fn average(&self) -> f32 { + (self.nw as f32 + self.ne as f32 + self.sw as f32 + self.se as f32) / 4.0 + } +} + +impl core::ops::Add for CornerRadius { + type Output = Self; + #[inline] + fn add(self, rhs: Self) -> Self { + Self { + nw: self.nw.saturating_add(rhs.nw), + ne: self.ne.saturating_add(rhs.ne), + sw: self.sw.saturating_add(rhs.sw), + se: self.se.saturating_add(rhs.se), + } + } +} + +impl core::ops::Add for CornerRadius { + type Output = Self; + #[inline] + fn add(self, rhs: u8) -> Self { + Self { + nw: self.nw.saturating_add(rhs), + ne: self.ne.saturating_add(rhs), + sw: self.sw.saturating_add(rhs), + se: self.se.saturating_add(rhs), + } + } +} + +impl core::ops::AddAssign for CornerRadius { + #[inline] + fn add_assign(&mut self, rhs: Self) { + *self = Self { + nw: self.nw.saturating_add(rhs.nw), + ne: self.ne.saturating_add(rhs.ne), + sw: self.sw.saturating_add(rhs.sw), + se: self.se.saturating_add(rhs.se), + }; + } +} + +impl core::ops::AddAssign for CornerRadius { + #[inline] + fn add_assign(&mut self, rhs: u8) { + *self = Self { + nw: self.nw.saturating_add(rhs), + ne: self.ne.saturating_add(rhs), + sw: self.sw.saturating_add(rhs), + se: self.se.saturating_add(rhs), + }; + } +} + +impl core::ops::Sub for CornerRadius { + type Output = Self; + #[inline] + fn sub(self, rhs: Self) -> Self { + Self { + nw: self.nw.saturating_sub(rhs.nw), + ne: self.ne.saturating_sub(rhs.ne), + sw: self.sw.saturating_sub(rhs.sw), + se: self.se.saturating_sub(rhs.se), + } + } +} + +impl core::ops::Sub for CornerRadius { + type Output = Self; + #[inline] + fn sub(self, rhs: u8) -> Self { + Self { + nw: self.nw.saturating_sub(rhs), + ne: self.ne.saturating_sub(rhs), + sw: self.sw.saturating_sub(rhs), + se: self.se.saturating_sub(rhs), + } + } +} + +impl core::ops::SubAssign for CornerRadius { + #[inline] + fn sub_assign(&mut self, rhs: Self) { + *self = Self { + nw: self.nw.saturating_sub(rhs.nw), + ne: self.ne.saturating_sub(rhs.ne), + sw: self.sw.saturating_sub(rhs.sw), + se: self.se.saturating_sub(rhs.se), + }; + } +} + +impl core::ops::SubAssign for CornerRadius { + #[inline] + fn sub_assign(&mut self, rhs: u8) { + *self = Self { + nw: self.nw.saturating_sub(rhs), + ne: self.ne.saturating_sub(rhs), + sw: self.sw.saturating_sub(rhs), + se: self.se.saturating_sub(rhs), + }; + } +} + +impl core::ops::Div for CornerRadius { + type Output = Self; + #[inline] + fn div(self, rhs: f32) -> Self { + Self { + nw: (self.nw as f32 / rhs) as u8, + ne: (self.ne as f32 / rhs) as u8, + sw: (self.sw as f32 / rhs) as u8, + se: (self.se as f32 / rhs) as u8, + } + } +} + +impl core::ops::DivAssign for CornerRadius { + #[inline] + fn div_assign(&mut self, rhs: f32) { + *self = Self { + nw: (self.nw as f32 / rhs) as u8, + ne: (self.ne as f32 / rhs) as u8, + sw: (self.sw as f32 / rhs) as u8, + se: (self.se as f32 / rhs) as u8, + }; + } +} + +impl core::ops::Mul for CornerRadius { + type Output = Self; + #[inline] + fn mul(self, rhs: f32) -> Self { + Self { + nw: (self.nw as f32 * rhs) as u8, + ne: (self.ne as f32 * rhs) as u8, + sw: (self.sw as f32 * rhs) as u8, + se: (self.se as f32 * rhs) as u8, + } + } +} + +impl core::ops::MulAssign for CornerRadius { + #[inline] + fn mul_assign(&mut self, rhs: f32) { + *self = Self { + nw: (self.nw as f32 * rhs) as u8, + ne: (self.ne as f32 * rhs) as u8, + sw: (self.sw as f32 * rhs) as u8, + se: (self.se as f32 * rhs) as u8, + }; + } +} diff --git a/crates/accshift-egui/vendor/epaint/src/corner_radius_f32.rs b/crates/accshift-egui/vendor/epaint/src/corner_radius_f32.rs new file mode 100644 index 0000000..ef6e597 --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/corner_radius_f32.rs @@ -0,0 +1,236 @@ +use crate::CornerRadius; + +/// How rounded the corners of things should be, in `f32`. +/// +/// This is used for calculations, but storage is usually done with the more compact [`CornerRadius`]. +#[derive(Copy, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct CornerRadiusF32 { + /// Radius of the rounding of the North-West (left top) corner. + pub nw: f32, + + /// Radius of the rounding of the North-East (right top) corner. + pub ne: f32, + + /// Radius of the rounding of the South-West (left bottom) corner. + pub sw: f32, + + /// Radius of the rounding of the South-East (right bottom) corner. + pub se: f32, +} + +impl From for CornerRadiusF32 { + #[inline] + fn from(cr: CornerRadius) -> Self { + Self { + nw: cr.nw as f32, + ne: cr.ne as f32, + sw: cr.sw as f32, + se: cr.se as f32, + } + } +} + +impl From for CornerRadius { + #[inline] + fn from(cr: CornerRadiusF32) -> Self { + Self { + nw: cr.nw.round() as u8, + ne: cr.ne.round() as u8, + sw: cr.sw.round() as u8, + se: cr.se.round() as u8, + } + } +} + +impl Default for CornerRadiusF32 { + #[inline] + fn default() -> Self { + Self::ZERO + } +} + +impl From for CornerRadiusF32 { + #[inline] + fn from(radius: f32) -> Self { + Self { + nw: radius, + ne: radius, + sw: radius, + se: radius, + } + } +} + +impl CornerRadiusF32 { + /// No rounding on any corner. + pub const ZERO: Self = Self { + nw: 0.0, + ne: 0.0, + sw: 0.0, + se: 0.0, + }; + + /// Same rounding on all four corners. + #[inline] + pub const fn same(radius: f32) -> Self { + Self { + nw: radius, + ne: radius, + sw: radius, + se: radius, + } + } + + /// Do all corners have the same rounding? + #[inline] + pub fn is_same(&self) -> bool { + self.nw == self.ne && self.nw == self.sw && self.nw == self.se + } + + /// Make sure each corner has a rounding of at least this. + #[inline] + pub fn at_least(&self, min: f32) -> Self { + Self { + nw: self.nw.max(min), + ne: self.ne.max(min), + sw: self.sw.max(min), + se: self.se.max(min), + } + } + + /// Make sure each corner has a rounding of at most this. + #[inline] + pub fn at_most(&self, max: f32) -> Self { + Self { + nw: self.nw.min(max), + ne: self.ne.min(max), + sw: self.sw.min(max), + se: self.se.min(max), + } + } +} + +impl core::ops::Add for CornerRadiusF32 { + type Output = Self; + #[inline] + fn add(self, rhs: Self) -> Self { + Self { + nw: self.nw + rhs.nw, + ne: self.ne + rhs.ne, + sw: self.sw + rhs.sw, + se: self.se + rhs.se, + } + } +} + +impl core::ops::AddAssign for CornerRadiusF32 { + #[inline] + fn add_assign(&mut self, rhs: Self) { + *self = Self { + nw: self.nw + rhs.nw, + ne: self.ne + rhs.ne, + sw: self.sw + rhs.sw, + se: self.se + rhs.se, + }; + } +} + +impl core::ops::AddAssign for CornerRadiusF32 { + #[inline] + fn add_assign(&mut self, rhs: f32) { + *self = Self { + nw: self.nw + rhs, + ne: self.ne + rhs, + sw: self.sw + rhs, + se: self.se + rhs, + }; + } +} + +impl core::ops::Sub for CornerRadiusF32 { + type Output = Self; + #[inline] + fn sub(self, rhs: Self) -> Self { + Self { + nw: self.nw - rhs.nw, + ne: self.ne - rhs.ne, + sw: self.sw - rhs.sw, + se: self.se - rhs.se, + } + } +} + +impl core::ops::SubAssign for CornerRadiusF32 { + #[inline] + fn sub_assign(&mut self, rhs: Self) { + *self = Self { + nw: self.nw - rhs.nw, + ne: self.ne - rhs.ne, + sw: self.sw - rhs.sw, + se: self.se - rhs.se, + }; + } +} + +impl core::ops::SubAssign for CornerRadiusF32 { + #[inline] + fn sub_assign(&mut self, rhs: f32) { + *self = Self { + nw: self.nw - rhs, + ne: self.ne - rhs, + sw: self.sw - rhs, + se: self.se - rhs, + }; + } +} + +impl core::ops::Div for CornerRadiusF32 { + type Output = Self; + #[inline] + fn div(self, rhs: f32) -> Self { + Self { + nw: self.nw / rhs, + ne: self.ne / rhs, + sw: self.sw / rhs, + se: self.se / rhs, + } + } +} + +impl core::ops::DivAssign for CornerRadiusF32 { + #[inline] + fn div_assign(&mut self, rhs: f32) { + *self = Self { + nw: self.nw / rhs, + ne: self.ne / rhs, + sw: self.sw / rhs, + se: self.se / rhs, + }; + } +} + +impl core::ops::Mul for CornerRadiusF32 { + type Output = Self; + #[inline] + fn mul(self, rhs: f32) -> Self { + Self { + nw: self.nw * rhs, + ne: self.ne * rhs, + sw: self.sw * rhs, + se: self.se * rhs, + } + } +} + +impl core::ops::MulAssign for CornerRadiusF32 { + #[inline] + fn mul_assign(&mut self, rhs: f32) { + *self = Self { + nw: self.nw * rhs, + ne: self.ne * rhs, + sw: self.sw * rhs, + se: self.se * rhs, + }; + } +} diff --git a/crates/accshift-egui/vendor/epaint/src/direction.rs b/crates/accshift-egui/vendor/epaint/src/direction.rs new file mode 100644 index 0000000..b2f317c --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/direction.rs @@ -0,0 +1,27 @@ +/// A cardinal direction, one of [`LeftToRight`](Direction::LeftToRight), [`RightToLeft`](Direction::RightToLeft), [`TopDown`](Direction::TopDown), [`BottomUp`](Direction::BottomUp). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum Direction { + LeftToRight, + RightToLeft, + TopDown, + BottomUp, +} + +impl Direction { + #[inline(always)] + pub fn is_horizontal(self) -> bool { + match self { + Self::LeftToRight | Self::RightToLeft => true, + Self::TopDown | Self::BottomUp => false, + } + } + + #[inline(always)] + pub fn is_vertical(self) -> bool { + match self { + Self::LeftToRight | Self::RightToLeft => false, + Self::TopDown | Self::BottomUp => true, + } + } +} diff --git a/crates/accshift-egui/vendor/epaint/src/image.rs b/crates/accshift-egui/vendor/epaint/src/image.rs new file mode 100644 index 0000000..92890e1 --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/image.rs @@ -0,0 +1,496 @@ +use ecolor::linear_f32_from_linear_u8; +use emath::Vec2; + +use crate::{Color32, textures::TextureOptions}; +use std::sync::Arc; + +/// An image stored in RAM. +/// +/// To load an image file, see [`ColorImage::from_rgba_unmultiplied`]. +/// +/// This is currently an enum with only one variant, but more image types may be added in the future. +/// +/// See also: [`ColorImage`]. +#[derive(Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum ImageData { + /// RGBA image. + Color(Arc), +} + +impl ImageData { + pub fn size(&self) -> [usize; 2] { + match self { + Self::Color(image) => image.size, + } + } + + pub fn width(&self) -> usize { + self.size()[0] + } + + pub fn height(&self) -> usize { + self.size()[1] + } + + pub fn bytes_per_pixel(&self) -> usize { + match self { + Self::Color(_) => 4, + } + } +} + +// ---------------------------------------------------------------------------- + +/// A 2D RGBA color image in RAM. +#[derive(Clone, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct ColorImage { + /// width, height in texels. + pub size: [usize; 2], + + /// Size of the original SVG image (if any), or just the texel size of the image. + pub source_size: Vec2, + + /// The pixels, row by row, from top to bottom. + pub pixels: Vec, +} + +impl ColorImage { + /// Create an image filled with the given color. + pub fn new(size: [usize; 2], pixels: Vec) -> Self { + debug_assert!( + size[0] * size[1] == pixels.len(), + "size: {size:?}, pixels.len(): {}", + pixels.len() + ); + Self { + size, + source_size: Vec2::new(size[0] as f32, size[1] as f32), + pixels, + } + } + + /// Create an image filled with the given color. + pub fn filled(size: [usize; 2], color: Color32) -> Self { + Self { + size, + source_size: Vec2::new(size[0] as f32, size[1] as f32), + pixels: vec![color; size[0] * size[1]], + } + } + + /// Create a [`ColorImage`] from flat un-multiplied RGBA data. + /// + /// This is usually what you want to use after having loaded an image file. + /// + /// Panics if `size[0] * size[1] * 4 != rgba.len()`. + /// + /// ## Example using the [`image`](crates.io/crates/image) crate: + /// ``` ignore + /// fn load_image_from_path(path: &std::path::Path) -> Result { + /// let image = image::io::Reader::open(path)?.decode()?; + /// let size = [image.width() as _, image.height() as _]; + /// let image_buffer = image.to_rgba8(); + /// let pixels = image_buffer.as_flat_samples(); + /// Ok(egui::ColorImage::from_rgba_unmultiplied( + /// size, + /// pixels.as_slice(), + /// )) + /// } + /// + /// fn load_image_from_memory(image_data: &[u8]) -> Result { + /// let image = image::load_from_memory(image_data)?; + /// let size = [image.width() as _, image.height() as _]; + /// let image_buffer = image.to_rgba8(); + /// let pixels = image_buffer.as_flat_samples(); + /// Ok(ColorImage::from_rgba_unmultiplied( + /// size, + /// pixels.as_slice(), + /// )) + /// } + /// ``` + pub fn from_rgba_unmultiplied(size: [usize; 2], rgba: &[u8]) -> Self { + assert_eq!( + size[0] * size[1] * 4, + rgba.len(), + "size: {:?}, rgba.len(): {}", + size, + rgba.len() + ); + let pixels = rgba + .chunks_exact(4) + .map(|p| Color32::from_rgba_unmultiplied(p[0], p[1], p[2], p[3])) + .collect(); + Self::new(size, pixels) + } + + pub fn from_rgba_premultiplied(size: [usize; 2], rgba: &[u8]) -> Self { + assert_eq!( + size[0] * size[1] * 4, + rgba.len(), + "size: {:?}, rgba.len(): {}", + size, + rgba.len() + ); + let pixels = rgba + .chunks_exact(4) + .map(|p| Color32::from_rgba_premultiplied(p[0], p[1], p[2], p[3])) + .collect(); + Self::new(size, pixels) + } + + /// Create a [`ColorImage`] from flat opaque gray data. + /// + /// Panics if `size[0] * size[1] != gray.len()`. + pub fn from_gray(size: [usize; 2], gray: &[u8]) -> Self { + assert_eq!( + size[0] * size[1], + gray.len(), + "size: {:?}, gray.len(): {}", + size, + gray.len() + ); + let pixels = gray.iter().map(|p| Color32::from_gray(*p)).collect(); + Self::new(size, pixels) + } + + /// Alternative method to `from_gray`. + /// Create a [`ColorImage`] from iterator over flat opaque gray data. + /// + /// Panics if `size[0] * size[1] != gray_iter.len()`. + #[doc(alias = "from_grey_iter")] + pub fn from_gray_iter(size: [usize; 2], gray_iter: impl Iterator) -> Self { + let pixels: Vec<_> = gray_iter.map(Color32::from_gray).collect(); + assert_eq!( + size[0] * size[1], + pixels.len(), + "size: {:?}, pixels.len(): {}", + size, + pixels.len() + ); + Self::new(size, pixels) + } + + /// A view of the underlying data as `&[u8]` + #[cfg(feature = "bytemuck")] + pub fn as_raw(&self) -> &[u8] { + bytemuck::cast_slice(&self.pixels) + } + + /// A view of the underlying data as `&mut [u8]` + #[cfg(feature = "bytemuck")] + pub fn as_raw_mut(&mut self) -> &mut [u8] { + bytemuck::cast_slice_mut(&mut self.pixels) + } + + /// Create a [`ColorImage`] from flat RGB data. + /// + /// This is what you want to use after having loaded an image file (and if + /// you are ignoring the alpha channel - considering it to always be 0xff) + /// + /// Panics if `size[0] * size[1] * 3 != rgb.len()`. + pub fn from_rgb(size: [usize; 2], rgb: &[u8]) -> Self { + assert_eq!( + size[0] * size[1] * 3, + rgb.len(), + "size: {:?}, rgb.len(): {}", + size, + rgb.len() + ); + let pixels = rgb + .chunks_exact(3) + .map(|p| Color32::from_rgb(p[0], p[1], p[2])) + .collect(); + Self::new(size, pixels) + } + + /// An example color image, useful for tests. + pub fn example() -> Self { + let width = 128; + let height = 64; + let mut img = Self::filled([width, height], Color32::TRANSPARENT); + for y in 0..height { + for x in 0..width { + let h = x as f32 / width as f32; + let s = 1.0; + let v = 1.0; + let a = y as f32 / height as f32; + img[(x, y)] = crate::Hsva { h, s, v, a }.into(); + } + } + img + } + + /// Set the source size of e.g. the original SVG image. + #[inline] + pub fn with_source_size(mut self, source_size: Vec2) -> Self { + self.source_size = source_size; + self + } + + #[inline] + pub fn width(&self) -> usize { + self.size[0] + } + + #[inline] + pub fn height(&self) -> usize { + self.size[1] + } + + /// Create a new image from a patch of the current image. + /// + /// This method is especially convenient for screenshotting a part of the app + /// since `region` can be interpreted as screen coordinates of the entire screenshot if `pixels_per_point` is provided for the native application. + /// The floats of [`emath::Rect`] are cast to usize, rounding them down in order to interpret them as indices to the image data. + /// + /// Panics if `region.min.x > region.max.x || region.min.y > region.max.y`, or if a region larger than the image is passed. + pub fn region(&self, region: &emath::Rect, pixels_per_point: Option) -> Self { + let pixels_per_point = pixels_per_point.unwrap_or(1.0); + let min_x = (region.min.x * pixels_per_point) as usize; + let max_x = (region.max.x * pixels_per_point) as usize; + let min_y = (region.min.y * pixels_per_point) as usize; + let max_y = (region.max.y * pixels_per_point) as usize; + assert!( + min_x <= max_x && min_y <= max_y, + "Screenshot region is invalid: {region:?}" + ); + let width = max_x - min_x; + let height = max_y - min_y; + let mut output = Vec::with_capacity(width * height); + let row_stride = self.size[0]; + + for row in min_y..max_y { + output.extend_from_slice( + &self.pixels[row * row_stride + min_x..row * row_stride + max_x], + ); + } + Self::new([width, height], output) + } + + /// Clone a sub-region as a new image. + pub fn region_by_pixels(&self, [x, y]: [usize; 2], [w, h]: [usize; 2]) -> Self { + assert!( + x + w <= self.width(), + "x + w should be <= self.width(), but x: {}, w: {}, width: {}", + x, + w, + self.width() + ); + assert!( + y + h <= self.height(), + "y + h should be <= self.height(), but y: {}, h: {}, height: {}", + y, + h, + self.height() + ); + + let mut pixels = Vec::with_capacity(w * h); + for y in y..y + h { + let offset = y * self.width() + x; + pixels.extend(&self.pixels[offset..(offset + w)]); + } + assert_eq!( + pixels.len(), + w * h, + "pixels.len should be w * h, but got {}", + pixels.len() + ); + Self::new([w, h], pixels) + } +} + +impl core::ops::Index<(usize, usize)> for ColorImage { + type Output = Color32; + + #[inline] + fn index(&self, (x, y): (usize, usize)) -> &Color32 { + let [w, h] = self.size; + assert!(x < w && y < h, "x: {x}, y: {y}, w: {w}, h: {h}"); + &self.pixels[y * w + x] + } +} + +impl core::ops::IndexMut<(usize, usize)> for ColorImage { + #[inline] + fn index_mut(&mut self, (x, y): (usize, usize)) -> &mut Color32 { + let [w, h] = self.size; + assert!(x < w && y < h, "x: {x}, y: {y}, w: {w}, h: {h}"); + &mut self.pixels[y * w + x] + } +} + +impl From for ImageData { + #[inline(always)] + fn from(image: ColorImage) -> Self { + Self::Color(Arc::new(image)) + } +} + +impl From> for ImageData { + #[inline] + fn from(image: Arc) -> Self { + Self::Color(image) + } +} + +impl core::fmt::Debug for ColorImage { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("ColorImage") + .field("size", &self.size) + .field("pixel-count", &self.pixels.len()) + .finish_non_exhaustive() + } +} + +// ---------------------------------------------------------------------------- + +/// How to convert font coverage values into alpha and color values. +/// +/// epaint stores all glyphs in the font atlas as white (with varying opacity), +/// so that egui can reuse the same glyph for different text colors +/// (with a simple color multiplication in the shader). +/// +/// Because of this simplification, we need to apply a non-linear +/// ramp to the glyph colors before writing them into the font atlas, +/// as a way to compensate. +/// +/// This whole thing is less than rigorous. +/// +/// It would be better to either render all text colors into the font atlas +/// (which would require more atlas space, but would allow for more accurate rendering of colored text and emojis), +/// or do the color compensation in the shader, based on the active text color. +/// +/// When experimenting, use to compare to a ground truth. +/// +/// See for related analysis. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum FontColorTransferFunction { + /// Use the raw RGBA values from the font rasterizer, without any conversion. + /// + /// This is the required mode for colored emojis etc. + /// + /// This mode looks good for black-on-white text, i.e. light mode. + Off, + + /// `alpha = coverage^gamma`. + /// + /// Gamma=1 looks good for black-on-white text, i.e. light mode. + Gamma(f32), + + /// `alpha = 2 * coverage - coverage^2` + /// + /// This looks good for white-on-black text, i.e. dark mode. + /// + /// Very similar to a gamma of 0.5, but produces sharper text. + /// See for a comparison to gamma=0.5. + #[default] + TwoCoverageMinusCoverageSq, +} + +impl FontColorTransferFunction { + /// A good-looking default for light mode (black-on-white text). + pub const LIGHT_MODE_DEFAULT: Self = Self::Off; + + /// A good-looking default for dark mode (white-on-black text). + pub const DARK_MODE_DEFAULT: Self = Self::TwoCoverageMinusCoverageSq; + + /// How to convert a white color written by the font rasterizer + /// into a color to be written into the font atlas. + #[inline(always)] + pub fn to_atlas_color(self, input_color: Color32) -> Color32 { + match self { + Self::Off | Self::Gamma(1.0) => input_color, + + Self::Gamma(gamma) => { + let coverage = linear_f32_from_linear_u8(input_color.a()); + let alpha = coverage.powf(gamma); + Color32::from_white_alpha(ecolor::linear_u8_from_linear_f32(alpha)) + } + + Self::TwoCoverageMinusCoverageSq => { + let coverage = linear_f32_from_linear_u8(input_color.a()); + let alpha = 2.0 * coverage - coverage * coverage; + Color32::from_white_alpha(ecolor::linear_u8_from_linear_f32(alpha)) + } + } + } + + /// Convert coverage to alpha. + #[inline(always)] + pub fn alpha_from_coverage(self, coverage: f32) -> f32 { + let coverage = coverage.clamp(0.0, 1.0); + match self { + Self::Off | Self::Gamma(1.0) => coverage, + Self::Gamma(gamma) => coverage.powf(gamma), + Self::TwoCoverageMinusCoverageSq => 2.0 * coverage - coverage * coverage, + } + } + + #[inline(always)] + pub fn color_from_coverage(self, coverage: f32) -> Color32 { + let alpha = self.alpha_from_coverage(coverage); + Color32::from_white_alpha(ecolor::linear_u8_from_linear_f32(alpha)) + } + + /// Convert this into the closest gamma exponent + pub fn to_gamma(self) -> f32 { + match self { + Self::Off => 1.0, + Self::Gamma(gamma) => gamma, + Self::TwoCoverageMinusCoverageSq => 0.5, // approximately the same + } + } +} + +// ---------------------------------------------------------------------------- + +/// A change to an image. +/// +/// Either a whole new image, or an update to a rectangular region of it. +#[derive(Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[must_use = "The painter must take care of this"] +pub struct ImageDelta { + /// What to set the texture to. + /// + /// If [`Self::pos`] is `None`, this describes the whole texture. + /// + /// If [`Self::pos`] is `Some`, this describes a patch of the whole image starting at [`Self::pos`]. + pub image: ImageData, + + pub options: TextureOptions, + + /// If `None`, set the whole texture to [`Self::image`]. + /// + /// If `Some(pos)`, update a sub-region of an already allocated texture with the patch in [`Self::image`]. + pub pos: Option<[usize; 2]>, +} + +impl ImageDelta { + /// Update the whole texture. + pub fn full(image: impl Into, options: TextureOptions) -> Self { + Self { + image: image.into(), + options, + pos: None, + } + } + + /// Update a sub-region of an existing texture. + pub fn partial(pos: [usize; 2], image: impl Into, options: TextureOptions) -> Self { + Self { + image: image.into(), + options, + pos: Some(pos), + } + } + + /// Is this affecting the whole texture? + /// If `false`, this is a partial (sub-region) update. + pub fn is_whole(&self) -> bool { + self.pos.is_none() + } +} diff --git a/crates/accshift-egui/vendor/epaint/src/lib.rs b/crates/accshift-egui/vendor/epaint/src/lib.rs new file mode 100644 index 0000000..bff5c79 --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/lib.rs @@ -0,0 +1,161 @@ +//! A simple 2D graphics library for turning simple 2D shapes and text into textured triangles. +//! +//! Made for [`egui`](https://github.com/emilk/egui/). +//! +//! Create some [`Shape`]:s and pass them to [`Tessellator::tessellate_shapes`] to generate [`Mesh`]:es +//! that you can then paint using some graphics API of your choice (e.g. OpenGL). +//! +//! ## Coordinate system +//! The left-top corner of the screen is `(0.0, 0.0)`, +//! with X increasing to the right and Y increasing downwards. +//! +//! `epaint` uses logical _points_ as its coordinate system. +//! Those related to physical _pixels_ by the `pixels_per_point` scale factor. +//! For example, a high-dpi screen can have `pixels_per_point = 2.0`, +//! meaning there are two physical screen pixels for each logical point. +//! +//! Angles are in radians, and are measured clockwise from the X-axis, which has angle=0. +//! +//! ## Feature flags +#![cfg_attr(feature = "document-features", doc = document_features::document_features!())] +//! + +#![expect(clippy::float_cmp)] +#![expect(clippy::manual_range_contains)] + +mod brush; +pub mod color; +mod corner_radius; +mod corner_radius_f32; +mod direction; +pub mod image; +mod margin; +mod margin_f32; +mod mesh; +pub mod mutex; +mod shadow; +pub mod shape_transform; +mod shapes; +pub mod stats; +mod stroke; +pub mod tessellator; +pub mod text; +mod texture_atlas; +mod texture_handle; +pub mod textures; +pub mod util; +mod viewport; + +pub use self::{ + brush::Brush, + color::ColorMode, + corner_radius::CornerRadius, + corner_radius_f32::CornerRadiusF32, + direction::Direction, + image::{ColorImage, FontColorTransferFunction, ImageData, ImageDelta}, + margin::Margin, + margin_f32::*, + mesh::{Mesh, Mesh16, Vertex}, + shadow::Shadow, + shapes::{ + CircleShape, CubicBezierShape, EllipseShape, PaintCallback, PaintCallbackInfo, PathShape, + QuadraticBezierShape, RectShape, Shape, TextShape, + }, + stats::PaintStats, + stroke::{PathStroke, Stroke, StrokeKind}, + tessellator::{TessellationOptions, Tessellator}, + text::{FontFamily, FontId, Fonts, FontsView, Galley, TextOptions}, + texture_atlas::TextureAtlas, + texture_handle::TextureHandle, + textures::TextureManager, + viewport::ViewportInPixels, +}; + +pub use ecolor::{Color32, Hsva, HsvaGamma, Rgba}; +pub use emath::{Pos2, Rect, Vec2, pos2, vec2}; + +pub use ecolor; +pub use emath; + +#[cfg(feature = "color-hex")] +pub use ecolor::hex_color; + +/// The UV coordinate of a white region of the texture mesh. +/// +/// The default egui texture has the top-left corner pixel fully white. +/// You need need use a clamping texture sampler for this to work +/// (so it doesn't do bilinear blending with bottom right corner). +pub const WHITE_UV: emath::Pos2 = emath::pos2(0.0, 0.0); + +/// What texture to use in a [`Mesh`] mesh. +/// +/// If you don't want to use a texture, use `TextureId::Managed(0)` and the [`WHITE_UV`] for uv-coord. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum TextureId { + /// Textures allocated using [`TextureManager`]. + /// + /// The first texture (`TextureId::Managed(0)`) is used for the font data. + Managed(u64), + + /// Your own texture, defined in any which way you want. + /// The backend renderer will presumably use this to look up what texture to use. + User(u64), +} + +impl Default for TextureId { + /// The epaint font texture. + fn default() -> Self { + Self::Managed(0) + } +} + +/// A [`Shape`] within a clip rectangle. +/// +/// Everything is using logical points. +#[derive(Clone, Debug, PartialEq)] +pub struct ClippedShape { + /// Clip / scissor rectangle. + /// Only show the part of the [`Shape`] that falls within this. + pub clip_rect: emath::Rect, + + /// The shape + pub shape: Shape, +} + +impl ClippedShape { + /// Transform (move/scale) the shape in-place. + /// + /// If using a [`PaintCallback`], note that only the rect is scaled as opposed + /// to other shapes where the stroke is also scaled. + pub fn transform(&mut self, transform: emath::TSTransform) { + let Self { clip_rect, shape } = self; + *clip_rect = transform * *clip_rect; + shape.transform(transform); + } +} + +/// A [`Mesh`] or [`PaintCallback`] within a clip rectangle. +/// +/// Everything is using logical points. +#[derive(Clone, Debug)] +pub struct ClippedPrimitive { + /// Clip / scissor rectangle. + /// Only show the part of the [`Mesh`] that falls within this. + pub clip_rect: emath::Rect, + + /// What to paint - either a [`Mesh`] or a [`PaintCallback`]. + pub primitive: Primitive, +} + +/// A rendering primitive - either a [`Mesh`] or a [`PaintCallback`]. +#[derive(Clone, Debug)] +pub enum Primitive { + Mesh(Mesh), + Callback(PaintCallback), +} + +// --------------------------------------------------------------------------- + +/// Was epaint compiled with the `rayon` feature? +pub const HAS_RAYON: bool = cfg!(feature = "rayon"); diff --git a/crates/accshift-egui/vendor/epaint/src/margin.rs b/crates/accshift-egui/vendor/epaint/src/margin.rs new file mode 100644 index 0000000..765208e --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/margin.rs @@ -0,0 +1,280 @@ +use emath::{Rect, Vec2, vec2}; + +/// A value for all four sides of a rectangle, +/// often used to express padding or spacing. +/// +/// Can be added and subtracted to/from [`Rect`]s. +/// +/// Negative margins are possible, but may produce weird behavior. +/// Use with care. +/// +/// All values are stored as [`i8`] to keep the size of [`Margin`] small. +/// If you want floats, use [`crate::MarginF32`] instead. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct Margin { + pub left: i8, + pub right: i8, + pub top: i8, + pub bottom: i8, +} + +impl Margin { + pub const ZERO: Self = Self { + left: 0, + right: 0, + top: 0, + bottom: 0, + }; + + /// The same margin on every side. + #[doc(alias = "symmetric")] + #[inline] + pub const fn same(margin: i8) -> Self { + Self { + left: margin, + right: margin, + top: margin, + bottom: margin, + } + } + + /// Margins with the same size on opposing sides + #[inline] + pub const fn symmetric(x: i8, y: i8) -> Self { + Self { + left: x, + right: x, + top: y, + bottom: y, + } + } + + /// Left margin, as `f32` + #[inline] + pub const fn leftf(self) -> f32 { + self.left as _ + } + + /// Right margin, as `f32` + #[inline] + pub const fn rightf(self) -> f32 { + self.right as _ + } + + /// Top margin, as `f32` + #[inline] + pub const fn topf(self) -> f32 { + self.top as _ + } + + /// Bottom margin, as `f32` + #[inline] + pub const fn bottomf(self) -> f32 { + self.bottom as _ + } + + /// Total margins on both sides + #[inline] + pub fn sum(self) -> Vec2 { + vec2(self.leftf() + self.rightf(), self.topf() + self.bottomf()) + } + + #[inline] + pub const fn left_top(self) -> Vec2 { + vec2(self.leftf(), self.topf()) + } + + #[inline] + pub const fn right_bottom(self) -> Vec2 { + vec2(self.rightf(), self.bottomf()) + } + + /// Are the margin on every side the same? + #[doc(alias = "symmetric")] + #[inline] + pub const fn is_same(self) -> bool { + self.left == self.right && self.left == self.top && self.left == self.bottom + } +} + +impl From for Margin { + #[inline] + fn from(v: i8) -> Self { + Self::same(v) + } +} + +impl From for Margin { + #[inline] + fn from(v: f32) -> Self { + Self::same(v.round() as _) + } +} + +impl From for Margin { + #[inline] + fn from(v: Vec2) -> Self { + Self::symmetric(v.x.round() as _, v.y.round() as _) + } +} + +/// `Margin + Margin` +impl core::ops::Add for Margin { + type Output = Self; + + #[inline] + fn add(self, other: Self) -> Self { + Self { + left: self.left.saturating_add(other.left), + right: self.right.saturating_add(other.right), + top: self.top.saturating_add(other.top), + bottom: self.bottom.saturating_add(other.bottom), + } + } +} + +/// `Margin + i8` +impl core::ops::Add for Margin { + type Output = Self; + + #[inline] + fn add(self, v: i8) -> Self { + Self { + left: self.left.saturating_add(v), + right: self.right.saturating_add(v), + top: self.top.saturating_add(v), + bottom: self.bottom.saturating_add(v), + } + } +} + +/// `Margin += i8` +impl core::ops::AddAssign for Margin { + #[inline] + fn add_assign(&mut self, v: i8) { + *self = *self + v; + } +} + +/// `Margin * f32` +impl core::ops::Mul for Margin { + type Output = Self; + + #[inline] + fn mul(self, v: f32) -> Self { + Self { + left: (self.leftf() * v).round() as _, + right: (self.rightf() * v).round() as _, + top: (self.topf() * v).round() as _, + bottom: (self.bottomf() * v).round() as _, + } + } +} + +/// `Margin *= f32` +impl core::ops::MulAssign for Margin { + #[inline] + fn mul_assign(&mut self, v: f32) { + *self = *self * v; + } +} + +/// `Margin / f32` +impl core::ops::Div for Margin { + type Output = Self; + + #[inline] + fn div(self, v: f32) -> Self { + #![expect(clippy::suspicious_arithmetic_impl)] + self * v.recip() + } +} + +/// `Margin /= f32` +impl core::ops::DivAssign for Margin { + #[inline] + fn div_assign(&mut self, v: f32) { + *self = *self / v; + } +} + +/// `Margin - Margin` +impl core::ops::Sub for Margin { + type Output = Self; + + #[inline] + fn sub(self, other: Self) -> Self { + Self { + left: self.left.saturating_sub(other.left), + right: self.right.saturating_sub(other.right), + top: self.top.saturating_sub(other.top), + bottom: self.bottom.saturating_sub(other.bottom), + } + } +} + +/// `Margin - i8` +impl core::ops::Sub for Margin { + type Output = Self; + + #[inline] + fn sub(self, v: i8) -> Self { + Self { + left: self.left.saturating_sub(v), + right: self.right.saturating_sub(v), + top: self.top.saturating_sub(v), + bottom: self.bottom.saturating_sub(v), + } + } +} + +/// `Margin -= i8` +impl core::ops::SubAssign for Margin { + #[inline] + fn sub_assign(&mut self, v: i8) { + *self = *self - v; + } +} + +/// `Rect + Margin` +impl core::ops::Add for Rect { + type Output = Self; + + #[inline] + fn add(self, margin: Margin) -> Self { + Self::from_min_max( + self.min - margin.left_top(), + self.max + margin.right_bottom(), + ) + } +} + +/// `Rect += Margin` +impl core::ops::AddAssign for Rect { + #[inline] + fn add_assign(&mut self, margin: Margin) { + *self = *self + margin; + } +} + +/// `Rect - Margin` +impl core::ops::Sub for Rect { + type Output = Self; + + #[inline] + fn sub(self, margin: Margin) -> Self { + Self::from_min_max( + self.min + margin.left_top(), + self.max - margin.right_bottom(), + ) + } +} + +/// `Rect -= Margin` +impl core::ops::SubAssign for Rect { + #[inline] + fn sub_assign(&mut self, margin: Margin) { + *self = *self - margin; + } +} diff --git a/crates/accshift-egui/vendor/epaint/src/margin_f32.rs b/crates/accshift-egui/vendor/epaint/src/margin_f32.rs new file mode 100644 index 0000000..793e5de --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/margin_f32.rs @@ -0,0 +1,287 @@ +use emath::{Rect, Vec2, vec2}; + +use crate::Margin; + +/// A value for all four sides of a rectangle, +/// often used to express padding or spacing. +/// +/// Can be added and subtracted to/from [`Rect`]s. +/// +/// For storage, use [`crate::Margin`] instead. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct MarginF32 { + pub left: f32, + pub right: f32, + pub top: f32, + pub bottom: f32, +} + +impl From for MarginF32 { + #[inline] + fn from(margin: Margin) -> Self { + Self { + left: margin.left as _, + right: margin.right as _, + top: margin.top as _, + bottom: margin.bottom as _, + } + } +} + +impl From for Margin { + #[inline] + fn from(marginf: MarginF32) -> Self { + Self { + left: marginf.left as _, + right: marginf.right as _, + top: marginf.top as _, + bottom: marginf.bottom as _, + } + } +} + +impl MarginF32 { + pub const ZERO: Self = Self { + left: 0.0, + right: 0.0, + top: 0.0, + bottom: 0.0, + }; + + /// The same margin on every side. + #[doc(alias = "symmetric")] + #[inline] + pub const fn same(margin: f32) -> Self { + Self { + left: margin, + right: margin, + top: margin, + bottom: margin, + } + } + + /// Margins with the same size on opposing sides + #[inline] + pub const fn symmetric(x: f32, y: f32) -> Self { + Self { + left: x, + right: x, + top: y, + bottom: y, + } + } + + /// Total margins on both sides + #[inline] + pub fn sum(&self) -> Vec2 { + vec2(self.left + self.right, self.top + self.bottom) + } + + #[inline] + pub const fn left_top(&self) -> Vec2 { + vec2(self.left, self.top) + } + + #[inline] + pub const fn right_bottom(&self) -> Vec2 { + vec2(self.right, self.bottom) + } + + /// Are the margin on every side the same? + #[doc(alias = "symmetric")] + #[inline] + pub fn is_same(&self) -> bool { + self.left == self.right && self.left == self.top && self.left == self.bottom + } +} + +impl From for MarginF32 { + #[inline] + fn from(v: f32) -> Self { + Self::same(v) + } +} + +impl From for MarginF32 { + #[inline] + fn from(v: Vec2) -> Self { + Self::symmetric(v.x, v.y) + } +} + +/// `MarginF32 + MarginF32` +impl core::ops::Add for MarginF32 { + type Output = Self; + + #[inline] + fn add(self, other: Self) -> Self { + Self { + left: self.left + other.left, + right: self.right + other.right, + top: self.top + other.top, + bottom: self.bottom + other.bottom, + } + } +} + +/// `MarginF32 + f32` +impl core::ops::Add for MarginF32 { + type Output = Self; + + #[inline] + fn add(self, v: f32) -> Self { + Self { + left: self.left + v, + right: self.right + v, + top: self.top + v, + bottom: self.bottom + v, + } + } +} + +/// `Margind += f32` +impl core::ops::AddAssign for MarginF32 { + #[inline] + fn add_assign(&mut self, v: f32) { + self.left += v; + self.right += v; + self.top += v; + self.bottom += v; + } +} + +/// `MarginF32 * f32` +impl core::ops::Mul for MarginF32 { + type Output = Self; + + #[inline] + fn mul(self, v: f32) -> Self { + Self { + left: self.left * v, + right: self.right * v, + top: self.top * v, + bottom: self.bottom * v, + } + } +} + +/// `MarginF32 *= f32` +impl core::ops::MulAssign for MarginF32 { + #[inline] + fn mul_assign(&mut self, v: f32) { + self.left *= v; + self.right *= v; + self.top *= v; + self.bottom *= v; + } +} + +/// `MarginF32 / f32` +impl core::ops::Div for MarginF32 { + type Output = Self; + + #[inline] + fn div(self, v: f32) -> Self { + Self { + left: self.left / v, + right: self.right / v, + top: self.top / v, + bottom: self.bottom / v, + } + } +} + +/// `MarginF32 /= f32` +impl core::ops::DivAssign for MarginF32 { + #[inline] + fn div_assign(&mut self, v: f32) { + self.left /= v; + self.right /= v; + self.top /= v; + self.bottom /= v; + } +} + +/// `MarginF32 - MarginF32` +impl core::ops::Sub for MarginF32 { + type Output = Self; + + #[inline] + fn sub(self, other: Self) -> Self { + Self { + left: self.left - other.left, + right: self.right - other.right, + top: self.top - other.top, + bottom: self.bottom - other.bottom, + } + } +} + +/// `MarginF32 - f32` +impl core::ops::Sub for MarginF32 { + type Output = Self; + + #[inline] + fn sub(self, v: f32) -> Self { + Self { + left: self.left - v, + right: self.right - v, + top: self.top - v, + bottom: self.bottom - v, + } + } +} + +/// `MarginF32 -= f32` +impl core::ops::SubAssign for MarginF32 { + #[inline] + fn sub_assign(&mut self, v: f32) { + self.left -= v; + self.right -= v; + self.top -= v; + self.bottom -= v; + } +} + +/// `Rect + MarginF32` +impl core::ops::Add for Rect { + type Output = Self; + + #[inline] + fn add(self, margin: MarginF32) -> Self { + Self::from_min_max( + self.min - margin.left_top(), + self.max + margin.right_bottom(), + ) + } +} + +/// `Rect += MarginF32` +impl core::ops::AddAssign for Rect { + #[inline] + fn add_assign(&mut self, margin: MarginF32) { + *self = *self + margin; + } +} + +/// `Rect - MarginF32` +impl core::ops::Sub for Rect { + type Output = Self; + + #[inline] + fn sub(self, margin: MarginF32) -> Self { + Self::from_min_max( + self.min + margin.left_top(), + self.max - margin.right_bottom(), + ) + } +} + +/// `Rect -= MarginF32` +impl core::ops::SubAssign for Rect { + #[inline] + fn sub_assign(&mut self, margin: MarginF32) { + *self = *self - margin; + } +} diff --git a/crates/accshift-egui/vendor/epaint/src/mesh.rs b/crates/accshift-egui/vendor/epaint/src/mesh.rs new file mode 100644 index 0000000..5ef978f --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/mesh.rs @@ -0,0 +1,359 @@ +use crate::{Color32, TextureId, WHITE_UV, emath}; +use emath::{Pos2, Rect, Rot2, TSTransform, Vec2}; + +/// The 2D vertex type. +/// +/// Should be friendly to send to GPU as is. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg(any(not(feature = "unity"), feature = "_override_unity"))] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "bytemuck", derive(bytemuck::Pod, bytemuck::Zeroable))] +pub struct Vertex { + /// Logical pixel coordinates (points). + /// (0,0) is the top left corner of the screen. + pub pos: Pos2, // 64 bit + + /// Normalized texture coordinates. + /// (0, 0) is the top left corner of the texture. + /// (1, 1) is the bottom right corner of the texture. + pub uv: Pos2, // 64 bit + + /// sRGBA with premultiplied alpha + pub color: Color32, // 32 bit +} + +impl Vertex { + /// An untextured vertex + #[inline] + pub fn untextured(pos: Pos2, color: Color32) -> Self { + Self { + pos, + uv: WHITE_UV, + color, + } + } +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg(all(feature = "unity", not(feature = "_override_unity")))] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "bytemuck", derive(bytemuck::Pod, bytemuck::Zeroable))] +pub struct Vertex { + /// Logical pixel coordinates (points). + /// (0,0) is the top left corner of the screen. + pub pos: Pos2, // 64 bit + + /// sRGBA with premultiplied alpha + pub color: Color32, // 32 bit + + /// Normalized texture coordinates. + /// (0, 0) is the top left corner of the texture. + /// (1, 1) is the bottom right corner of the texture. + pub uv: Pos2, // 64 bit +} + +/// Textured triangles in two dimensions. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct Mesh { + /// Draw as triangles (i.e. the length is always multiple of three). + /// + /// If you only support 16-bit indices you can use [`Mesh::split_to_u16`]. + /// + /// egui is NOT consistent with what winding order it uses, so turn off backface culling. + pub indices: Vec, + + /// The vertex data indexed by `indices`. + pub vertices: Vec, + + /// The texture to use when drawing these triangles. + pub texture_id: TextureId, + // TODO(emilk): bounding rectangle +} + +impl Mesh { + pub fn with_texture(texture_id: TextureId) -> Self { + Self { + texture_id, + ..Default::default() + } + } + + /// Restore to default state, but without freeing memory. + pub fn clear(&mut self) { + self.indices.clear(); + self.vertices.clear(); + self.vertices = Default::default(); + } + + /// Returns the amount of memory used by the vertices and indices. + pub fn bytes_used(&self) -> usize { + core::mem::size_of::() + + self.vertices.len() * core::mem::size_of::() + + self.indices.len() * core::mem::size_of::() + } + + /// Are all indices within the bounds of the contained vertices? + pub fn is_valid(&self) -> bool { + profiling::function_scope!(); + + if let Ok(n) = u32::try_from(self.vertices.len()) { + self.indices.iter().all(|&i| i < n) + } else { + false + } + } + + pub fn is_empty(&self) -> bool { + self.indices.is_empty() && self.vertices.is_empty() + } + + /// Iterate over the triangles of this mesh, returning vertex indices. + pub fn triangles(&self) -> impl Iterator + '_ { + self.indices + .chunks_exact(3) + .map(|chunk| [chunk[0], chunk[1], chunk[2]]) + } + + /// Calculate a bounding rectangle. + pub fn calc_bounds(&self) -> Rect { + let mut bounds = Rect::NOTHING; + for v in &self.vertices { + bounds.extend_with(v.pos); + } + bounds + } + + /// Append all the indices and vertices of `other` to `self`. + /// + /// Panics when `other` mesh has a different texture. + pub fn append(&mut self, other: Self) { + profiling::function_scope!(); + debug_assert!(other.is_valid(), "Other mesh is invalid"); + + if self.is_empty() { + *self = other; + } else { + self.append_ref(&other); + } + } + + /// Append all the indices and vertices of `other` to `self` without + /// taking ownership. + /// + /// Panics when `other` mesh has a different texture. + pub fn append_ref(&mut self, other: &Self) { + debug_assert!(other.is_valid(), "Other mesh is invalid"); + + if self.is_empty() { + self.texture_id = other.texture_id; + } else { + assert_eq!( + self.texture_id, other.texture_id, + "Can't merge Mesh using different textures" + ); + } + + let index_offset = self.vertices.len() as u32; + self.indices + .extend(other.indices.iter().map(|index| index + index_offset)); + self.vertices.extend(other.vertices.iter()); + } + + /// Add a colored vertex. + /// + /// Panics when the mesh has assigned a texture. + #[inline(always)] + pub fn colored_vertex(&mut self, pos: Pos2, color: Color32) { + debug_assert!( + self.texture_id == TextureId::default(), + "Mesh has an assigned texture" + ); + self.vertices.push(Vertex::untextured(pos, color)); + } + + /// Add a triangle. + #[inline(always)] + pub fn add_triangle(&mut self, a: u32, b: u32, c: u32) { + self.indices.extend_from_slice(&[a, b, c]); + } + + /// Make room for this many additional triangles (will reserve 3x as many indices). + /// See also `reserve_vertices`. + #[inline(always)] + pub fn reserve_triangles(&mut self, additional_triangles: usize) { + self.indices.reserve(3 * additional_triangles); + } + + /// Make room for this many additional vertices. + /// See also `reserve_triangles`. + #[inline(always)] + pub fn reserve_vertices(&mut self, additional: usize) { + self.vertices.reserve(additional); + } + + /// Rectangle with a texture and color. + #[inline(always)] + pub fn add_rect_with_uv(&mut self, rect: Rect, uv: Rect, color: Color32) { + #![expect(clippy::identity_op)] + let idx = self.vertices.len() as u32; + self.indices + .extend_from_slice(&[idx + 0, idx + 1, idx + 2, idx + 2, idx + 1, idx + 3]); + + self.vertices.extend_from_slice(&[ + Vertex { + pos: rect.left_top(), + uv: uv.left_top(), + color, + }, + Vertex { + pos: rect.right_top(), + uv: uv.right_top(), + color, + }, + Vertex { + pos: rect.left_bottom(), + uv: uv.left_bottom(), + color, + }, + Vertex { + pos: rect.right_bottom(), + uv: uv.right_bottom(), + color, + }, + ]); + } + + /// Uniformly colored rectangle. + #[inline(always)] + pub fn add_colored_rect(&mut self, rect: Rect, color: Color32) { + debug_assert!( + self.texture_id == TextureId::default(), + "Mesh has an assigned texture" + ); + self.add_rect_with_uv(rect, [WHITE_UV, WHITE_UV].into(), color); + } + + /// This is for platforms that only support 16-bit index buffers. + /// + /// Splits this mesh into many smaller meshes (if needed) + /// where the smaller meshes have 16-bit indices. + pub fn split_to_u16(self) -> Vec { + debug_assert!(self.is_valid(), "Mesh is invalid"); + + const MAX_SIZE: u32 = u16::MAX as u32; + + if self.vertices.len() <= MAX_SIZE as usize { + // Common-case optimization: + return vec![Mesh16 { + indices: self.indices.iter().map(|&i| i as u16).collect(), + vertices: self.vertices, + texture_id: self.texture_id, + }]; + } + + let mut output = vec![]; + let mut index_cursor = 0; + + while index_cursor < self.indices.len() { + let span_start = index_cursor; + let mut min_vindex = self.indices[index_cursor]; + let mut max_vindex = self.indices[index_cursor]; + + while index_cursor < self.indices.len() { + let (mut new_min, mut new_max) = (min_vindex, max_vindex); + for i in 0..3 { + let idx = self.indices[index_cursor + i]; + new_min = new_min.min(idx); + new_max = new_max.max(idx); + } + + let new_span_size = new_max - new_min + 1; // plus one, because it is an inclusive range + if new_span_size <= MAX_SIZE { + // Triangle fits + min_vindex = new_min; + max_vindex = new_max; + index_cursor += 3; + } else { + break; + } + } + + assert!( + index_cursor > span_start, + "One triangle spanned more than {MAX_SIZE} vertices" + ); + + let mesh = Mesh16 { + indices: self.indices[span_start..index_cursor] + .iter() + .map(|vi| { + #[expect(clippy::unwrap_used)] + { + u16::try_from(vi - min_vindex).unwrap() + } + }) + .collect(), + vertices: self.vertices[(min_vindex as usize)..=(max_vindex as usize)].to_vec(), + texture_id: self.texture_id, + }; + debug_assert!(mesh.is_valid(), "Mesh is invalid"); + output.push(mesh); + } + output + } + + /// Translate location by this much, in-place + pub fn translate(&mut self, delta: Vec2) { + for v in &mut self.vertices { + v.pos += delta; + } + } + + /// Transform the mesh in-place with the given transform. + pub fn transform(&mut self, transform: TSTransform) { + for v in &mut self.vertices { + v.pos = transform * v.pos; + } + } + + /// Rotate by some angle about an origin, in-place. + /// + /// Origin is a position in screen space. + pub fn rotate(&mut self, rot: Rot2, origin: Pos2) { + for v in &mut self.vertices { + v.pos = origin + rot * (v.pos - origin); + } + } +} + +// ---------------------------------------------------------------------------- + +/// A version of [`Mesh`] that uses 16-bit indices. +/// +/// This is produced by [`Mesh::split_to_u16`] and is meant to be used for legacy render backends. +pub struct Mesh16 { + /// Draw as triangles (i.e. the length is always multiple of three). + /// + /// egui is NOT consistent with what winding order it uses, so turn off backface culling. + pub indices: Vec, + + /// The vertex data indexed by `indices`. + pub vertices: Vec, + + /// The texture to use when drawing these triangles. + pub texture_id: TextureId, +} + +impl Mesh16 { + /// Are all indices within the bounds of the contained vertices? + pub fn is_valid(&self) -> bool { + if let Ok(n) = u16::try_from(self.vertices.len()) { + self.indices.iter().all(|&i| i < n) + } else { + false + } + } +} diff --git a/crates/accshift-egui/vendor/epaint/src/mutex.rs b/crates/accshift-egui/vendor/epaint/src/mutex.rs new file mode 100644 index 0000000..09e64d3 --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/mutex.rs @@ -0,0 +1,277 @@ +//! Wrappers around `parking_lot` locks, with a simple deadlock detection mechanism. + +// ---------------------------------------------------------------------------- + +const DEADLOCK_DURATION: core::time::Duration = core::time::Duration::from_secs(10); + +/// Provides interior mutability. +/// +/// It's tailored for internal use in egui should only be used for short locks (as a guideline, +/// locks should never be held longer than a single frame). In debug builds, when a lock can't +/// be acquired within 10 seconds, we assume a deadlock and will panic. +/// +/// This is a thin wrapper around [`parking_lot::Mutex`]. +#[derive(Default)] +pub struct Mutex(parking_lot::Mutex); + +/// The lock you get from [`Mutex`]. +pub use parking_lot::MutexGuard; + +impl Mutex { + #[inline(always)] + pub fn new(val: T) -> Self { + Self(parking_lot::Mutex::new(val)) + } + + /// Try to acquire the lock. + /// + /// ## Panics + /// Will panic in debug builds if the lock can't be acquired within 10 seconds. + #[inline(always)] + #[cfg_attr(debug_assertions, track_caller)] + pub fn lock(&self) -> MutexGuard<'_, T> { + if cfg!(debug_assertions) { + self.0.try_lock_for(DEADLOCK_DURATION).unwrap_or_else(|| { + panic!( + "DEBUG PANIC: Failed to acquire Mutex after {}s. Deadlock?", + DEADLOCK_DURATION.as_secs() + ) + }) + } else { + self.0.lock() + } + } +} + +// ---------------------------------------------------------------------------- + +/// The lock you get from [`RwLock::read`]. +pub use parking_lot::MappedRwLockReadGuard as RwLockReadGuard; + +/// The lock you get from [`RwLock::write`]. +pub use parking_lot::MappedRwLockWriteGuard as RwLockWriteGuard; + +/// Provides interior mutability. +/// +/// It's tailored for internal use in egui should only be used for short locks (as a guideline, +/// locks should never be held longer than a single frame). In debug builds, when a lock can't +/// be acquired within 10 seconds, we assume a deadlock and will panic. +/// +/// This is a thin wrapper around [`parking_lot::RwLock`]. +#[derive(Default)] +pub struct RwLock(parking_lot::RwLock); + +impl RwLock { + #[inline(always)] + pub fn new(val: T) -> Self { + Self(parking_lot::RwLock::new(val)) + } +} + +impl RwLock { + /// Try to acquire read-access to the lock. + /// + /// ## Panics + /// Will panic in debug builds if the lock can't be acquired within 10 seconds. + #[inline(always)] + #[cfg_attr(debug_assertions, track_caller)] + pub fn read(&self) -> RwLockReadGuard<'_, T> { + let guard = if cfg!(debug_assertions) { + self.0.try_read_for(DEADLOCK_DURATION).unwrap_or_else(|| { + panic!( + "DEBUG PANIC: Failed to acquire RwLock read after {}s. Deadlock?", + DEADLOCK_DURATION.as_secs() + ) + }) + } else { + self.0.read() + }; + parking_lot::RwLockReadGuard::map(guard, |v| v) + } + + /// Try to acquire write-access to the lock. + /// + /// ## Panics + /// Will panic in debug builds if the lock can't be acquired within 10 seconds. + #[inline(always)] + #[cfg_attr(debug_assertions, track_caller)] + pub fn write(&self) -> RwLockWriteGuard<'_, T> { + let guard = if cfg!(debug_assertions) { + self.0.try_write_for(DEADLOCK_DURATION).unwrap_or_else(|| { + panic!( + "DEBUG PANIC: Failed to acquire RwLock write after {}s. Deadlock?", + DEADLOCK_DURATION.as_secs() + ) + }) + } else { + self.0.write() + }; + parking_lot::RwLockWriteGuard::map(guard, |v| v) + } +} + +// ---------------------------------------------------------------------------- + +impl Clone for Mutex +where + T: Clone, +{ + fn clone(&self) -> Self { + Self::new(self.lock().clone()) + } +} + +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + #![expect(clippy::disallowed_methods)] // Ok for tests + + use crate::mutex::Mutex; + use core::time::Duration; + + #[test] + fn lock_two_different_mutexes_single_thread() { + let one = Mutex::new(()); + let two = Mutex::new(()); + let _a = one.lock(); + let _b = two.lock(); + } + + #[test] + fn lock_multiple_threads() { + use std::sync::Arc; + let one = Arc::new(Mutex::new(())); + let our_lock = one.lock(); + let other_thread = { + let one = Arc::clone(&one); + std::thread::spawn(move || { + let _lock = one.lock(); + }) + }; + std::thread::sleep(Duration::from_millis(200)); + drop(our_lock); + other_thread.join().unwrap(); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[cfg(test)] +mod tests_rwlock { + #![expect(clippy::disallowed_methods)] // Ok for tests + + use crate::mutex::RwLock; + use core::time::Duration; + + #[test] + fn lock_two_different_rwlocks_single_thread() { + let one = RwLock::new(()); + let two = RwLock::new(()); + let _a = one.write(); + let _b = two.write(); + } + + #[test] + fn rwlock_multiple_threads() { + use std::sync::Arc; + let one = Arc::new(RwLock::new(())); + let our_lock = one.write(); + let other_thread1 = { + let one = Arc::clone(&one); + std::thread::spawn(move || { + let _ = one.write(); + }) + }; + let other_thread2 = { + let one = Arc::clone(&one); + std::thread::spawn(move || { + let _ = one.read(); + }) + }; + std::thread::sleep(Duration::from_millis(200)); + drop(our_lock); + other_thread1.join().unwrap(); + other_thread2.join().unwrap(); + } + + #[test] + #[should_panic] + fn rwlock_write_write_reentrancy() { + let one = RwLock::new(()); + let _a1 = one.write(); + let _a2 = one.write(); // panics + } + + #[test] + #[should_panic] + fn rwlock_write_read_reentrancy() { + let one = RwLock::new(()); + let _a1 = one.write(); + let _a2 = one.read(); // panics + } + + #[test] + #[should_panic] + fn rwlock_read_write_reentrancy() { + let one = RwLock::new(()); + let _a1 = one.read(); + let _a2 = one.write(); // panics + } + + #[test] + fn rwlock_read_read_reentrancy() { + let one = RwLock::new(()); + let _a1 = one.read(); + // This is legal: this test suite specifically targets native, which relies + // on parking_lot's rw-locks, which are reentrant. + let _a2 = one.read(); + } + + #[test] + fn rwlock_short_read_foreign_read_write_reentrancy() { + use std::sync::Arc; + + let lock = Arc::new(RwLock::new(())); + + // Thread #0 grabs a read lock + let t0r0 = lock.read(); + + // Thread #1 grabs the same read lock + let other_thread = { + let lock = Arc::clone(&lock); + std::thread::spawn(move || { + let _t1r0 = lock.read(); + }) + }; + other_thread.join().unwrap(); + + // Thread #0 releases its read lock + drop(t0r0); + + // Thread #0 now grabs a write lock, which is legal + let _t0w0 = lock.write(); + } + + #[test] + #[should_panic] + fn rwlock_read_foreign_read_write_reentrancy() { + use std::sync::Arc; + + let lock = Arc::new(RwLock::new(())); + + // Thread #0 grabs a read lock + let _t0r0 = lock.read(); + + // Thread #1 grabs the same read lock + let other_thread = { + let lock = Arc::clone(&lock); + std::thread::spawn(move || { + let _t1r0 = lock.read(); + }) + }; + other_thread.join().unwrap(); + + // Thread #0 now grabs a write lock, which should panic (read-write) + let _t0w0 = lock.write(); // panics + } +} diff --git a/crates/accshift-egui/vendor/epaint/src/shadow.rs b/crates/accshift-egui/vendor/epaint/src/shadow.rs new file mode 100644 index 0000000..251b57b --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/shadow.rs @@ -0,0 +1,85 @@ +use crate::{Color32, CornerRadius, MarginF32, Rect, RectShape, Vec2}; + +/// The color and fuzziness of a fuzzy shape. +/// +/// Can be used for a rectangular shadow with a soft penumbra. +/// +/// Very similar to a box-shadow in CSS. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct Shadow { + /// Move the shadow by this much. + /// + /// For instance, a value of `[1.0, 2.0]` will move the shadow 1 point to the right and 2 points down, + /// causing a drop-shadow effect. + pub offset: [i8; 2], + + /// The width of the blur, i.e. the width of the fuzzy penumbra. + /// + /// A value of 0 means a sharp shadow. + pub blur: u8, + + /// Expand the shadow in all directions by this much. + pub spread: u8, + + /// Color of the opaque center of the shadow. + pub color: Color32, +} + +#[test] +fn shadow_size() { + assert_eq!( + core::mem::size_of::(), + 8, + "Shadow changed size! If it shrank - good! Update this test. If it grew - bad! Try to find a way to avoid it." + ); +} + +impl Shadow { + /// No shadow at all. + pub const NONE: Self = Self { + offset: [0, 0], + blur: 0, + spread: 0, + color: Color32::TRANSPARENT, + }; + + /// The argument is the rectangle of the shadow caster. + pub fn as_shape(&self, rect: Rect, corner_radius: impl Into) -> RectShape { + // tessellator.clip_rect = clip_rect; // TODO(emilk): culling + + let Self { + offset, + blur, + spread, + color, + } = *self; + let [offset_x, offset_y] = offset; + + let rect = rect + .translate(Vec2::new(offset_x as _, offset_y as _)) + .expand(spread as _); + let corner_radius = corner_radius.into() + CornerRadius::from(spread); + + RectShape::filled(rect, corner_radius, color).with_blur_width(blur as _) + } + + /// How much larger than the parent rect are we in each direction? + pub fn margin(&self) -> MarginF32 { + let Self { + offset, + blur, + spread, + color: _, + } = *self; + let spread = spread as f32; + let blur = blur as f32; + let [offset_x, offset_y] = offset; + MarginF32 { + left: spread + 0.5 * blur - offset_x as f32, + right: spread + 0.5 * blur + offset_x as f32, + top: spread + 0.5 * blur - offset_y as f32, + bottom: spread + 0.5 * blur + offset_y as f32, + } + } +} diff --git a/crates/accshift-egui/vendor/epaint/src/shape_transform.rs b/crates/accshift-egui/vendor/epaint/src/shape_transform.rs new file mode 100644 index 0000000..71cc133 --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/shape_transform.rs @@ -0,0 +1,136 @@ +use std::sync::Arc; + +use crate::{ + CircleShape, Color32, ColorMode, CubicBezierShape, EllipseShape, Mesh, PathShape, + QuadraticBezierShape, RectShape, Shape, TextShape, color, +}; + +/// Remember to handle [`Color32::PLACEHOLDER`] specially! +pub fn adjust_colors( + shape: &mut Shape, + adjust_color: impl Fn(&mut Color32) + Send + Sync + Copy + 'static, +) { + #![expect(clippy::match_same_arms)] + match shape { + Shape::Noop => {} + + Shape::Vec(shapes) => { + for shape in shapes { + adjust_colors(shape, adjust_color); + } + } + + Shape::LineSegment { stroke, points: _ } => { + adjust_color(&mut stroke.color); + } + + Shape::Path(PathShape { + points: _, + closed: _, + fill, + stroke, + }) + | Shape::QuadraticBezier(QuadraticBezierShape { + points: _, + closed: _, + fill, + stroke, + }) + | Shape::CubicBezier(CubicBezierShape { + points: _, + closed: _, + fill, + stroke, + }) => { + adjust_color(fill); + adjust_color_mode(&mut stroke.color, adjust_color); + } + + Shape::Circle(CircleShape { + center: _, + radius: _, + fill, + stroke, + }) + | Shape::Ellipse(EllipseShape { + center: _, + radius: _, + fill, + stroke, + angle: _, + }) + | Shape::Rect(RectShape { + rect: _, + corner_radius: _, + fill, + stroke, + stroke_kind: _, + round_to_pixels: _, + blur_width: _, + brush: _, + angle: _, + }) => { + adjust_color(fill); + adjust_color(&mut stroke.color); + } + + Shape::Text(TextShape { + pos: _, + galley, + underline, + fallback_color, + override_text_color, + opacity_factor: _, + angle: _, + }) => { + adjust_color(&mut underline.color); + adjust_color(fallback_color); + if let Some(override_text_color) = override_text_color { + adjust_color(override_text_color); + } + + if !galley.is_empty() { + let galley = Arc::make_mut(galley); + for placed_row in &mut galley.rows { + let row = Arc::make_mut(&mut placed_row.row); + for vertex in &mut row.visuals.mesh.vertices { + adjust_color(&mut vertex.color); + } + } + } + } + + Shape::Mesh(mesh) => { + let Mesh { + indices: _, + vertices, + texture_id: _, + } = Arc::make_mut(mesh); + + for v in vertices { + adjust_color(&mut v.color); + } + } + + Shape::Callback(_) => { + // Can't tint user callback code + } + } +} + +fn adjust_color_mode( + color_mode: &mut ColorMode, + adjust_color: impl Fn(&mut Color32) + Send + Sync + Copy + 'static, +) { + match color_mode { + color::ColorMode::Solid(color) => adjust_color(color), + color::ColorMode::UV(callback) => { + let callback = Arc::clone(callback); + *color_mode = color::ColorMode::UV(Arc::new(Box::new(move |rect, pos| { + let mut color = callback(rect, pos); + adjust_color(&mut color); + color + }))); + } + } +} diff --git a/crates/accshift-egui/vendor/epaint/src/shapes/bezier_shape.rs b/crates/accshift-egui/vendor/epaint/src/shapes/bezier_shape.rs new file mode 100644 index 0000000..caf1094 --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/shapes/bezier_shape.rs @@ -0,0 +1,1137 @@ +#![expect(clippy::many_single_char_names)] + +use core::ops::Range; + +use crate::{Color32, PathShape, PathStroke, Shape}; +use emath::{Pos2, Rect, RectTransform, fast_midpoint}; + +// ---------------------------------------------------------------------------- + +/// A cubic [Bézier Curve](https://en.wikipedia.org/wiki/B%C3%A9zier_curve). +/// +/// See also [`QuadraticBezierShape`]. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct CubicBezierShape { + /// The first point is the starting point and the last one is the ending point of the curve. + /// The middle points are the control points. + pub points: [Pos2; 4], + pub closed: bool, + + pub fill: Color32, + pub stroke: PathStroke, +} + +impl CubicBezierShape { + /// Creates a cubic Bézier curve based on 4 points and stroke. + /// + /// The first point is the starting point and the last one is the ending point of the curve. + /// The middle points are the control points. + pub fn from_points_stroke( + points: [Pos2; 4], + closed: bool, + fill: Color32, + stroke: impl Into, + ) -> Self { + Self { + points, + closed, + fill, + stroke: stroke.into(), + } + } + + /// Transform the curve with the given transform. + pub fn transform(&self, transform: &RectTransform) -> Self { + let mut points = [Pos2::default(); 4]; + for (i, origin_point) in self.points.iter().enumerate() { + points[i] = transform * *origin_point; + } + Self { + points, + closed: self.closed, + fill: self.fill, + stroke: self.stroke.clone(), + } + } + + /// Convert the cubic Bézier curve to one or two [`PathShape`]'s. + /// When the curve is closed and it has to intersect with the base line, it will be converted into two shapes. + /// Otherwise, it will be converted into one shape. + /// The `tolerance` will be used to control the max distance between the curve and the base line. + /// The `epsilon` is used when comparing two floats. + pub fn to_path_shapes(&self, tolerance: Option, epsilon: Option) -> Vec { + let mut pathshapes = Vec::new(); + let mut points_vec = self.flatten_closed(tolerance, epsilon); + for points in points_vec.drain(..) { + let pathshape = PathShape { + points, + closed: self.closed, + fill: self.fill, + stroke: self.stroke.clone(), + }; + pathshapes.push(pathshape); + } + pathshapes + } + + /// The visual bounding rectangle (includes stroke width) + pub fn visual_bounding_rect(&self) -> Rect { + if self.fill == Color32::TRANSPARENT && self.stroke.is_empty() { + Rect::NOTHING + } else { + self.logical_bounding_rect().expand(self.stroke.width / 2.0) + } + } + + /// Logical bounding rectangle (ignoring stroke width) + pub fn logical_bounding_rect(&self) -> Rect { + // temporary solution + let (mut min_x, mut max_x) = if self.points[0].x < self.points[3].x { + (self.points[0].x, self.points[3].x) + } else { + (self.points[3].x, self.points[0].x) + }; + let (mut min_y, mut max_y) = if self.points[0].y < self.points[3].y { + (self.points[0].y, self.points[3].y) + } else { + (self.points[3].y, self.points[0].y) + }; + + // find the inflection points and get the x value + cubic_for_each_local_extremum( + self.points[0].x, + self.points[1].x, + self.points[2].x, + self.points[3].x, + &mut |t| { + let x = self.sample(t).x; + if x < min_x { + min_x = x; + } + if x > max_x { + max_x = x; + } + }, + ); + + // find the inflection points and get the y value + cubic_for_each_local_extremum( + self.points[0].y, + self.points[1].y, + self.points[2].y, + self.points[3].y, + &mut |t| { + let y = self.sample(t).y; + if y < min_y { + min_y = y; + } + if y > max_y { + max_y = y; + } + }, + ); + + Rect { + min: Pos2 { x: min_x, y: min_y }, + max: Pos2 { x: max_x, y: max_y }, + } + } + + /// split the original cubic curve into a new one within a range. + pub fn split_range(&self, t_range: Range) -> Self { + debug_assert!( + 0.0 <= t_range.start && t_range.end <= 1.0 && t_range.start <= t_range.end, + "range should be in [0.0,1.0]" + ); + + let from = self.sample(t_range.start); + let to = self.sample(t_range.end); + + let d_from = self.points[1] - self.points[0].to_vec2(); + let d_ctrl = self.points[2] - self.points[1].to_vec2(); + let d_to = self.points[3] - self.points[2].to_vec2(); + let q = QuadraticBezierShape { + points: [d_from, d_ctrl, d_to], + closed: self.closed, + fill: self.fill, + stroke: self.stroke.clone(), + }; + let delta_t = t_range.end - t_range.start; + let q_start = q.sample(t_range.start); + let q_end = q.sample(t_range.end); + let ctrl1 = from + q_start.to_vec2() * delta_t; + let ctrl2 = to - q_end.to_vec2() * delta_t; + + Self { + points: [from, ctrl1, ctrl2, to], + closed: self.closed, + fill: self.fill, + stroke: self.stroke.clone(), + } + } + + // copied from + // Computes the number of quadratic bézier segments to approximate a cubic one. + // Derived by Raph Levien from section 10.6 of Sedeberg's CAGD notes + // https://scholarsarchive.byu.edu/cgi/viewcontent.cgi?article=1000&context=facpub#section.10.6 + // and the error metric from the caffein owl blog post http://caffeineowl.com/graphics/2d/vectorial/cubic2quad01.html + pub fn num_quadratics(&self, tolerance: f32) -> u32 { + debug_assert!(tolerance > 0.0, "the tolerance should be positive"); + + let x = + self.points[0].x - 3.0 * self.points[1].x + 3.0 * self.points[2].x - self.points[3].x; + let y = + self.points[0].y - 3.0 * self.points[1].y + 3.0 * self.points[2].y - self.points[3].y; + let err = x * x + y * y; + + (err / (432.0 * tolerance * tolerance)) + .powf(1.0 / 6.0) + .ceil() + .max(1.0) as u32 + } + + /// Find out the t value for the point where the curve is intersected with the base line. + /// The base line is the line from P0 to P3. + /// If the curve only has two intersection points with the base line, they should be 0.0 and 1.0. + /// In this case, the "fill" will be simple since the curve is a convex line. + /// If the curve has more than two intersection points with the base line, the "fill" will be a problem. + /// We need to find out where is the 3rd t value (0 0, there will be one real root, two complex roots + /// when p = 0, there will be two real roots, when p=q=0, there will be three real roots but all 0. + /// when p < 0, there will be three unique real roots. this is what we need. (x1, x2, x3) + /// t = x + b / (3 * a), then we have: t1, t2, t3. + /// the one between 0.0 and 1.0 is what we need. + /// <`https://baike.baidu.com/item/%E4%B8%80%E5%85%83%E4%B8%89%E6%AC%A1%E6%96%B9%E7%A8%8B/8388473 /`> + /// + pub fn find_cross_t(&self, epsilon: f32) -> Option { + let p0 = self.points[0]; + let p1 = self.points[1]; + let p2 = self.points[2]; + let p3 = self.points[3]; + + let a = (p3.x - 3.0 * p2.x + 3.0 * p1.x - p0.x) * (p3.y - p0.y) + - (p3.y - 3.0 * p2.y + 3.0 * p1.y - p0.y) * (p3.x - p0.x); + let b = (3.0 * p2.x - 6.0 * p1.x + 3.0 * p0.x) * (p3.y - p0.y) + - (3.0 * p2.y - 6.0 * p1.y + 3.0 * p0.y) * (p3.x - p0.x); + let c = + (3.0 * p1.x - 3.0 * p0.x) * (p3.y - p0.y) - (3.0 * p1.y - 3.0 * p0.y) * (p3.x - p0.x); + let d = p0.x * (p3.y - p0.y) - p0.y * (p3.x - p0.x) + + p0.x * (p0.y - p3.y) + + p0.y * (p3.x - p0.x); + + let h = -b / (3.0 * a); + let p = (3.0 * a * c - b * b) / (3.0 * a * a); + let q = (2.0 * b * b * b - 9.0 * a * b * c + 27.0 * a * a * d) / (27.0 * a * a * a); + + if p > 0.0 { + return None; + } + let r = (-(p / 3.0).powi(3)).sqrt(); + let theta = (-q / (2.0 * r)).acos() / 3.0; + + let t1 = 2.0 * r.cbrt() * theta.cos() + h; + let t2 = 2.0 * r.cbrt() * (theta + 120.0 * core::f32::consts::PI / 180.0).cos() + h; + let t3 = 2.0 * r.cbrt() * (theta + 240.0 * core::f32::consts::PI / 180.0).cos() + h; + + if t1 > epsilon && t1 < 1.0 - epsilon { + return Some(t1); + } + if t2 > epsilon && t2 < 1.0 - epsilon { + return Some(t2); + } + if t3 > epsilon && t3 < 1.0 - epsilon { + return Some(t3); + } + None + } + + /// Calculate the point (x,y) at t based on the cubic Bézier curve equation. + /// t is in [0.0,1.0] + /// [Bézier Curve](https://en.wikipedia.org/wiki/B%C3%A9zier_curve#Cubic_B.C3.A9zier_curves) + /// + pub fn sample(&self, t: f32) -> Pos2 { + debug_assert!( + t >= 0.0 && t <= 1.0, + "the sample value should be in [0.0,1.0]" + ); + + let h = 1.0 - t; + let a = t * t * t; + let b = 3.0 * t * t * h; + let c = 3.0 * t * h * h; + let d = h * h * h; + let result = self.points[3].to_vec2() * a + + self.points[2].to_vec2() * b + + self.points[1].to_vec2() * c + + self.points[0].to_vec2() * d; + result.to_pos2() + } + + /// find a set of points that approximate the cubic Bézier curve. + /// the number of points is determined by the tolerance. + /// the points may not be evenly distributed in the range [0.0,1.0] (t value) + pub fn flatten(&self, tolerance: Option) -> Vec { + let tolerance = + tolerance.unwrap_or_else(|| (self.points[0].x - self.points[3].x).abs() * 0.001); + let mut result = vec![self.points[0]]; + self.for_each_flattened_with_t(tolerance, &mut |p, _t| { + result.push(p); + }); + result + } + + /// find a set of points that approximate the cubic Bézier curve. + /// the number of points is determined by the tolerance. + /// the points may not be evenly distributed in the range [0.0,1.0] (t value) + /// this api will check whether the curve will cross the base line or not when closed = true. + /// The result will be a vec of vec of Pos2. it will store two closed aren in different vec. + /// The epsilon is used to compare a float value. + pub fn flatten_closed(&self, tolerance: Option, epsilon: Option) -> Vec> { + let tolerance = + tolerance.unwrap_or_else(|| (self.points[0].x - self.points[3].x).abs() * 0.001); + let epsilon = epsilon.unwrap_or(1.0e-5); + let mut result = Vec::new(); + let mut first_half = Vec::new(); + let mut second_half = Vec::new(); + let mut flipped = false; + first_half.push(self.points[0]); + + let cross = self.find_cross_t(epsilon); + match cross { + Some(cross) => { + if self.closed { + self.for_each_flattened_with_t(tolerance, &mut |p, t| { + if t < cross { + first_half.push(p); + } else { + if !flipped { + // when just crossed the base line, flip the order of the points + // add the cross point to the first half as the last point + // and add the cross point to the second half as the first point + flipped = true; + let cross_point = self.sample(cross); + first_half.push(cross_point); + second_half.push(cross_point); + } + second_half.push(p); + } + }); + } else { + self.for_each_flattened_with_t(tolerance, &mut |p, _t| { + first_half.push(p); + }); + } + } + None => { + self.for_each_flattened_with_t(tolerance, &mut |p, _t| { + first_half.push(p); + }); + } + } + + result.push(first_half); + if !second_half.is_empty() { + result.push(second_half); + } + result + } + // from lyon_geom::cubic_bezier.rs + /// Iterates through the curve invoking a callback at each point. + pub fn for_each_flattened_with_t(&self, tolerance: f32, callback: &mut F) { + flatten_cubic_bezier_with_t(self, tolerance, callback); + } +} + +impl From for Shape { + #[inline(always)] + fn from(shape: CubicBezierShape) -> Self { + Self::CubicBezier(shape) + } +} + +// ---------------------------------------------------------------------------- + +/// A quadratic [Bézier Curve](https://en.wikipedia.org/wiki/B%C3%A9zier_curve). +/// +/// See also [`CubicBezierShape`]. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct QuadraticBezierShape { + /// The first point is the starting point and the last one is the ending point of the curve. + /// The middle point is the control points. + pub points: [Pos2; 3], + pub closed: bool, + + pub fill: Color32, + pub stroke: PathStroke, +} + +impl QuadraticBezierShape { + /// Create a new quadratic Bézier shape based on the 3 points and stroke. + /// + /// The first point is the starting point and the last one is the ending point of the curve. + /// The middle point is the control points. + /// The points should be in the order [start, control, end] + pub fn from_points_stroke( + points: [Pos2; 3], + closed: bool, + fill: Color32, + stroke: impl Into, + ) -> Self { + Self { + points, + closed, + fill, + stroke: stroke.into(), + } + } + + /// Transform the curve with the given transform. + pub fn transform(&self, transform: &RectTransform) -> Self { + let mut points = [Pos2::default(); 3]; + for (i, origin_point) in self.points.iter().enumerate() { + points[i] = transform * *origin_point; + } + Self { + points, + closed: self.closed, + fill: self.fill, + stroke: self.stroke.clone(), + } + } + + /// Convert the quadratic Bézier curve to one [`PathShape`]. + /// The `tolerance` will be used to control the max distance between the curve and the base line. + pub fn to_path_shape(&self, tolerance: Option) -> PathShape { + let points = self.flatten(tolerance); + PathShape { + points, + closed: self.closed, + fill: self.fill, + stroke: self.stroke.clone(), + } + } + + /// The visual bounding rectangle (includes stroke width) + pub fn visual_bounding_rect(&self) -> Rect { + if self.fill == Color32::TRANSPARENT && self.stroke.is_empty() { + Rect::NOTHING + } else { + self.logical_bounding_rect().expand(self.stroke.width / 2.0) + } + } + + /// Logical bounding rectangle (ignoring stroke width) + pub fn logical_bounding_rect(&self) -> Rect { + let (mut min_x, mut max_x) = if self.points[0].x < self.points[2].x { + (self.points[0].x, self.points[2].x) + } else { + (self.points[2].x, self.points[0].x) + }; + let (mut min_y, mut max_y) = if self.points[0].y < self.points[2].y { + (self.points[0].y, self.points[2].y) + } else { + (self.points[2].y, self.points[0].y) + }; + + quadratic_for_each_local_extremum( + self.points[0].x, + self.points[1].x, + self.points[2].x, + &mut |t| { + let x = self.sample(t).x; + if x < min_x { + min_x = x; + } + if x > max_x { + max_x = x; + } + }, + ); + + quadratic_for_each_local_extremum( + self.points[0].y, + self.points[1].y, + self.points[2].y, + &mut |t| { + let y = self.sample(t).y; + if y < min_y { + min_y = y; + } + if y > max_y { + max_y = y; + } + }, + ); + + Rect { + min: Pos2 { x: min_x, y: min_y }, + max: Pos2 { x: max_x, y: max_y }, + } + } + + /// Calculate the point (x,y) at t based on the quadratic Bézier curve equation. + /// t is in [0.0,1.0] + /// [Bézier Curve](https://en.wikipedia.org/wiki/B%C3%A9zier_curve#Quadratic_B.C3.A9zier_curves) + /// + pub fn sample(&self, t: f32) -> Pos2 { + debug_assert!( + t >= 0.0 && t <= 1.0, + "the sample value should be in [0.0,1.0]" + ); + + let h = 1.0 - t; + let a = t * t; + let b = 2.0 * t * h; + let c = h * h; + let result = self.points[2].to_vec2() * a + + self.points[1].to_vec2() * b + + self.points[0].to_vec2() * c; + result.to_pos2() + } + + /// find a set of points that approximate the quadratic Bézier curve. + /// the number of points is determined by the tolerance. + /// the points may not be evenly distributed in the range [0.0,1.0] (t value) + pub fn flatten(&self, tolerance: Option) -> Vec { + let tolerance = + tolerance.unwrap_or_else(|| (self.points[0].x - self.points[2].x).abs() * 0.001); + let mut result = vec![self.points[0]]; + self.for_each_flattened_with_t(tolerance, &mut |p, _t| { + result.push(p); + }); + result + } + + // copied from https://docs.rs/lyon_geom/latest/lyon_geom/ + /// Compute a flattened approximation of the curve, invoking a callback at + /// each step. + /// + /// The callback takes the point and corresponding curve parameter at each step. + /// + /// This implements the algorithm described by Raph Levien at + /// + pub fn for_each_flattened_with_t(&self, tolerance: f32, callback: &mut F) + where + F: FnMut(Pos2, f32), + { + let params = FlatteningParameters::from_curve(self, tolerance); + if params.is_point { + return; + } + + let count = params.count as u32; + for index in 1..count { + let t = params.t_at_iteration(index as f32); + + callback(self.sample(t), t); + } + + callback(self.sample(1.0), 1.0); + } +} + +impl From for Shape { + #[inline(always)] + fn from(shape: QuadraticBezierShape) -> Self { + Self::QuadraticBezier(shape) + } +} + +// ---------------------------------------------------------------------------- + +// lyon_geom::flatten_cubic.rs +// copied from https://docs.rs/lyon_geom/latest/lyon_geom/ +fn flatten_cubic_bezier_with_t( + curve: &CubicBezierShape, + tolerance: f32, + callback: &mut F, +) { + // debug_assert!(tolerance >= S::EPSILON * S::EPSILON); + let quadratics_tolerance = tolerance * 0.2; + let flattening_tolerance = tolerance * 0.8; + + let num_quadratics = curve.num_quadratics(quadratics_tolerance); + let step = 1.0 / num_quadratics as f32; + let n = num_quadratics; + let mut t0 = 0.0; + for _ in 0..(n - 1) { + let t1 = t0 + step; + + let quadratic = single_curve_approximation(&curve.split_range(t0..t1)); + quadratic.for_each_flattened_with_t(flattening_tolerance, &mut |point, t_sub| { + let t = t0 + step * t_sub; + callback(point, t); + }); + + t0 = t1; + } + + // Do the last step manually to make sure we finish at t = 1.0 exactly. + let quadratic = single_curve_approximation(&curve.split_range(t0..1.0)); + quadratic.for_each_flattened_with_t(flattening_tolerance, &mut |point, t_sub| { + let t = t0 + step * t_sub; + callback(point, t); + }); +} + +// from lyon_geom::quadratic_bezier.rs +// copied from https://docs.rs/lyon_geom/latest/lyon_geom/ +struct FlatteningParameters { + count: f32, + integral_from: f32, + integral_step: f32, + inv_integral_from: f32, + div_inv_integral_diff: f32, + is_point: bool, +} + +impl FlatteningParameters { + // https://raphlinus.github.io/graphics/curves/2019/12/23/flatten-quadbez.html + pub fn from_curve(curve: &QuadraticBezierShape, tolerance: f32) -> Self { + #![expect(clippy::useless_let_if_seq)] + + // Map the quadratic bézier segment to y = x^2 parabola. + let from = curve.points[0]; + let ctrl = curve.points[1]; + let to = curve.points[2]; + + let ddx = 2.0 * ctrl.x - from.x - to.x; + let ddy = 2.0 * ctrl.y - from.y - to.y; + let cross = (to.x - from.x) * ddy - (to.y - from.y) * ddx; + let inv_cross = 1.0 / cross; + let parabola_from = ((ctrl.x - from.x) * ddx + (ctrl.y - from.y) * ddy) * inv_cross; + let parabola_to = ((to.x - ctrl.x) * ddx + (to.y - ctrl.y) * ddy) * inv_cross; + // Note, scale can be NaN, for example with straight lines. When it happens the NaN will + // propagate to other parameters. We catch it all by setting the iteration count to zero + // and leave the rest as garbage. + let scale = cross.abs() / (ddx.hypot(ddy) * (parabola_to - parabola_from).abs()); + + let integral_from = approx_parabola_integral(parabola_from); + let integral_to = approx_parabola_integral(parabola_to); + let integral_diff = integral_to - integral_from; + + let inv_integral_from = approx_parabola_inv_integral(integral_from); + let inv_integral_to = approx_parabola_inv_integral(integral_to); + let div_inv_integral_diff = 1.0 / (inv_integral_to - inv_integral_from); + + // the original author thinks it can be stored as integer if it's not generic. + // but if so, we have to handle the edge case of the integral being infinite. + let mut count = (0.5 * integral_diff.abs() * (scale / tolerance).sqrt()).ceil(); + let mut is_point = false; + // If count is NaN the curve can be approximated by a single straight line or a point. + if !count.is_finite() { + count = 0.0; + is_point = (to.x - from.x).hypot(to.y - from.y) < tolerance * tolerance; + } + + let integral_step = integral_diff / count; + + Self { + count, + integral_from, + integral_step, + inv_integral_from, + div_inv_integral_diff, + is_point, + } + } + + fn t_at_iteration(&self, iteration: f32) -> f32 { + let u = approx_parabola_inv_integral(self.integral_from + self.integral_step * iteration); + (u - self.inv_integral_from) * self.div_inv_integral_diff + } +} + +/// Compute an approximation to integral (1 + 4x^2) ^ -0.25 dx used in the flattening code. +fn approx_parabola_integral(x: f32) -> f32 { + let d: f32 = 0.67; + let quarter = 0.25; + x / (1.0 - d + (d.powi(4) + quarter * x * x).sqrt().sqrt()) +} + +/// Approximate the inverse of the function above. +fn approx_parabola_inv_integral(x: f32) -> f32 { + let b = 0.39; + let quarter = 0.25; + x * (1.0 - b + (b * b + quarter * x * x).sqrt()) +} + +fn single_curve_approximation(curve: &CubicBezierShape) -> QuadraticBezierShape { + let c1_x = (curve.points[1].x * 3.0 - curve.points[0].x) * 0.5; + let c1_y = (curve.points[1].y * 3.0 - curve.points[0].y) * 0.5; + let c2_x = (curve.points[2].x * 3.0 - curve.points[3].x) * 0.5; + let c2_y = (curve.points[2].y * 3.0 - curve.points[3].y) * 0.5; + let c = Pos2 { + x: fast_midpoint(c1_x, c2_x), + y: fast_midpoint(c1_y, c2_y), + }; + QuadraticBezierShape { + points: [curve.points[0], c, curve.points[3]], + closed: curve.closed, + fill: curve.fill, + stroke: curve.stroke.clone(), + } +} + +fn quadratic_for_each_local_extremum(p0: f32, p1: f32, p2: f32, cb: &mut F) { + // A quadratic Bézier curve can be derived by a linear function: + // p(t) = p0 + t(p1 - p0) + t^2(p2 - 2p1 + p0) + // The derivative is: + // p'(t) = (p1 - p0) + 2(p2 - 2p1 + p0)t or: + // f(x) = a* x + b + let a = p2 - 2.0 * p1 + p0; + // let b = p1 - p0; + // no need to check for zero, since we're only interested in local extrema + if a == 0.0 { + return; + } + + let t = (p0 - p1) / a; + if t > 0.0 && t < 1.0 { + cb(t); + } +} + +fn cubic_for_each_local_extremum(p0: f32, p1: f32, p2: f32, p3: f32, cb: &mut F) { + // See www.faculty.idc.ac.il/arik/quality/appendixa.html for an explanation + // A cubic Bézier curve can be derived by the following equation: + // B'(t) = 3(1-t)^2(p1-p0) + 6(1-t)t(p2-p1) + 3t^2(p3-p2) or + // f(x) = a * x² + b * x + c + let a = 3.0 * (p3 + 3.0 * (p1 - p2) - p0); + let b = 6.0 * (p2 - 2.0 * p1 + p0); + let c = 3.0 * (p1 - p0); + + let in_range = |t: f32| t <= 1.0 && t >= 0.0; + + // linear situation + if a == 0.0 { + if b != 0.0 { + let t = -c / b; + if in_range(t) { + cb(t); + } + } + return; + } + + let discr = b * b - 4.0 * a * c; + // no Real solution + if discr < 0.0 { + return; + } + + // one Real solution + if discr == 0.0 { + let t = -b / (2.0 * a); + if in_range(t) { + cb(t); + } + return; + } + + // two Real solutions + let discr = discr.sqrt(); + let t1 = (-b - discr) / (2.0 * a); + let t2 = (-b + discr) / (2.0 * a); + if in_range(t1) { + cb(t1); + } + if in_range(t2) { + cb(t2); + } +} + +#[cfg(test)] +mod tests { + use emath::pos2; + + use super::*; + + #[test] + fn test_quadratic_bounding_box() { + let curve = QuadraticBezierShape { + points: [ + Pos2 { x: 110.0, y: 170.0 }, + Pos2 { x: 10.0, y: 10.0 }, + Pos2 { x: 180.0, y: 30.0 }, + ], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + let bbox = curve.logical_bounding_rect(); + assert!((bbox.min.x - 72.96).abs() < 0.01); + assert!((bbox.min.y - 27.78).abs() < 0.01); + + assert!((bbox.max.x - 180.0).abs() < 0.01); + assert!((bbox.max.y - 170.0).abs() < 0.01); + + let mut result = vec![curve.points[0]]; // add the start point + curve.for_each_flattened_with_t(0.1, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 26); + + let curve = QuadraticBezierShape { + points: [ + Pos2 { x: 110.0, y: 170.0 }, + Pos2 { x: 180.0, y: 30.0 }, + Pos2 { x: 10.0, y: 10.0 }, + ], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + let bbox = curve.logical_bounding_rect(); + assert!((bbox.min.x - 10.0).abs() < 0.01); + assert!((bbox.min.y - 10.0).abs() < 0.01); + + assert!((bbox.max.x - 130.42).abs() < 0.01); + assert!((bbox.max.y - 170.0).abs() < 0.01); + + let mut result = vec![curve.points[0]]; // add the start point + curve.for_each_flattened_with_t(0.1, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 25); + } + + #[test] + fn test_quadratic_different_tolerance() { + let curve = QuadraticBezierShape { + points: [ + Pos2 { x: 110.0, y: 170.0 }, + Pos2 { x: 180.0, y: 30.0 }, + Pos2 { x: 10.0, y: 10.0 }, + ], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + let mut result = vec![curve.points[0]]; // add the start point + curve.for_each_flattened_with_t(1.0, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 9); + + let mut result = vec![curve.points[0]]; // add the start point + curve.for_each_flattened_with_t(0.1, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 25); + + let mut result = vec![curve.points[0]]; // add the start point + curve.for_each_flattened_with_t(0.01, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 77); + + let mut result = vec![curve.points[0]]; // add the start point + curve.for_each_flattened_with_t(0.001, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 240); + } + + #[test] + fn test_cubic_bounding_box() { + let curve = CubicBezierShape { + points: [ + pos2(10.0, 10.0), + pos2(110.0, 170.0), + pos2(180.0, 30.0), + pos2(270.0, 210.0), + ], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + + let bbox = curve.logical_bounding_rect(); + assert_eq!(bbox.min.x, 10.0); + assert_eq!(bbox.min.y, 10.0); + assert_eq!(bbox.max.x, 270.0); + assert_eq!(bbox.max.y, 210.0); + + let curve = CubicBezierShape { + points: [ + pos2(10.0, 10.0), + pos2(110.0, 170.0), + pos2(270.0, 210.0), + pos2(180.0, 30.0), + ], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + + let bbox = curve.logical_bounding_rect(); + assert_eq!(bbox.min.x, 10.0); + assert_eq!(bbox.min.y, 10.0); + assert!((bbox.max.x - 206.50).abs() < 0.01); + assert!((bbox.max.y - 148.48).abs() < 0.01); + + let curve = CubicBezierShape { + points: [ + pos2(110.0, 170.0), + pos2(10.0, 10.0), + pos2(270.0, 210.0), + pos2(180.0, 30.0), + ], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + + let bbox = curve.logical_bounding_rect(); + assert!((bbox.min.x - 86.71).abs() < 0.01); + assert!((bbox.min.y - 30.0).abs() < 0.01); + + assert!((bbox.max.x - 199.27).abs() < 0.01); + assert!((bbox.max.y - 170.0).abs() < 0.01); + } + + #[test] + fn test_cubic_different_tolerance_flattening() { + let curve = CubicBezierShape { + points: [ + pos2(0.0, 0.0), + pos2(100.0, 0.0), + pos2(100.0, 100.0), + pos2(100.0, 200.0), + ], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + + let mut result = vec![curve.points[0]]; // add the start point + curve.for_each_flattened_with_t(1.0, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 10); + + let mut result = vec![curve.points[0]]; // add the start point + curve.for_each_flattened_with_t(0.5, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 13); + + let mut result = vec![curve.points[0]]; // add the start point + curve.for_each_flattened_with_t(0.1, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 28); + + let mut result = vec![curve.points[0]]; // add the start point + curve.for_each_flattened_with_t(0.01, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 83); + + let mut result = vec![curve.points[0]]; // add the start point + curve.for_each_flattened_with_t(0.001, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 248); + } + + #[test] + fn test_cubic_different_shape_flattening() { + let curve = CubicBezierShape { + points: [ + pos2(90.0, 110.0), + pos2(30.0, 170.0), + pos2(210.0, 170.0), + pos2(170.0, 110.0), + ], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + + let mut result = vec![curve.points[0]]; // add the start point + curve.for_each_flattened_with_t(0.01, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 117); + + let curve = CubicBezierShape { + points: [ + pos2(90.0, 110.0), + pos2(90.0, 170.0), + pos2(170.0, 170.0), + pos2(170.0, 110.0), + ], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + + let mut result = vec![curve.points[0]]; // add the start point + curve.for_each_flattened_with_t(0.01, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 91); + + let curve = CubicBezierShape { + points: [ + pos2(90.0, 110.0), + pos2(110.0, 170.0), + pos2(150.0, 170.0), + pos2(170.0, 110.0), + ], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + + let mut result = vec![curve.points[0]]; // add the start point + curve.for_each_flattened_with_t(0.01, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 75); + + let curve = CubicBezierShape { + points: [ + pos2(90.0, 110.0), + pos2(110.0, 170.0), + pos2(230.0, 110.0), + pos2(170.0, 110.0), + ], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + + let mut result = vec![curve.points[0]]; // add the start point + curve.for_each_flattened_with_t(0.01, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 100); + + let curve = CubicBezierShape { + points: [ + pos2(90.0, 110.0), + pos2(110.0, 170.0), + pos2(210.0, 70.0), + pos2(170.0, 110.0), + ], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + + let mut result = vec![curve.points[0]]; // add the start point + curve.for_each_flattened_with_t(0.01, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 71); + + let curve = CubicBezierShape { + points: [ + pos2(90.0, 110.0), + pos2(110.0, 170.0), + pos2(150.0, 50.0), + pos2(170.0, 110.0), + ], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + + let mut result = vec![curve.points[0]]; // add the start point + curve.for_each_flattened_with_t(0.01, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 88); + } + + #[test] + fn test_quadratic_flattening() { + let curve = QuadraticBezierShape { + points: [pos2(0.0, 0.0), pos2(80.0, 200.0), pos2(100.0, 30.0)], + closed: false, + fill: Default::default(), + stroke: Default::default(), + }; + + let mut result = vec![curve.points[0]]; // add the start point + curve.for_each_flattened_with_t(1.0, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 9); + + let mut result = vec![curve.points[0]]; // add the start point + curve.for_each_flattened_with_t(0.5, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 11); + + let mut result = vec![curve.points[0]]; // add the start point + curve.for_each_flattened_with_t(0.1, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 24); + + let mut result = vec![curve.points[0]]; // add the start point + curve.for_each_flattened_with_t(0.01, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 72); + let mut result = vec![curve.points[0]]; // add the start point + curve.for_each_flattened_with_t(0.001, &mut |pos, _t| { + result.push(pos); + }); + + assert_eq!(result.len(), 223); + } +} diff --git a/crates/accshift-egui/vendor/epaint/src/shapes/circle_shape.rs b/crates/accshift-egui/vendor/epaint/src/shapes/circle_shape.rs new file mode 100644 index 0000000..a86ae3f --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/shapes/circle_shape.rs @@ -0,0 +1,52 @@ +use crate::{Color32, Pos2, Rect, Shape, Stroke, Vec2}; + +/// How to paint a circle. +#[derive(Copy, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct CircleShape { + pub center: Pos2, + pub radius: f32, + pub fill: Color32, + pub stroke: Stroke, +} + +impl CircleShape { + #[inline] + pub fn filled(center: Pos2, radius: f32, fill_color: impl Into) -> Self { + Self { + center, + radius, + fill: fill_color.into(), + stroke: Default::default(), + } + } + + #[inline] + pub fn stroke(center: Pos2, radius: f32, stroke: impl Into) -> Self { + Self { + center, + radius, + fill: Default::default(), + stroke: stroke.into(), + } + } + + /// The visual bounding rectangle (includes stroke width) + pub fn visual_bounding_rect(&self) -> Rect { + if self.fill == Color32::TRANSPARENT && self.stroke.is_empty() { + Rect::NOTHING + } else { + Rect::from_center_size( + self.center, + Vec2::splat(self.radius * 2.0 + self.stroke.width), + ) + } + } +} + +impl From for Shape { + #[inline(always)] + fn from(shape: CircleShape) -> Self { + Self::Circle(shape) + } +} diff --git a/crates/accshift-egui/vendor/epaint/src/shapes/ellipse_shape.rs b/crates/accshift-egui/vendor/epaint/src/shapes/ellipse_shape.rs new file mode 100644 index 0000000..b436eb8 --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/shapes/ellipse_shape.rs @@ -0,0 +1,78 @@ +use crate::*; + +/// How to paint an ellipse. +#[derive(Copy, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct EllipseShape { + pub center: Pos2, + + /// Radius is the vector (a, b) where the width of the Ellipse is 2a and the height is 2b + pub radius: Vec2, + pub fill: Color32, + pub stroke: Stroke, + + /// Rotate ellipse by this many radians clockwise around its center. + pub angle: f32, +} + +impl EllipseShape { + #[inline] + pub fn filled(center: Pos2, radius: Vec2, fill_color: impl Into) -> Self { + Self { + center, + radius, + fill: fill_color.into(), + stroke: Default::default(), + angle: 0.0, + } + } + + #[inline] + pub fn stroke(center: Pos2, radius: Vec2, stroke: impl Into) -> Self { + Self { + center, + radius, + fill: Default::default(), + stroke: stroke.into(), + angle: 0.0, + } + } + + /// Set the rotation of the ellipse (in radians, clockwise). + /// The ellipse rotates around its center. + #[inline] + pub fn with_angle(mut self, angle: f32) -> Self { + self.angle = angle; + self + } + + /// Set the rotation of the ellipse (in radians, clockwise) around a custom pivot point. + #[inline] + pub fn with_angle_and_pivot(mut self, angle: f32, pivot: Pos2) -> Self { + self.angle = angle; + let rot = emath::Rot2::from_angle(angle); + self.center = pivot + rot * (self.center - pivot); + self + } + + /// The visual bounding rectangle (includes stroke width) + pub fn visual_bounding_rect(&self) -> Rect { + if self.fill == Color32::TRANSPARENT && self.stroke.is_empty() { + Rect::NOTHING + } else { + let rect = Rect::from_center_size( + Pos2::ZERO, + self.radius * 2.0 + Vec2::splat(self.stroke.width), + ); + rect.rotate_bb(emath::Rot2::from_angle(self.angle)) + .translate(self.center.to_vec2()) + } + } +} + +impl From for Shape { + #[inline(always)] + fn from(shape: EllipseShape) -> Self { + Self::Ellipse(shape) + } +} diff --git a/crates/accshift-egui/vendor/epaint/src/shapes/mod.rs b/crates/accshift-egui/vendor/epaint/src/shapes/mod.rs new file mode 100644 index 0000000..8a42b2c --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/shapes/mod.rs @@ -0,0 +1,19 @@ +mod bezier_shape; +mod circle_shape; +mod ellipse_shape; +mod paint_callback; +mod path_shape; +mod rect_shape; +mod shape; +mod text_shape; + +pub use self::{ + bezier_shape::{CubicBezierShape, QuadraticBezierShape}, + circle_shape::CircleShape, + ellipse_shape::EllipseShape, + paint_callback::{PaintCallback, PaintCallbackInfo}, + path_shape::PathShape, + rect_shape::RectShape, + shape::Shape, + text_shape::TextShape, +}; diff --git a/crates/accshift-egui/vendor/epaint/src/shapes/paint_callback.rs b/crates/accshift-egui/vendor/epaint/src/shapes/paint_callback.rs new file mode 100644 index 0000000..a451092 --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/shapes/paint_callback.rs @@ -0,0 +1,104 @@ +use core::any::Any; +use std::sync::Arc; + +use crate::*; + +/// Information passed along with [`PaintCallback`] ([`Shape::Callback`]). +pub struct PaintCallbackInfo { + /// Viewport in points. + /// + /// This specifies where on the screen to paint, and the borders of this + /// Rect is the [-1, +1] of the Normalized Device Coordinates. + /// + /// Note than only a portion of this may be visible due to [`Self::clip_rect`]. + /// + /// This comes from [`PaintCallback::rect`]. + pub viewport: Rect, + + /// Clip rectangle in points. + pub clip_rect: Rect, + + /// Pixels per point. + pub pixels_per_point: f32, + + /// Full size of the screen, in pixels. + pub screen_size_px: [u32; 2], +} + +#[test] +fn test_viewport_rounding() { + for i in 0..=10_000 { + // Two adjacent viewports should never overlap: + let x = i as f32 / 97.0; + let left = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)).with_max_x(x); + let right = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 100.0)).with_min_x(x); + + for pixels_per_point in [0.618, 1.0, core::f32::consts::PI] { + let left = ViewportInPixels::from_points(&left, pixels_per_point, [100, 100]); + let right = ViewportInPixels::from_points(&right, pixels_per_point, [100, 100]); + assert_eq!(left.left_px + left.width_px, right.left_px); + } + } +} + +impl PaintCallbackInfo { + /// The viewport rectangle. This is what you would use in e.g. `glViewport`. + pub fn viewport_in_pixels(&self) -> ViewportInPixels { + ViewportInPixels::from_points(&self.viewport, self.pixels_per_point, self.screen_size_px) + } + + /// The "scissor" or "clip" rectangle. This is what you would use in e.g. `glScissor`. + pub fn clip_rect_in_pixels(&self) -> ViewportInPixels { + ViewportInPixels::from_points(&self.clip_rect, self.pixels_per_point, self.screen_size_px) + } +} + +/// If you want to paint some 3D shapes inside an egui region, you can use this. +/// +/// This is advanced usage, and is backend specific. +#[derive(Clone)] +pub struct PaintCallback { + /// Where to paint. + /// + /// This will become [`PaintCallbackInfo::viewport`]. + pub rect: Rect, + + /// Paint something custom (e.g. 3D stuff). + /// + /// The concrete value of `callback` depends on the rendering backend used. For instance, the + /// `glow` backend requires that callback be an `egui_glow::CallbackFn` while the `wgpu` + /// backend requires a `egui_wgpu::Callback`. + /// + /// If the type cannot be downcast to the type expected by the current backend the callback + /// will not be drawn. + /// + /// The rendering backend is responsible for first setting the active viewport to + /// [`Self::rect`]. + /// + /// The rendering backend is also responsible for restoring any state, such as the bound shader + /// program, vertex array, etc. + /// + /// Shape has to be clone, therefore this has to be an `Arc` instead of a `Box`. + pub callback: Arc, +} + +impl core::fmt::Debug for PaintCallback { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("CustomShape") + .field("rect", &self.rect) + .finish_non_exhaustive() + } +} + +impl core::cmp::PartialEq for PaintCallback { + fn eq(&self, other: &Self) -> bool { + self.rect.eq(&other.rect) && Arc::ptr_eq(&self.callback, &other.callback) + } +} + +impl From for Shape { + #[inline(always)] + fn from(shape: PaintCallback) -> Self { + Self::Callback(shape) + } +} diff --git a/crates/accshift-egui/vendor/epaint/src/shapes/path_shape.rs b/crates/accshift-egui/vendor/epaint/src/shapes/path_shape.rs new file mode 100644 index 0000000..8486055 --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/shapes/path_shape.rs @@ -0,0 +1,81 @@ +use crate::*; + +/// A path which can be stroked and/or filled (if closed). +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct PathShape { + /// Filled paths should prefer clockwise order. + pub points: Vec, + + /// If true, connect the first and last of the points together. + /// This is required if `fill != TRANSPARENT`. + pub closed: bool, + + /// Fill is only supported for convex polygons. + pub fill: Color32, + + /// Color and thickness of the line. + pub stroke: PathStroke, + // TODO(emilk): Add texture support either by supplying uv for each point, + // or by some transform from points to uv (e.g. a callback or a linear transform matrix). +} + +impl PathShape { + /// A line through many points. + /// + /// Use [`Shape::line_segment`] instead if your line only connects two points. + #[inline] + pub fn line(points: Vec, stroke: impl Into) -> Self { + Self { + points, + closed: false, + fill: Default::default(), + stroke: stroke.into(), + } + } + + /// A line that closes back to the start point again. + #[inline] + pub fn closed_line(points: Vec, stroke: impl Into) -> Self { + Self { + points, + closed: true, + fill: Default::default(), + stroke: stroke.into(), + } + } + + /// A convex polygon with a fill and optional stroke. + /// + /// The most performant winding order is clockwise. + #[inline] + pub fn convex_polygon( + points: Vec, + fill: impl Into, + stroke: impl Into, + ) -> Self { + Self { + points, + closed: true, + fill: fill.into(), + stroke: stroke.into(), + } + } + + /// The visual bounding rectangle (includes stroke width) + #[inline] + pub fn visual_bounding_rect(&self) -> Rect { + if self.fill == Color32::TRANSPARENT && self.stroke.is_empty() { + Rect::NOTHING + } else { + Rect::from_points(&self.points).expand(self.stroke.width / 2.0) + } + } +} + +impl From for Shape { + #[inline(always)] + fn from(shape: PathShape) -> Self { + Self::Path(shape) + } +} diff --git a/crates/accshift-egui/vendor/epaint/src/shapes/rect_shape.rs b/crates/accshift-egui/vendor/epaint/src/shapes/rect_shape.rs new file mode 100644 index 0000000..159d56a --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/shapes/rect_shape.rs @@ -0,0 +1,223 @@ +use std::sync::Arc; + +use crate::*; + +/// How to paint a rectangle. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct RectShape { + pub rect: Rect, + + /// How rounded the corners of the rectangle are. + /// + /// Use [`CornerRadius::ZERO`] for for sharp corners. + /// + /// This is the corner radii of the rectangle. + /// If there is a stroke, then the stroke will have an inner and outer corner radius, + /// and those will depend on [`StrokeKind`] and the stroke width. + /// + /// For [`StrokeKind::Inside`], the outside of the stroke coincides with the rectangle, + /// so the rounding will in this case specify the outer corner radius. + pub corner_radius: CornerRadius, + + /// How to fill the rectangle. + pub fill: Color32, + + /// The thickness and color of the outline. + /// + /// Whether or not the stroke is inside or outside the edge of [`Self::rect`], + /// is controlled by [`Self::stroke_kind`]. + pub stroke: Stroke, + + /// Is the stroke on the inside, outside, or centered on the rectangle? + /// + /// If you want to perfectly tile rectangles, use [`StrokeKind::Inside`]. + pub stroke_kind: StrokeKind, + + /// Snap the rectangle to pixels? + /// + /// Rounding produces sharper rectangles. + /// + /// If `None`, [`crate::TessellationOptions::round_rects_to_pixels`] will be used. + pub round_to_pixels: Option, + + /// If larger than zero, the edges of the rectangle + /// (for both fill and stroke) will be blurred. + /// + /// This can be used to produce shadows and glow effects. + /// + /// The blur is currently implemented using a simple linear blur in sRGBA gamma space. + pub blur_width: f32, + + /// Controls texturing, if any. + /// + /// Since most rectangles do not have a texture, this is optional and in an `Arc`, + /// so that [`RectShape`] is kept small.. + pub brush: Option>, + + /// Rotate rectangle by this many radians clockwise around its center. + pub angle: f32, +} + +#[test] +fn rect_shape_size() { + assert_eq!( + core::mem::size_of::(), + 56, + "RectShape changed size! If it shrank - good! Update this test. If it grew - bad! Try to find a way to avoid it." + ); + assert!( + core::mem::size_of::() <= 64, + "RectShape is getting way too big!" + ); +} + +impl RectShape { + /// See also [`Self::filled`] and [`Self::stroke`]. + #[inline] + pub fn new( + rect: Rect, + corner_radius: impl Into, + fill_color: impl Into, + stroke: impl Into, + stroke_kind: StrokeKind, + ) -> Self { + Self { + rect, + corner_radius: corner_radius.into(), + fill: fill_color.into(), + stroke: stroke.into(), + stroke_kind, + round_to_pixels: None, + blur_width: 0.0, + brush: Default::default(), + angle: 0.0, + } + } + + #[inline] + pub fn filled( + rect: Rect, + corner_radius: impl Into, + fill_color: impl Into, + ) -> Self { + Self::new( + rect, + corner_radius, + fill_color, + Stroke::NONE, + StrokeKind::Outside, // doesn't matter + ) + } + + #[inline] + pub fn stroke( + rect: Rect, + corner_radius: impl Into, + stroke: impl Into, + stroke_kind: StrokeKind, + ) -> Self { + let fill = Color32::TRANSPARENT; + Self::new(rect, corner_radius, fill, stroke, stroke_kind) + } + + /// Set if the stroke is on the inside, outside, or centered on the rectangle. + #[inline] + pub fn with_stroke_kind(mut self, stroke_kind: StrokeKind) -> Self { + self.stroke_kind = stroke_kind; + self + } + + /// Snap the rectangle to pixels? + /// + /// Rounding produces sharper rectangles. + /// + /// If `None`, [`crate::TessellationOptions::round_rects_to_pixels`] will be used. + #[inline] + pub fn with_round_to_pixels(mut self, round_to_pixels: bool) -> Self { + self.round_to_pixels = Some(round_to_pixels); + self + } + + /// If larger than zero, the edges of the rectangle + /// (for both fill and stroke) will be blurred. + /// + /// This can be used to produce shadows and glow effects. + /// + /// The blur is currently implemented using a simple linear blur in `sRGBA` gamma space. + #[inline] + pub fn with_blur_width(mut self, blur_width: f32) -> Self { + self.blur_width = blur_width; + self + } + + /// Set the texture to use when painting this rectangle, if any. + #[inline] + pub fn with_texture(mut self, fill_texture_id: TextureId, uv: Rect) -> Self { + self.brush = Some(Arc::new(Brush { + fill_texture_id, + uv, + })); + self + } + + /// Set the rotation of the rectangle (in radians, clockwise). + /// The rectangle rotates around its center. + #[inline] + pub fn with_angle(mut self, angle: f32) -> Self { + self.angle = angle; + self + } + + /// Set the rotation of the rectangle (in radians, clockwise) around a custom pivot point. + #[inline] + pub fn with_angle_and_pivot(mut self, angle: f32, pivot: Pos2) -> Self { + self.angle = angle; + let rot = emath::Rot2::from_angle(angle); + let center = self.rect.center(); + let new_center = pivot + rot * (center - pivot); + self.rect = self.rect.translate(new_center - center); + self + } + + /// The visual bounding rectangle (includes stroke width) + #[inline] + pub fn visual_bounding_rect(&self) -> Rect { + if self.fill == Color32::TRANSPARENT && self.stroke.is_empty() { + Rect::NOTHING + } else { + let expand = match self.stroke_kind { + StrokeKind::Inside => 0.0, + StrokeKind::Middle => self.stroke.width / 2.0, + StrokeKind::Outside => self.stroke.width, + }; + let expanded = self.rect.expand(expand + self.blur_width / 2.0); + if self.angle == 0.0 { + expanded + } else { + // Rotate around the rectangle's center and compute bounding box + let center = self.rect.center(); + let rect_relative = Rect::from_center_size(Pos2::ZERO, expanded.size()); + rect_relative + .rotate_bb(emath::Rot2::from_angle(self.angle)) + .translate(center.to_vec2()) + } + } + } + + /// The texture to use when painting this rectangle, if any. + /// + /// If no texture is set, this will return [`TextureId::default`]. + pub fn fill_texture_id(&self) -> TextureId { + self.brush + .as_ref() + .map_or_else(TextureId::default, |brush| brush.fill_texture_id) + } +} + +impl From for Shape { + #[inline(always)] + fn from(shape: RectShape) -> Self { + Self::Rect(shape) + } +} diff --git a/crates/accshift-egui/vendor/epaint/src/shapes/shape.rs b/crates/accshift-egui/vendor/epaint/src/shapes/shape.rs new file mode 100644 index 0000000..55be52b --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/shapes/shape.rs @@ -0,0 +1,578 @@ +//! The different shapes that can be painted. + +use std::sync::Arc; + +use emath::{Align2, Pos2, Rangef, Rect, TSTransform, Vec2, pos2}; + +use crate::{ + Color32, CornerRadius, Direction, Mesh, Stroke, StrokeKind, TextureId, Vertex, + stroke::PathStroke, + text::{FontId, FontsView, Galley}, +}; + +use super::{ + CircleShape, CubicBezierShape, EllipseShape, PaintCallback, PathShape, QuadraticBezierShape, + RectShape, TextShape, +}; + +/// A paint primitive such as a circle or a piece of text. +/// Coordinates are all screen space points (not physical pixels). +/// +/// You should generally recreate your [`Shape`]s each frame, +/// but storing them should also be fine with one exception: +/// [`Shape::Text`] depends on the current `pixels_per_point` (dpi scale) +/// and so must be recreated every time `pixels_per_point` changes. +#[must_use = "Add a Shape to a Painter"] +#[derive(Clone, Debug, PartialEq)] +pub enum Shape { + /// Paint nothing. This can be useful as a placeholder. + Noop, + + /// Recursively nest more shapes - sometimes a convenience to be able to do. + /// For performance reasons it is better to avoid it. + Vec(Vec), + + /// Circle with optional outline and fill. + Circle(CircleShape), + + /// Ellipse with optional outline and fill. + Ellipse(EllipseShape), + + /// A line between two points. + LineSegment { points: [Pos2; 2], stroke: Stroke }, + + /// A series of lines between points. + /// The path can have a stroke and/or fill (if closed). + Path(PathShape), + + /// Rectangle with optional outline and fill. + Rect(RectShape), + + /// Text. + /// + /// This needs to be recreated if `pixels_per_point` (dpi scale) changes. + Text(TextShape), + + /// A general triangle mesh. + /// + /// Can be used to display images. + /// + /// Wrapped in an [`Arc`] to minimize the size of [`Shape`]. + Mesh(Arc), + + /// A quadratic [Bézier Curve](https://en.wikipedia.org/wiki/B%C3%A9zier_curve). + QuadraticBezier(QuadraticBezierShape), + + /// A cubic [Bézier Curve](https://en.wikipedia.org/wiki/B%C3%A9zier_curve). + CubicBezier(CubicBezierShape), + + /// Backend-specific painting. + Callback(PaintCallback), +} + +#[test] +fn shape_size() { + assert_eq!( + core::mem::size_of::(), + 64, + "Shape changed size! If it shrank - good! Update this test. If it grew - bad! Try to find a way to avoid it." + ); + assert!( + core::mem::size_of::() <= 64, + "Shape is getting way too big!" + ); +} + +#[test] +fn shape_impl_send_sync() { + fn assert_send_sync() {} + assert_send_sync::(); +} + +impl From> for Shape { + #[inline(always)] + fn from(shapes: Vec) -> Self { + Self::Vec(shapes) + } +} + +impl From for Shape { + #[inline(always)] + fn from(mesh: Mesh) -> Self { + Self::Mesh(mesh.into()) + } +} + +impl From> for Shape { + #[inline(always)] + fn from(mesh: Arc) -> Self { + Self::Mesh(mesh) + } +} + +/// ## Constructors +impl Shape { + /// A line between two points. + /// More efficient than calling [`Self::line`]. + #[inline] + pub fn line_segment(points: [Pos2; 2], stroke: impl Into) -> Self { + Self::LineSegment { + points, + stroke: stroke.into(), + } + } + + /// A horizontal line. + pub fn hline(x: impl Into, y: f32, stroke: impl Into) -> Self { + let x = x.into(); + Self::LineSegment { + points: [pos2(x.min, y), pos2(x.max, y)], + stroke: stroke.into(), + } + } + + /// A vertical line. + pub fn vline(x: f32, y: impl Into, stroke: impl Into) -> Self { + let y = y.into(); + Self::LineSegment { + points: [pos2(x, y.min), pos2(x, y.max)], + stroke: stroke.into(), + } + } + + /// A line through many points. + /// + /// Use [`Self::line_segment`] instead if your line only connects two points. + #[inline] + pub fn line(points: Vec, stroke: impl Into) -> Self { + Self::Path(PathShape::line(points, stroke)) + } + + /// A line that closes back to the start point again. + #[inline] + pub fn closed_line(points: Vec, stroke: impl Into) -> Self { + Self::Path(PathShape::closed_line(points, stroke)) + } + + /// Turn a line into equally spaced dots. + pub fn dotted_line( + path: &[Pos2], + color: impl Into, + spacing: f32, + radius: f32, + ) -> Vec { + let mut shapes = Vec::new(); + points_from_line(path, spacing, radius, color.into(), &mut shapes); + shapes + } + + /// Turn a line into dashes. + pub fn dashed_line( + path: &[Pos2], + stroke: impl Into, + dash_length: f32, + gap_length: f32, + ) -> Vec { + let mut shapes = Vec::new(); + dashes_from_line( + path, + stroke.into(), + &[dash_length], + &[gap_length], + &mut shapes, + 0., + ); + shapes + } + + /// Turn a line into dashes with different dash/gap lengths and a start offset. + pub fn dashed_line_with_offset( + path: &[Pos2], + stroke: impl Into, + dash_lengths: &[f32], + gap_lengths: &[f32], + dash_offset: f32, + ) -> Vec { + let mut shapes = Vec::new(); + dashes_from_line( + path, + stroke.into(), + dash_lengths, + gap_lengths, + &mut shapes, + dash_offset, + ); + shapes + } + + /// Turn a line into dashes. If you need to create many dashed lines use this instead of + /// [`Self::dashed_line`]. + pub fn dashed_line_many( + points: &[Pos2], + stroke: impl Into, + dash_length: f32, + gap_length: f32, + shapes: &mut Vec, + ) { + dashes_from_line( + points, + stroke.into(), + &[dash_length], + &[gap_length], + shapes, + 0., + ); + } + + /// Turn a line into dashes with different dash/gap lengths and a start offset. If you need to + /// create many dashed lines use this instead of [`Self::dashed_line_with_offset`]. + pub fn dashed_line_many_with_offset( + points: &[Pos2], + stroke: impl Into, + dash_lengths: &[f32], + gap_lengths: &[f32], + dash_offset: f32, + shapes: &mut Vec, + ) { + dashes_from_line( + points, + stroke.into(), + dash_lengths, + gap_lengths, + shapes, + dash_offset, + ); + } + + /// A convex polygon with a fill and optional stroke. + /// + /// The most performant winding order is clockwise. + #[inline] + pub fn convex_polygon( + points: Vec, + fill: impl Into, + stroke: impl Into, + ) -> Self { + Self::Path(PathShape::convex_polygon(points, fill, stroke)) + } + + #[inline] + pub fn circle_filled(center: Pos2, radius: f32, fill_color: impl Into) -> Self { + Self::Circle(CircleShape::filled(center, radius, fill_color)) + } + + #[inline] + pub fn circle_stroke(center: Pos2, radius: f32, stroke: impl Into) -> Self { + Self::Circle(CircleShape::stroke(center, radius, stroke)) + } + + #[inline] + pub fn ellipse_filled(center: Pos2, radius: Vec2, fill_color: impl Into) -> Self { + Self::Ellipse(EllipseShape::filled(center, radius, fill_color)) + } + + #[inline] + pub fn ellipse_stroke(center: Pos2, radius: Vec2, stroke: impl Into) -> Self { + Self::Ellipse(EllipseShape::stroke(center, radius, stroke)) + } + + /// See also [`Self::rect_stroke`]. + #[inline] + pub fn rect_filled( + rect: Rect, + corner_radius: impl Into, + fill_color: impl Into, + ) -> Self { + Self::Rect(RectShape::filled(rect, corner_radius, fill_color)) + } + + /// See also [`Self::rect_filled`]. + #[inline] + pub fn rect_stroke( + rect: Rect, + corner_radius: impl Into, + stroke: impl Into, + stroke_kind: StrokeKind, + ) -> Self { + Self::Rect(RectShape::stroke(rect, corner_radius, stroke, stroke_kind)) + } + + /// Paints a gradient rectangle that transitions from `color_from` to `color_to` + /// along the given `direction`. + /// + /// For example, [`Direction::TopDown`] paints `color_from` at the top edge fading + /// to `color_to` at the bottom edge. + #[inline] + pub fn gradient_rect(rect: Rect, direction: Direction, [from, to]: [Color32; 2]) -> Self { + let (left_top, right_top, left_bottom, right_bottom) = match direction { + Direction::TopDown => (from, from, to, to), + Direction::BottomUp => (to, to, from, from), + Direction::LeftToRight => (from, to, from, to), + Direction::RightToLeft => (to, from, to, from), + }; + + Self::from(Mesh { + indices: vec![0, 1, 2, 2, 1, 3], + vertices: vec![ + Vertex::untextured(rect.left_top(), left_top), + Vertex::untextured(rect.right_top(), right_top), + Vertex::untextured(rect.left_bottom(), left_bottom), + Vertex::untextured(rect.right_bottom(), right_bottom), + ], + texture_id: Default::default(), + }) + } + + #[expect(clippy::needless_pass_by_value)] + pub fn text( + fonts: &mut FontsView<'_>, + pos: Pos2, + anchor: Align2, + text: impl ToString, + font_id: FontId, + color: Color32, + ) -> Self { + let galley = fonts.layout_no_wrap(text.to_string(), font_id, color); + let rect = anchor.anchor_size(pos, galley.size()); + Self::galley(rect.min, galley, color) + } + + /// Any uncolored parts of the [`Galley`] (using [`Color32::PLACEHOLDER`]) will be replaced with the given color. + /// + /// Any non-placeholder color in the galley takes precedence over this fallback color. + #[inline] + pub fn galley(pos: Pos2, galley: Arc, fallback_color: Color32) -> Self { + TextShape::new(pos, galley, fallback_color).into() + } + + /// All text color in the [`Galley`] will be replaced with the given color. + #[inline] + pub fn galley_with_override_text_color( + pos: Pos2, + galley: Arc, + text_color: Color32, + ) -> Self { + TextShape::new(pos, galley, text_color) + .with_override_text_color(text_color) + .into() + } + + #[inline] + pub fn mesh(mesh: impl Into>) -> Self { + let mesh = mesh.into(); + debug_assert!(mesh.is_valid(), "Invalid mesh: {mesh:#?}"); + Self::Mesh(mesh) + } + + /// An image at the given position. + /// + /// `uv` should normally be `Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0))` + /// unless you want to crop or flip the image. + /// + /// `tint` is a color multiplier. Use [`Color32::WHITE`] if you don't want to tint the image. + pub fn image(texture_id: TextureId, rect: Rect, uv: Rect, tint: Color32) -> Self { + let mut mesh = Mesh::with_texture(texture_id); + mesh.add_rect_with_uv(rect, uv, tint); + Self::mesh(mesh) + } + + /// The visual bounding rectangle (includes stroke widths) + pub fn visual_bounding_rect(&self) -> Rect { + match self { + Self::Noop => Rect::NOTHING, + Self::Vec(shapes) => { + let mut rect = Rect::NOTHING; + for shape in shapes { + rect |= shape.visual_bounding_rect(); + } + rect + } + Self::Circle(circle_shape) => circle_shape.visual_bounding_rect(), + Self::Ellipse(ellipse_shape) => ellipse_shape.visual_bounding_rect(), + Self::LineSegment { points, stroke } => { + if stroke.is_empty() { + Rect::NOTHING + } else { + Rect::from_two_pos(points[0], points[1]).expand(stroke.width / 2.0) + } + } + Self::Path(path_shape) => path_shape.visual_bounding_rect(), + Self::Rect(rect_shape) => rect_shape.visual_bounding_rect(), + Self::Text(text_shape) => text_shape.visual_bounding_rect(), + Self::Mesh(mesh) => mesh.calc_bounds(), + Self::QuadraticBezier(bezier) => bezier.visual_bounding_rect(), + Self::CubicBezier(bezier) => bezier.visual_bounding_rect(), + Self::Callback(custom) => custom.rect, + } + } +} + +/// ## Inspection and transforms +impl Shape { + #[inline(always)] + pub fn texture_id(&self) -> crate::TextureId { + if let Self::Mesh(mesh) = self { + mesh.texture_id + } else if let Self::Rect(rect_shape) = self { + rect_shape.fill_texture_id() + } else { + crate::TextureId::default() + } + } + + /// Scale the shape by `factor`, in-place. + /// + /// A wrapper around [`Self::transform`]. + #[inline(always)] + pub fn scale(&mut self, factor: f32) { + self.transform(TSTransform::from_scaling(factor)); + } + + /// Move the shape by `delta`, in-place. + /// + /// A wrapper around [`Self::transform`]. + #[inline(always)] + pub fn translate(&mut self, delta: Vec2) { + self.transform(TSTransform::from_translation(delta)); + } + + /// Transform (move/scale) the shape in-place. + /// + /// If using a [`PaintCallback`], note that only the rect is scaled as opposed + /// to other shapes where the stroke is also scaled. + pub fn transform(&mut self, transform: TSTransform) { + match self { + Self::Noop => {} + Self::Vec(shapes) => { + for shape in shapes { + shape.transform(transform); + } + } + Self::Circle(circle_shape) => { + circle_shape.center = transform * circle_shape.center; + circle_shape.radius *= transform.scaling; + circle_shape.stroke.width *= transform.scaling; + } + Self::Ellipse(ellipse_shape) => { + ellipse_shape.center = transform * ellipse_shape.center; + ellipse_shape.radius *= transform.scaling; + ellipse_shape.stroke.width *= transform.scaling; + } + Self::LineSegment { points, stroke } => { + for p in points { + *p = transform * *p; + } + stroke.width *= transform.scaling; + } + Self::Path(path_shape) => { + for p in &mut path_shape.points { + *p = transform * *p; + } + path_shape.stroke.width *= transform.scaling; + } + Self::Rect(rect_shape) => { + rect_shape.rect = transform * rect_shape.rect; + rect_shape.corner_radius *= transform.scaling; + rect_shape.stroke.width *= transform.scaling; + rect_shape.blur_width *= transform.scaling; + } + Self::Text(text_shape) => { + text_shape.transform(transform); + } + Self::Mesh(mesh) => { + Arc::make_mut(mesh).transform(transform); + } + Self::QuadraticBezier(bezier) => { + for p in &mut bezier.points { + *p = transform * *p; + } + bezier.stroke.width *= transform.scaling; + } + Self::CubicBezier(bezier) => { + for p in &mut bezier.points { + *p = transform * *p; + } + bezier.stroke.width *= transform.scaling; + } + Self::Callback(shape) => { + shape.rect = transform * shape.rect; + } + } + } +} + +// ---------------------------------------------------------------------------- + +/// Creates equally spaced filled circles from a line. +fn points_from_line( + path: &[Pos2], + spacing: f32, + radius: f32, + color: Color32, + shapes: &mut Vec, +) { + let mut position_on_segment = 0.0; + for &[start, end] in path.array_windows() { + let vector = end - start; + let segment_length = vector.length(); + while position_on_segment < segment_length { + let new_point = start + vector * (position_on_segment / segment_length); + shapes.push(Shape::circle_filled(new_point, radius, color)); + position_on_segment += spacing; + } + position_on_segment -= segment_length; + } +} + +/// Creates dashes from a line. +fn dashes_from_line( + path: &[Pos2], + stroke: Stroke, + dash_lengths: &[f32], + gap_lengths: &[f32], + shapes: &mut Vec, + dash_offset: f32, +) { + assert_eq!( + dash_lengths.len(), + gap_lengths.len(), + "Mismatched dash and gap lengths, got dash_lengths: {}, gap_lengths: {}", + dash_lengths.len(), + gap_lengths.len() + ); + let mut position_on_segment = dash_offset; + let mut drawing_dash = false; + let mut step = 0; + let steps = dash_lengths.len(); + for &[start, end] in path.array_windows() { + let vector = end - start; + let segment_length = vector.length(); + + let mut start_point = start; + while position_on_segment < segment_length { + let new_point = start + vector * (position_on_segment / segment_length); + if drawing_dash { + // This is the end point. + shapes.push(Shape::line_segment([start_point, new_point], stroke)); + position_on_segment += gap_lengths[step]; + // Increment step counter + step += 1; + if step >= steps { + step = 0; + } + } else { + // Start a new dash. + start_point = new_point; + position_on_segment += dash_lengths[step]; + } + drawing_dash = !drawing_dash; + } + + // If the segment ends and the dash is not finished, add the segment's end point. + if drawing_dash { + shapes.push(Shape::line_segment([start_point, end], stroke)); + } + + position_on_segment -= segment_length; + } +} diff --git a/crates/accshift-egui/vendor/epaint/src/shapes/text_shape.rs b/crates/accshift-egui/vendor/epaint/src/shapes/text_shape.rs new file mode 100644 index 0000000..8083dcf --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/shapes/text_shape.rs @@ -0,0 +1,213 @@ +use std::sync::Arc; + +use emath::{Align2, Rot2}; + +use crate::*; + +/// How to paint some text on screen. +/// +/// This needs to be recreated if `pixels_per_point` (dpi scale) changes. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct TextShape { + /// Where the origin of [`Self::galley`] is. + /// + /// Usually the top left corner of the first character. + pub pos: Pos2, + + /// The laid out text, from [`FontsView::layout_job`]. + pub galley: Arc, + + /// Add this underline to the whole text. + /// You can also set an underline when creating the galley. + pub underline: Stroke, + + /// Any [`Color32::PLACEHOLDER`] in the galley will be replaced by the given color. + /// Affects everything: backgrounds, glyphs, strikethrough, underline, etc. + pub fallback_color: Color32, + + /// If set, the text color in the galley will be ignored and replaced + /// with the given color. + /// + /// This only affects the glyphs and will NOT replace background color nor strikethrough/underline color. + pub override_text_color: Option, + + /// If set, the text will be rendered with the given opacity in gamma space + /// Affects everything: backgrounds, glyphs, strikethrough, underline, etc. + pub opacity_factor: f32, + + /// Rotate text by this many radians clockwise. + /// The pivot is `pos` (the upper left corner of the text). + pub angle: f32, +} + +impl TextShape { + /// The given fallback color will be used for any uncolored part of the galley (using [`Color32::PLACEHOLDER`]). + /// + /// Any non-placeholder color in the galley takes precedence over this fallback color. + #[inline] + pub fn new(pos: Pos2, galley: Arc, fallback_color: Color32) -> Self { + Self { + pos, + galley, + underline: Stroke::NONE, + fallback_color, + override_text_color: None, + opacity_factor: 1.0, + angle: 0.0, + } + } + + /// The visual bounding rectangle + #[inline] + pub fn visual_bounding_rect(&self) -> Rect { + self.galley + .mesh_bounds + .rotate_bb(emath::Rot2::from_angle(self.angle)) + .translate(self.pos.to_vec2()) + } + + #[inline] + pub fn with_underline(mut self, underline: Stroke) -> Self { + self.underline = underline; + self + } + + /// Use the given color for the text, regardless of what color is already in the galley. + #[inline] + pub fn with_override_text_color(mut self, override_text_color: Color32) -> Self { + self.override_text_color = Some(override_text_color); + self + } + + /// Set text rotation to `angle` radians clockwise. + /// The pivot is `pos` (the upper left corner of the text). + #[inline] + pub fn with_angle(mut self, angle: f32) -> Self { + self.angle = angle; + self + } + + /// Set the text rotation to the `angle` radians clockwise. + /// The pivot is determined by the given `anchor` point on the text bounding box. + #[inline] + pub fn with_angle_and_anchor(mut self, angle: f32, anchor: Align2) -> Self { + self.angle = angle; + let a0 = anchor.pos_in_rect(&self.galley.rect).to_vec2(); + let a1 = Rot2::from_angle(angle) * a0; + self.pos += a0 - a1; + self + } + + /// Render text with this opacity in gamma space + #[inline] + pub fn with_opacity_factor(mut self, opacity_factor: f32) -> Self { + self.opacity_factor = opacity_factor; + self + } + + /// Move the shape by this many points, in-place. + pub fn transform(&mut self, transform: emath::TSTransform) { + let Self { + pos, + galley, + underline, + fallback_color: _, + override_text_color: _, + opacity_factor: _, + angle: _, + } = self; + + *pos = transform * *pos; + underline.width *= transform.scaling; + + let Galley { + job: _, + rows, + elided: _, + rect, + mesh_bounds, + num_vertices: _, + num_indices: _, + pixels_per_point: _, + intrinsic_size, + } = Arc::make_mut(galley); + + *rect = transform.scaling * *rect; + *mesh_bounds = transform.scaling * *mesh_bounds; + *intrinsic_size = transform.scaling * *intrinsic_size; + + for text::PlacedRow { + pos, + row, + ends_with_newline: _, + } in rows + { + *pos *= transform.scaling; + + let text::Row { + section_index_at_start: _, + glyphs: _, // TODO(emilk): would it make sense to transform these? + size, + visuals, + } = Arc::make_mut(row); + + *size *= transform.scaling; + + let text::RowVisuals { + mesh, + mesh_bounds, + glyph_index_start: _, + glyph_vertex_range: _, + } = visuals; + + *mesh_bounds = transform.scaling * *mesh_bounds; + + for v in &mut mesh.vertices { + v.pos *= transform.scaling; + } + } + } +} + +impl From for Shape { + #[inline(always)] + fn from(shape: TextShape) -> Self { + Self::Text(shape) + } +} + +#[cfg(test)] +mod tests { + use super::{super::*, *}; + use crate::text::FontDefinitions; + use emath::almost_equal; + + #[test] + fn text_bounding_box_under_rotation() { + let mut fonts = Fonts::new(TextOptions::default(), FontDefinitions::default()); + let font = FontId::monospace(12.0); + + let mut t = crate::Shape::text( + &mut fonts.with_pixels_per_point(1.0), + Pos2::ZERO, + emath::Align2::CENTER_CENTER, + "testing123", + font, + Color32::BLACK, + ); + + let size_orig = t.visual_bounding_rect().size(); + + // 90 degree rotation + if let Shape::Text(ts) = &mut t { + ts.angle = core::f32::consts::PI / 2.0; + } + + let size_rot = t.visual_bounding_rect().size(); + + // make sure the box is actually rotated + assert!(almost_equal(size_orig.x, size_rot.y, 1e-4)); + assert!(almost_equal(size_orig.y, size_rot.x, 1e-4)); + } +} diff --git a/crates/accshift-egui/vendor/epaint/src/stats.rs b/crates/accshift-egui/vendor/epaint/src/stats.rs new file mode 100644 index 0000000..8c191dc --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/stats.rs @@ -0,0 +1,244 @@ +//! Collect statistics about what is being painted. + +use crate::{ClippedShape, Galley, Mesh, Primitive, Shape}; + +/// Size of the elements in a vector/array. +#[derive(Clone, Copy, Default, PartialEq)] +enum ElementSize { + #[default] + Unknown, + Homogeneous(usize), + Heterogenous, +} + +/// Aggregate information about a bunch of allocations. +#[derive(Clone, Copy, Default, PartialEq)] +pub struct AllocInfo { + element_size: ElementSize, + num_allocs: usize, + num_elements: usize, + num_bytes: usize, +} + +impl From<&[T]> for AllocInfo { + fn from(slice: &[T]) -> Self { + Self::from_slice(slice) + } +} + +impl core::ops::Add for AllocInfo { + type Output = Self; + + fn add(self, rhs: Self) -> Self { + use ElementSize::{Heterogenous, Homogeneous, Unknown}; + let element_size = match (self.element_size, rhs.element_size) { + (Heterogenous, _) | (_, Heterogenous) => Heterogenous, + (Unknown, other) | (other, Unknown) => other, + (Homogeneous(lhs), Homogeneous(rhs)) if lhs == rhs => Homogeneous(lhs), + _ => Heterogenous, + }; + + Self { + element_size, + num_allocs: self.num_allocs + rhs.num_allocs, + num_elements: self.num_elements + rhs.num_elements, + num_bytes: self.num_bytes + rhs.num_bytes, + } + } +} + +impl core::ops::AddAssign for AllocInfo { + fn add_assign(&mut self, rhs: Self) { + *self = *self + rhs; + } +} + +impl core::iter::Sum for AllocInfo { + fn sum(iter: I) -> Self + where + I: Iterator, + { + let mut sum = Self::default(); + for value in iter { + sum += value; + } + sum + } +} + +impl AllocInfo { + // pub fn from_shape(shape: &Shape) -> Self { + // match shape { + // Shape::Noop + // Shape::Vec(shapes) => Self::from_shapes(shapes) + // | Shape::Circle { .. } + // | Shape::LineSegment { .. } + // | Shape::Rect { .. } => Self::default(), + // Shape::Path { points, .. } => Self::from_slice(points), + // Shape::Text { galley, .. } => Self::from_galley(galley), + // Shape::Mesh(mesh) => Self::from_mesh(mesh), + // } + // } + + pub fn from_galley(galley: &Galley) -> Self { + Self::from_slice(galley.text().as_bytes()) + + Self::from_slice(&galley.rows) + + galley.rows.iter().map(Self::from_galley_row).sum() + } + + fn from_galley_row(row: &crate::text::PlacedRow) -> Self { + Self::from_mesh(&row.visuals.mesh) + Self::from_slice(&row.glyphs) + } + + pub fn from_mesh(mesh: &Mesh) -> Self { + Self::from_slice(&mesh.indices) + Self::from_slice(&mesh.vertices) + } + + pub fn from_slice(slice: &[T]) -> Self { + use core::mem::size_of; + let element_size = size_of::(); + Self { + element_size: ElementSize::Homogeneous(element_size), + num_allocs: 1, + num_elements: slice.len(), + num_bytes: core::mem::size_of_val(slice), + } + } + + pub fn num_elements(&self) -> usize { + assert!( + self.element_size != ElementSize::Heterogenous, + "Heterogenous element size" + ); + self.num_elements + } + + pub fn num_allocs(&self) -> usize { + self.num_allocs + } + + pub fn num_bytes(&self) -> usize { + self.num_bytes + } + + pub fn megabytes(&self) -> String { + megabytes(self.num_bytes()) + } + + pub fn format(&self, what: &str) -> String { + if self.num_allocs() == 0 { + format!("{:6} {:16}", 0, what) + } else if self.num_allocs() == 1 { + format!( + "{:6} {:16} {} 1 allocation", + self.num_elements, + what, + self.megabytes() + ) + } else if self.element_size == ElementSize::Heterogenous { + format!( + "{:6} {:16} {} {:3} allocations", + "", + what, + self.megabytes(), + self.num_allocs() + ) + } else { + format!( + "{:6} {:16} {} {:3} allocations", + self.num_elements(), + what, + self.megabytes(), + self.num_allocs() + ) + } + } +} + +/// Collected allocation statistics for shapes and meshes. +#[derive(Clone, Copy, Default)] +pub struct PaintStats { + pub shapes: AllocInfo, + pub shape_text: AllocInfo, + pub shape_path: AllocInfo, + pub shape_mesh: AllocInfo, + pub shape_vec: AllocInfo, + pub num_callbacks: usize, + + pub text_shape_vertices: AllocInfo, + pub text_shape_indices: AllocInfo, + + /// Number of separate clip rectangles + pub clipped_primitives: AllocInfo, + pub vertices: AllocInfo, + pub indices: AllocInfo, +} + +impl PaintStats { + pub fn from_shapes(shapes: &[ClippedShape]) -> Self { + let mut stats = Self::default(); + stats.shape_path.element_size = ElementSize::Heterogenous; // nicer display later + stats.shape_vec.element_size = ElementSize::Heterogenous; // nicer display later + + stats.shapes = AllocInfo::from_slice(shapes); + for ClippedShape { shape, .. } in shapes { + stats.add(shape); + } + stats + } + + fn add(&mut self, shape: &Shape) { + match shape { + Shape::Vec(shapes) => { + // self += PaintStats::from_shapes(&shapes); // TODO(emilk) + self.shapes += AllocInfo::from_slice(shapes); + self.shape_vec += AllocInfo::from_slice(shapes); + for shape in shapes { + self.add(shape); + } + } + Shape::Noop + | Shape::Circle { .. } + | Shape::Ellipse { .. } + | Shape::LineSegment { .. } + | Shape::Rect { .. } + | Shape::CubicBezier(_) + | Shape::QuadraticBezier(_) => {} + Shape::Path(path_shape) => { + self.shape_path += AllocInfo::from_slice(&path_shape.points); + } + Shape::Text(text_shape) => { + self.shape_text += AllocInfo::from_galley(&text_shape.galley); + + for row in &text_shape.galley.rows { + self.text_shape_indices += AllocInfo::from_slice(&row.visuals.mesh.indices); + self.text_shape_vertices += AllocInfo::from_slice(&row.visuals.mesh.vertices); + } + } + Shape::Mesh(mesh) => { + self.shape_mesh += AllocInfo::from_mesh(mesh); + } + Shape::Callback(_) => { + self.num_callbacks += 1; + } + } + } + + pub fn with_clipped_primitives( + mut self, + clipped_primitives: &[crate::ClippedPrimitive], + ) -> Self { + self.clipped_primitives += AllocInfo::from_slice(clipped_primitives); + for clipped_primitive in clipped_primitives { + if let Primitive::Mesh(mesh) = &clipped_primitive.primitive { + self.vertices += AllocInfo::from_slice(&mesh.vertices); + self.indices += AllocInfo::from_slice(&mesh.indices); + } + } + self + } +} + +fn megabytes(size: usize) -> String { + format!("{:.2} MB", size as f64 / 1e6) +} diff --git a/crates/accshift-egui/vendor/epaint/src/stroke.rs b/crates/accshift-egui/vendor/epaint/src/stroke.rs new file mode 100644 index 0000000..1a5b2f0 --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/stroke.rs @@ -0,0 +1,243 @@ +use core::fmt::Debug; +use std::sync::Arc; + +use emath::GuiRounding as _; + +use super::{Color32, ColorMode, Pos2, Rect, emath}; + +/// Describes the width and color of a line. +/// +/// The default stroke is the same as [`Stroke::NONE`]. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct Stroke { + pub width: f32, + pub color: Color32, +} + +impl Stroke { + /// Same as [`Stroke::default`]. + pub const NONE: Self = Self { + width: 0.0, + color: Color32::TRANSPARENT, + }; + + #[inline] + pub fn new(width: f32, color: impl Into) -> Self { + Self { + width, + color: color.into(), + } + } + + /// True if width is zero or color is transparent + #[inline] + pub fn is_empty(&self) -> bool { + self.width <= 0.0 || self.color == Color32::TRANSPARENT + } + + /// For vertical or horizontal lines: + /// round the stroke center to produce a sharp, pixel-aligned line. + pub fn round_center_to_pixel(&self, pixels_per_point: f32, coord: &mut f32) { + // If the stroke is an odd number of pixels wide, + // we want to round the center of it to the center of a pixel. + // + // If however it is an even number of pixels wide, + // we want to round the center to be between two pixels. + // + // We also want to treat strokes that are _almost_ odd as it it was odd, + // to make it symmetric. Same for strokes that are _almost_ even. + // + // For strokes less than a pixel wide we also round to the center, + // because it will rendered as a single row of pixels by the tessellator. + + let pixel_size = 1.0 / pixels_per_point; + + if self.width <= pixel_size || is_nearest_integer_odd(pixels_per_point * self.width) { + *coord = coord.round_to_pixel_center(pixels_per_point); + } else { + *coord = coord.round_to_pixels(pixels_per_point); + } + } + + pub(crate) fn round_rect_to_pixel(&self, pixels_per_point: f32, rect: &mut Rect) { + // We put odd-width strokes in the center of pixels. + // To understand why, see `fn round_center_to_pixel`. + + let pixel_size = 1.0 / pixels_per_point; + + let width = self.width; + if width <= 0.0 { + *rect = rect.round_to_pixels(pixels_per_point); + } else if width <= pixel_size || is_nearest_integer_odd(pixels_per_point * width) { + *rect = rect.round_to_pixel_center(pixels_per_point); + } else { + *rect = rect.round_to_pixels(pixels_per_point); + } + } +} + +impl From<(f32, Color)> for Stroke +where + Color: Into, +{ + #[inline(always)] + fn from((width, color): (f32, Color)) -> Self { + Self::new(width, color) + } +} + +impl core::hash::Hash for Stroke { + #[inline(always)] + fn hash(&self, state: &mut H) { + let Self { width, color } = *self; + emath::OrderedFloat(width).hash(state); + color.hash(state); + } +} + +/// Describes how the stroke of a shape should be painted. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum StrokeKind { + /// The stroke should be painted entirely inside of the shape + Inside, + + /// The stroke should be painted right on the edge of the shape, half inside and half outside. + Middle, + + /// The stroke should be painted entirely outside of the shape + Outside, +} + +/// Describes the width and color of paths. The color can either be solid or provided by a callback. For more information, see [`ColorMode`] +/// +/// The default stroke is the same as [`Stroke::NONE`]. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct PathStroke { + pub width: f32, + pub color: ColorMode, + pub kind: StrokeKind, +} + +impl Default for PathStroke { + #[inline] + fn default() -> Self { + Self::NONE + } +} + +impl PathStroke { + /// Same as [`PathStroke::default`]. + pub const NONE: Self = Self { + width: 0.0, + color: ColorMode::TRANSPARENT, + kind: StrokeKind::Middle, + }; + + #[inline] + pub fn new(width: f32, color: impl Into) -> Self { + Self { + width, + color: ColorMode::Solid(color.into()), + kind: StrokeKind::Middle, + } + } + + /// Create a new `PathStroke` with a UV function + /// + /// The bounding box passed to the callback will have a margin of [`TessellationOptions::feathering_size_in_pixels`](`crate::tessellator::TessellationOptions::feathering_size_in_pixels`) + #[inline] + pub fn new_uv( + width: f32, + callback: impl Fn(Rect, Pos2) -> Color32 + Send + Sync + 'static, + ) -> Self { + Self { + width, + color: ColorMode::UV(Arc::new(callback)), + kind: StrokeKind::Middle, + } + } + + #[inline] + pub fn with_kind(self, kind: StrokeKind) -> Self { + Self { kind, ..self } + } + + /// Set the stroke to be painted right on the edge of the shape, half inside and half outside. + #[inline] + pub fn middle(self) -> Self { + Self { + kind: StrokeKind::Middle, + ..self + } + } + + /// Set the stroke to be painted entirely outside of the shape + #[inline] + pub fn outside(self) -> Self { + Self { + kind: StrokeKind::Outside, + ..self + } + } + + /// Set the stroke to be painted entirely inside of the shape + #[inline] + pub fn inside(self) -> Self { + Self { + kind: StrokeKind::Inside, + ..self + } + } + + /// True if width is zero or color is solid and transparent + #[inline] + pub fn is_empty(&self) -> bool { + self.width <= 0.0 || self.color == ColorMode::TRANSPARENT + } +} + +impl From<(f32, Color)> for PathStroke +where + Color: Into, +{ + #[inline(always)] + fn from((width, color): (f32, Color)) -> Self { + Self::new(width, color) + } +} + +impl From for PathStroke { + fn from(value: Stroke) -> Self { + if value.is_empty() { + // Important, since we use the stroke color when doing feathering of the fill! + Self::NONE + } else { + Self { + width: value.width, + color: ColorMode::Solid(value.color), + kind: StrokeKind::Middle, + } + } + } +} + +/// Returns true if the nearest integer is odd. +fn is_nearest_integer_odd(x: f32) -> bool { + (x * 0.5 + 0.25).fract() > 0.5 +} + +#[test] +fn test_is_nearest_integer_odd() { + assert!(is_nearest_integer_odd(0.6)); + assert!(is_nearest_integer_odd(1.0)); + assert!(is_nearest_integer_odd(1.4)); + assert!(!is_nearest_integer_odd(1.6)); + assert!(!is_nearest_integer_odd(2.0)); + assert!(!is_nearest_integer_odd(2.4)); + assert!(is_nearest_integer_odd(2.6)); + assert!(is_nearest_integer_odd(3.0)); + assert!(is_nearest_integer_odd(3.4)); +} diff --git a/crates/accshift-egui/vendor/epaint/src/tessellator.rs b/crates/accshift-egui/vendor/epaint/src/tessellator.rs new file mode 100644 index 0000000..f9f2b06 --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/tessellator.rs @@ -0,0 +1,2421 @@ +//! Converts graphics primitives into textured triangles. +//! +//! This module converts lines, circles, text and more represented by [`Shape`] +//! into textured triangles represented by [`Mesh`]. + +#![expect(clippy::identity_op)] + +use emath::{ + GuiRounding as _, NumExt as _, Pos2, Rect, Rot2, Vec2, fast_midpoint, pos2, remap, vec2, +}; + +use crate::{ + CircleShape, ClippedPrimitive, ClippedShape, Color32, CornerRadiusF32, CubicBezierShape, + EllipseShape, Mesh, PathShape, Primitive, QuadraticBezierShape, RectShape, Shape, Stroke, + StrokeKind, TextShape, TextureId, Vertex, color::ColorMode, emath, stroke::PathStroke, + texture_atlas::PreparedDisc, +}; + +// ---------------------------------------------------------------------------- + +#[expect(clippy::approx_constant)] +mod precomputed_vertices { + // fn main() { + // let n = 64; + // println!("pub const CIRCLE_{}: [Vec2; {}] = [", n, n+1); + // for i in 0..=n { + // let a = std::f64::consts::TAU * i as f64 / n as f64; + // println!(" vec2({:.06}, {:.06}),", a.cos(), a.sin()); + // } + // println!("];") + // } + + use emath::{Vec2, vec2}; + + pub const CIRCLE_8: [Vec2; 9] = [ + vec2(1.000000, 0.000000), + vec2(0.707107, 0.707107), + vec2(0.000000, 1.000000), + vec2(-0.707107, 0.707107), + vec2(-1.000000, 0.000000), + vec2(-0.707107, -0.707107), + vec2(0.000000, -1.000000), + vec2(0.707107, -0.707107), + vec2(1.000000, 0.000000), + ]; + + pub const CIRCLE_16: [Vec2; 17] = [ + vec2(1.000000, 0.000000), + vec2(0.923880, 0.382683), + vec2(0.707107, 0.707107), + vec2(0.382683, 0.923880), + vec2(0.000000, 1.000000), + vec2(-0.382684, 0.923880), + vec2(-0.707107, 0.707107), + vec2(-0.923880, 0.382683), + vec2(-1.000000, 0.000000), + vec2(-0.923880, -0.382683), + vec2(-0.707107, -0.707107), + vec2(-0.382684, -0.923880), + vec2(0.000000, -1.000000), + vec2(0.382684, -0.923879), + vec2(0.707107, -0.707107), + vec2(0.923880, -0.382683), + vec2(1.000000, 0.000000), + ]; + + pub const CIRCLE_32: [Vec2; 33] = [ + vec2(1.000000, 0.000000), + vec2(0.980785, 0.195090), + vec2(0.923880, 0.382683), + vec2(0.831470, 0.555570), + vec2(0.707107, 0.707107), + vec2(0.555570, 0.831470), + vec2(0.382683, 0.923880), + vec2(0.195090, 0.980785), + vec2(0.000000, 1.000000), + vec2(-0.195090, 0.980785), + vec2(-0.382683, 0.923880), + vec2(-0.555570, 0.831470), + vec2(-0.707107, 0.707107), + vec2(-0.831470, 0.555570), + vec2(-0.923880, 0.382683), + vec2(-0.980785, 0.195090), + vec2(-1.000000, 0.000000), + vec2(-0.980785, -0.195090), + vec2(-0.923880, -0.382683), + vec2(-0.831470, -0.555570), + vec2(-0.707107, -0.707107), + vec2(-0.555570, -0.831470), + vec2(-0.382683, -0.923880), + vec2(-0.195090, -0.980785), + vec2(-0.000000, -1.000000), + vec2(0.195090, -0.980785), + vec2(0.382683, -0.923880), + vec2(0.555570, -0.831470), + vec2(0.707107, -0.707107), + vec2(0.831470, -0.555570), + vec2(0.923880, -0.382683), + vec2(0.980785, -0.195090), + vec2(1.000000, -0.000000), + ]; + + pub const CIRCLE_64: [Vec2; 65] = [ + vec2(1.000000, 0.000000), + vec2(0.995185, 0.098017), + vec2(0.980785, 0.195090), + vec2(0.956940, 0.290285), + vec2(0.923880, 0.382683), + vec2(0.881921, 0.471397), + vec2(0.831470, 0.555570), + vec2(0.773010, 0.634393), + vec2(0.707107, 0.707107), + vec2(0.634393, 0.773010), + vec2(0.555570, 0.831470), + vec2(0.471397, 0.881921), + vec2(0.382683, 0.923880), + vec2(0.290285, 0.956940), + vec2(0.195090, 0.980785), + vec2(0.098017, 0.995185), + vec2(0.000000, 1.000000), + vec2(-0.098017, 0.995185), + vec2(-0.195090, 0.980785), + vec2(-0.290285, 0.956940), + vec2(-0.382683, 0.923880), + vec2(-0.471397, 0.881921), + vec2(-0.555570, 0.831470), + vec2(-0.634393, 0.773010), + vec2(-0.707107, 0.707107), + vec2(-0.773010, 0.634393), + vec2(-0.831470, 0.555570), + vec2(-0.881921, 0.471397), + vec2(-0.923880, 0.382683), + vec2(-0.956940, 0.290285), + vec2(-0.980785, 0.195090), + vec2(-0.995185, 0.098017), + vec2(-1.000000, 0.000000), + vec2(-0.995185, -0.098017), + vec2(-0.980785, -0.195090), + vec2(-0.956940, -0.290285), + vec2(-0.923880, -0.382683), + vec2(-0.881921, -0.471397), + vec2(-0.831470, -0.555570), + vec2(-0.773010, -0.634393), + vec2(-0.707107, -0.707107), + vec2(-0.634393, -0.773010), + vec2(-0.555570, -0.831470), + vec2(-0.471397, -0.881921), + vec2(-0.382683, -0.923880), + vec2(-0.290285, -0.956940), + vec2(-0.195090, -0.980785), + vec2(-0.098017, -0.995185), + vec2(-0.000000, -1.000000), + vec2(0.098017, -0.995185), + vec2(0.195090, -0.980785), + vec2(0.290285, -0.956940), + vec2(0.382683, -0.923880), + vec2(0.471397, -0.881921), + vec2(0.555570, -0.831470), + vec2(0.634393, -0.773010), + vec2(0.707107, -0.707107), + vec2(0.773010, -0.634393), + vec2(0.831470, -0.555570), + vec2(0.881921, -0.471397), + vec2(0.923880, -0.382683), + vec2(0.956940, -0.290285), + vec2(0.980785, -0.195090), + vec2(0.995185, -0.098017), + vec2(1.000000, -0.000000), + ]; + + pub const CIRCLE_128: [Vec2; 129] = [ + vec2(1.000000, 0.000000), + vec2(0.998795, 0.049068), + vec2(0.995185, 0.098017), + vec2(0.989177, 0.146730), + vec2(0.980785, 0.195090), + vec2(0.970031, 0.242980), + vec2(0.956940, 0.290285), + vec2(0.941544, 0.336890), + vec2(0.923880, 0.382683), + vec2(0.903989, 0.427555), + vec2(0.881921, 0.471397), + vec2(0.857729, 0.514103), + vec2(0.831470, 0.555570), + vec2(0.803208, 0.595699), + vec2(0.773010, 0.634393), + vec2(0.740951, 0.671559), + vec2(0.707107, 0.707107), + vec2(0.671559, 0.740951), + vec2(0.634393, 0.773010), + vec2(0.595699, 0.803208), + vec2(0.555570, 0.831470), + vec2(0.514103, 0.857729), + vec2(0.471397, 0.881921), + vec2(0.427555, 0.903989), + vec2(0.382683, 0.923880), + vec2(0.336890, 0.941544), + vec2(0.290285, 0.956940), + vec2(0.242980, 0.970031), + vec2(0.195090, 0.980785), + vec2(0.146730, 0.989177), + vec2(0.098017, 0.995185), + vec2(0.049068, 0.998795), + vec2(0.000000, 1.000000), + vec2(-0.049068, 0.998795), + vec2(-0.098017, 0.995185), + vec2(-0.146730, 0.989177), + vec2(-0.195090, 0.980785), + vec2(-0.242980, 0.970031), + vec2(-0.290285, 0.956940), + vec2(-0.336890, 0.941544), + vec2(-0.382683, 0.923880), + vec2(-0.427555, 0.903989), + vec2(-0.471397, 0.881921), + vec2(-0.514103, 0.857729), + vec2(-0.555570, 0.831470), + vec2(-0.595699, 0.803208), + vec2(-0.634393, 0.773010), + vec2(-0.671559, 0.740951), + vec2(-0.707107, 0.707107), + vec2(-0.740951, 0.671559), + vec2(-0.773010, 0.634393), + vec2(-0.803208, 0.595699), + vec2(-0.831470, 0.555570), + vec2(-0.857729, 0.514103), + vec2(-0.881921, 0.471397), + vec2(-0.903989, 0.427555), + vec2(-0.923880, 0.382683), + vec2(-0.941544, 0.336890), + vec2(-0.956940, 0.290285), + vec2(-0.970031, 0.242980), + vec2(-0.980785, 0.195090), + vec2(-0.989177, 0.146730), + vec2(-0.995185, 0.098017), + vec2(-0.998795, 0.049068), + vec2(-1.000000, 0.000000), + vec2(-0.998795, -0.049068), + vec2(-0.995185, -0.098017), + vec2(-0.989177, -0.146730), + vec2(-0.980785, -0.195090), + vec2(-0.970031, -0.242980), + vec2(-0.956940, -0.290285), + vec2(-0.941544, -0.336890), + vec2(-0.923880, -0.382683), + vec2(-0.903989, -0.427555), + vec2(-0.881921, -0.471397), + vec2(-0.857729, -0.514103), + vec2(-0.831470, -0.555570), + vec2(-0.803208, -0.595699), + vec2(-0.773010, -0.634393), + vec2(-0.740951, -0.671559), + vec2(-0.707107, -0.707107), + vec2(-0.671559, -0.740951), + vec2(-0.634393, -0.773010), + vec2(-0.595699, -0.803208), + vec2(-0.555570, -0.831470), + vec2(-0.514103, -0.857729), + vec2(-0.471397, -0.881921), + vec2(-0.427555, -0.903989), + vec2(-0.382683, -0.923880), + vec2(-0.336890, -0.941544), + vec2(-0.290285, -0.956940), + vec2(-0.242980, -0.970031), + vec2(-0.195090, -0.980785), + vec2(-0.146730, -0.989177), + vec2(-0.098017, -0.995185), + vec2(-0.049068, -0.998795), + vec2(-0.000000, -1.000000), + vec2(0.049068, -0.998795), + vec2(0.098017, -0.995185), + vec2(0.146730, -0.989177), + vec2(0.195090, -0.980785), + vec2(0.242980, -0.970031), + vec2(0.290285, -0.956940), + vec2(0.336890, -0.941544), + vec2(0.382683, -0.923880), + vec2(0.427555, -0.903989), + vec2(0.471397, -0.881921), + vec2(0.514103, -0.857729), + vec2(0.555570, -0.831470), + vec2(0.595699, -0.803208), + vec2(0.634393, -0.773010), + vec2(0.671559, -0.740951), + vec2(0.707107, -0.707107), + vec2(0.740951, -0.671559), + vec2(0.773010, -0.634393), + vec2(0.803208, -0.595699), + vec2(0.831470, -0.555570), + vec2(0.857729, -0.514103), + vec2(0.881921, -0.471397), + vec2(0.903989, -0.427555), + vec2(0.923880, -0.382683), + vec2(0.941544, -0.336890), + vec2(0.956940, -0.290285), + vec2(0.970031, -0.242980), + vec2(0.980785, -0.195090), + vec2(0.989177, -0.146730), + vec2(0.995185, -0.098017), + vec2(0.998795, -0.049068), + vec2(1.000000, -0.000000), + ]; +} + +// ---------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct PathPoint { + pos: Pos2, + + /// For filled paths the normal is used for anti-aliasing (both strokes and filled areas). + /// + /// For strokes the normal is also used for giving thickness to the path + /// (i.e. in what direction to expand). + /// + /// The normal could be estimated by differences between successive points, + /// but that would be less accurate (and in some cases slower). + /// + /// Normals are normally unit-length. + normal: Vec2, +} + +/// A connected line (without thickness or gaps) which can be tessellated +/// to either to a stroke (with thickness) or a filled convex area. +/// Used as a scratch-pad during tessellation. +#[derive(Clone, Debug, Default)] +pub struct Path(Vec); + +impl Path { + #[inline(always)] + pub fn clear(&mut self) { + self.0.clear(); + } + + #[inline(always)] + pub fn reserve(&mut self, additional: usize) { + self.0.reserve(additional); + } + + #[inline(always)] + pub fn add_point(&mut self, pos: Pos2, normal: Vec2) { + self.0.push(PathPoint { pos, normal }); + } + + pub fn add_circle(&mut self, center: Pos2, radius: f32) { + use precomputed_vertices::{CIRCLE_8, CIRCLE_16, CIRCLE_32, CIRCLE_64, CIRCLE_128}; + + // These cutoffs are based on a high-dpi display. TODO(emilk): use pixels_per_point here? + // same cutoffs as in add_circle_quadrant + + if radius <= 2.0 { + self.0.extend(CIRCLE_8.iter().map(|&n| PathPoint { + pos: center + radius * n, + normal: n, + })); + } else if radius <= 5.0 { + self.0.extend(CIRCLE_16.iter().map(|&n| PathPoint { + pos: center + radius * n, + normal: n, + })); + } else if radius < 18.0 { + self.0.extend(CIRCLE_32.iter().map(|&n| PathPoint { + pos: center + radius * n, + normal: n, + })); + } else if radius < 50.0 { + self.0.extend(CIRCLE_64.iter().map(|&n| PathPoint { + pos: center + radius * n, + normal: n, + })); + } else { + self.0.extend(CIRCLE_128.iter().map(|&n| PathPoint { + pos: center + radius * n, + normal: n, + })); + } + } + + pub fn add_line_segment(&mut self, points: [Pos2; 2]) { + self.reserve(2); + let normal = (points[1] - points[0]).normalized().rot90(); + self.add_point(points[0], normal); + self.add_point(points[1], normal); + } + + pub fn add_open_points(&mut self, points: &[Pos2]) { + let n = points.len(); + assert!(n >= 2, "A path needs at least two points, but got {n}"); + + if n == 2 { + // Common case optimization: + self.add_line_segment([points[0], points[1]]); + } else { + self.reserve(n); + self.add_point(points[0], (points[1] - points[0]).normalized().rot90()); + let mut n0 = (points[1] - points[0]).normalized().rot90(); + for i in 1..n - 1 { + let mut n1 = (points[i + 1] - points[i]).normalized().rot90(); + + // Handle duplicated points (but not triplicated…): + if n0 == Vec2::ZERO { + n0 = n1; + } else if n1 == Vec2::ZERO { + n1 = n0; + } + + let normal = (n0 + n1) / 2.0; + let length_sq = normal.length_sq(); + let right_angle_length_sq = 0.5; + let sharper_than_a_right_angle = length_sq < right_angle_length_sq; + if sharper_than_a_right_angle { + // cut off the sharp corner + let center_normal = normal.normalized(); + let n0c = (n0 + center_normal) / 2.0; + let n1c = (n1 + center_normal) / 2.0; + self.add_point(points[i], n0c / n0c.length_sq()); + self.add_point(points[i], n1c / n1c.length_sq()); + } else { + // miter join + self.add_point(points[i], normal / length_sq); + } + + n0 = n1; + } + self.add_point( + points[n - 1], + (points[n - 1] - points[n - 2]).normalized().rot90(), + ); + } + } + + pub fn add_line_loop(&mut self, points: &[Pos2]) { + let n = points.len(); + assert!(n >= 2, "A path needs at least two points, but got {n}"); + self.reserve(n); + + let mut n0 = (points[0] - points[n - 1]).normalized().rot90(); + + for i in 0..n { + let next_i = if i + 1 == n { 0 } else { i + 1 }; + let mut n1 = (points[next_i] - points[i]).normalized().rot90(); + + // Handle duplicated points (but not triplicated…): + if n0 == Vec2::ZERO { + n0 = n1; + } else if n1 == Vec2::ZERO { + n1 = n0; + } + + let normal = (n0 + n1) / 2.0; + let length_sq = normal.length_sq(); + + // We can't just cut off corners for filled shapes like this, + // because the feather will both expand and contract the corner along the provided normals + // to make sure it doesn't grow, and the shrinking will make the inner points cross each other. + // + // A better approach is to shrink the vertices in by half the feather-width here + // and then only expand during feathering. + // + // See https://github.com/emilk/egui/issues/1226 + const CUT_OFF_SHARP_CORNERS: bool = false; + + let right_angle_length_sq = 0.5; + let sharper_than_a_right_angle = length_sq < right_angle_length_sq; + if CUT_OFF_SHARP_CORNERS && sharper_than_a_right_angle { + // cut off the sharp corner + let center_normal = normal.normalized(); + let n0c = (n0 + center_normal) / 2.0; + let n1c = (n1 + center_normal) / 2.0; + self.add_point(points[i], n0c / n0c.length_sq()); + self.add_point(points[i], n1c / n1c.length_sq()); + } else { + // miter join + self.add_point(points[i], normal / length_sq); + } + + n0 = n1; + } + } + + /// The path is taken to be closed (i.e. returning to the start again). + /// + /// Calling this may reverse the vertices in the path if they are wrong winding order. + /// The preferred winding order is clockwise. + pub fn fill_and_stroke( + &mut self, + feathering: f32, + fill: Color32, + stroke: &PathStroke, + out: &mut Mesh, + ) { + stroke_and_fill_path(feathering, &mut self.0, PathType::Closed, stroke, fill, out); + } + + /// Open-ended. + pub fn stroke_open(&mut self, feathering: f32, stroke: &PathStroke, out: &mut Mesh) { + stroke_path(feathering, &mut self.0, PathType::Open, stroke, out); + } + + /// A closed path (returning to the first point). + pub fn stroke_closed(&mut self, feathering: f32, stroke: &PathStroke, out: &mut Mesh) { + stroke_path(feathering, &mut self.0, PathType::Closed, stroke, out); + } + + pub fn stroke( + &mut self, + feathering: f32, + path_type: PathType, + stroke: &PathStroke, + out: &mut Mesh, + ) { + stroke_path(feathering, &mut self.0, path_type, stroke, out); + } + + /// The path is taken to be closed (i.e. returning to the start again). + /// + /// Calling this may reverse the vertices in the path if they are wrong winding order. + /// The preferred winding order is clockwise. + pub fn fill(&mut self, feathering: f32, color: Color32, out: &mut Mesh) { + fill_closed_path(feathering, &mut self.0, color, out); + } + + /// Like [`Self::fill`] but with texturing. + /// + /// The `uv_from_pos` is called for each vertex position. + pub fn fill_with_uv( + &mut self, + feathering: f32, + color: Color32, + texture_id: TextureId, + uv_from_pos: impl Fn(Pos2) -> Pos2, + out: &mut Mesh, + ) { + fill_closed_path_with_uv(feathering, &mut self.0, color, texture_id, uv_from_pos, out); + } +} + +pub mod path { + //! Helpers for constructing paths + use crate::CornerRadiusF32; + use emath::{Pos2, Rect, pos2}; + + /// overwrites existing points + pub fn rounded_rectangle(path: &mut Vec, rect: Rect, cr: CornerRadiusF32) { + path.clear(); + + let min = rect.min; + let max = rect.max; + + let cr = clamp_corner_radius(cr, rect); + + if cr == CornerRadiusF32::ZERO { + path.reserve(4); + path.push(pos2(min.x, min.y)); // left top + path.push(pos2(max.x, min.y)); // right top + path.push(pos2(max.x, max.y)); // right bottom + path.push(pos2(min.x, max.y)); // left bottom + } else { + // We need to avoid duplicated vertices, because that leads to visual artifacts later. + // Duplicated vertices can happen when one side is all rounding, with no straight edge between. + let eps = f32::EPSILON * rect.size().max_elem(); + + add_circle_quadrant(path, pos2(max.x - cr.se, max.y - cr.se), cr.se, 0.0); // south east + + if rect.width() <= cr.se + cr.sw + eps { + path.pop(); // avoid duplicated vertex + } + + add_circle_quadrant(path, pos2(min.x + cr.sw, max.y - cr.sw), cr.sw, 1.0); // south west + + if rect.height() <= cr.sw + cr.nw + eps { + path.pop(); // avoid duplicated vertex + } + + add_circle_quadrant(path, pos2(min.x + cr.nw, min.y + cr.nw), cr.nw, 2.0); // north west + + if rect.width() <= cr.nw + cr.ne + eps { + path.pop(); // avoid duplicated vertex + } + + add_circle_quadrant(path, pos2(max.x - cr.ne, min.y + cr.ne), cr.ne, 3.0); // north east + + if rect.height() <= cr.ne + cr.se + eps { + path.pop(); // avoid duplicated vertex + } + } + } + + /// Add one quadrant of a circle + /// + /// * quadrant 0: right bottom + /// * quadrant 1: left bottom + /// * quadrant 2: left top + /// * quadrant 3: right top + // + // Derivation: + // + // * angle 0 * TAU / 4 = right + // - quadrant 0: right bottom + // * angle 1 * TAU / 4 = bottom + // - quadrant 1: left bottom + // * angle 2 * TAU / 4 = left + // - quadrant 2: left top + // * angle 3 * TAU / 4 = top + // - quadrant 3: right top + // * angle 4 * TAU / 4 = right + pub fn add_circle_quadrant(path: &mut Vec, center: Pos2, radius: f32, quadrant: f32) { + use super::precomputed_vertices::{CIRCLE_8, CIRCLE_16, CIRCLE_32, CIRCLE_64, CIRCLE_128}; + + // These cutoffs are based on a high-dpi display. TODO(emilk): use pixels_per_point here? + // same cutoffs as in add_circle + + if radius <= 0.0 { + path.push(center); + } else if radius <= 2.0 { + let offset = quadrant as usize * 2; + let quadrant_vertices = &CIRCLE_8[offset..=offset + 2]; + path.extend(quadrant_vertices.iter().map(|&n| center + radius * n)); + } else if radius <= 5.0 { + let offset = quadrant as usize * 4; + let quadrant_vertices = &CIRCLE_16[offset..=offset + 4]; + path.extend(quadrant_vertices.iter().map(|&n| center + radius * n)); + } else if radius < 18.0 { + let offset = quadrant as usize * 8; + let quadrant_vertices = &CIRCLE_32[offset..=offset + 8]; + path.extend(quadrant_vertices.iter().map(|&n| center + radius * n)); + } else if radius < 50.0 { + let offset = quadrant as usize * 16; + let quadrant_vertices = &CIRCLE_64[offset..=offset + 16]; + path.extend(quadrant_vertices.iter().map(|&n| center + radius * n)); + } else { + let offset = quadrant as usize * 32; + let quadrant_vertices = &CIRCLE_128[offset..=offset + 32]; + path.extend(quadrant_vertices.iter().map(|&n| center + radius * n)); + } + } + + // Ensures the radius of each corner is within a valid range + fn clamp_corner_radius(cr: CornerRadiusF32, rect: Rect) -> CornerRadiusF32 { + let half_width = rect.width() * 0.5; + let half_height = rect.height() * 0.5; + let max_cr = half_width.min(half_height); + cr.at_most(max_cr).at_least(0.0) + } +} + +// ---------------------------------------------------------------------------- + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum PathType { + Open, + Closed, +} + +/// Tessellation quality options +#[derive(Clone, Copy, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "serde", serde(default))] +pub struct TessellationOptions { + /// Use "feathering" to smooth out the edges of shapes as a form of anti-aliasing. + /// + /// Feathering works by making each edge into a thin gradient into transparency. + /// The size of this edge is controlled by [`Self::feathering_size_in_pixels`]. + /// + /// This makes shapes appear smoother, but requires more triangles and is therefore slower. + /// + /// This setting does not affect text. + /// + /// Default: `true`. + pub feathering: bool, + + /// The size of the feathering, in physical pixels. + /// + /// The default, and suggested, value for this is `1.0`. + /// If you use a larger value, edges will appear blurry. + pub feathering_size_in_pixels: f32, + + /// If `true` (default) cull certain primitives before tessellating them. + /// This likely makes + pub coarse_tessellation_culling: bool, + + /// If `true`, small filled circled will be optimized by using pre-rasterized circled + /// from the font atlas. + pub prerasterized_discs: bool, + + /// If `true` (default) align text to the physical pixel grid. + /// This makes the text sharper on most platforms. + pub round_text_to_pixels: bool, + + /// If `true` (default), align right-angled line segments to the physical pixel grid. + /// + /// This makes the line segments appear crisp on any display. + pub round_line_segments_to_pixels: bool, + + /// If `true` (default), align rectangles to the physical pixel grid. + /// + /// This makes the rectangle strokes more crisp, + /// and makes filled rectangles tile perfectly (without feathering). + /// + /// You can override this with [`crate::RectShape::round_to_pixels`]. + pub round_rects_to_pixels: bool, + + /// Output the clip rectangles to be painted. + pub debug_paint_clip_rects: bool, + + /// Output the text-containing rectangles. + pub debug_paint_text_rects: bool, + + /// If true, no clipping will be done. + pub debug_ignore_clip_rects: bool, + + /// The maximum distance between the original curve and the flattened curve. + pub bezier_tolerance: f32, + + /// The default value will be 1.0e-5, it will be used during float compare. + pub epsilon: f32, + + /// If `rayon` feature is activated, should we parallelize tessellation? + pub parallel_tessellation: bool, + + /// If `true`, invalid meshes will be silently ignored. + /// If `false`, invalid meshes will cause a panic. + /// + /// The default is `false` to save performance. + pub validate_meshes: bool, +} + +impl Default for TessellationOptions { + fn default() -> Self { + Self { + feathering: true, + feathering_size_in_pixels: 1.0, + coarse_tessellation_culling: true, + prerasterized_discs: true, + round_text_to_pixels: true, + round_line_segments_to_pixels: true, + round_rects_to_pixels: true, + debug_paint_text_rects: false, + debug_paint_clip_rects: false, + debug_ignore_clip_rects: false, + bezier_tolerance: 0.1, + epsilon: 1.0e-5, + parallel_tessellation: true, + validate_meshes: false, + } + } +} + +fn cw_signed_area(path: &[PathPoint]) -> f64 { + if let Some(last) = path.last() { + let mut previous = last.pos; + let mut area = 0.0; + for p in path { + area += (previous.x * p.pos.y - p.pos.x * previous.y) as f64; + previous = p.pos; + } + area + } else { + 0.0 + } +} + +/// Tessellate the given convex area into a polygon. +/// +/// Calling this may reverse the vertices in the path if they are wrong winding order. +/// +/// The preferred winding order is clockwise. +fn fill_closed_path(feathering: f32, path: &mut [PathPoint], fill_color: Color32, out: &mut Mesh) { + if fill_color == Color32::TRANSPARENT { + return; + } + + let n = path.len() as u32; + if n < 3 { + return; + } + + if 0.0 < feathering { + if cw_signed_area(path) < 0.0 { + // Wrong winding order - fix: + path.reverse(); + for point in &mut *path { + point.normal = -point.normal; + } + } + + out.reserve_triangles(3 * n as usize); + out.reserve_vertices(2 * n as usize); + let idx_inner = out.vertices.len() as u32; + let idx_outer = idx_inner + 1; + + // The fill: + for i in 2..n { + out.add_triangle(idx_inner + 2 * (i - 1), idx_inner, idx_inner + 2 * i); + } + + // The feathering: + let mut i0 = n - 1; + for i1 in 0..n { + let p1 = &path[i1 as usize]; + let dm = 0.5 * feathering * p1.normal; + + let pos_inner = p1.pos - dm; + let pos_outer = p1.pos + dm; + + out.colored_vertex(pos_inner, fill_color); + out.colored_vertex(pos_outer, Color32::TRANSPARENT); + out.add_triangle(idx_inner + i1 * 2, idx_inner + i0 * 2, idx_outer + 2 * i0); + out.add_triangle(idx_outer + i0 * 2, idx_outer + i1 * 2, idx_inner + 2 * i1); + i0 = i1; + } + } else { + out.reserve_triangles(n as usize); + let idx = out.vertices.len() as u32; + out.vertices + .extend(path.iter().map(|p| Vertex::untextured(p.pos, fill_color))); + for i in 2..n { + out.add_triangle(idx, idx + i - 1, idx + i); + } + } +} + +/// Like [`fill_closed_path`] but with texturing. +/// +/// The `uv_from_pos` is called for each vertex position. +fn fill_closed_path_with_uv( + feathering: f32, + path: &mut [PathPoint], + color: Color32, + texture_id: TextureId, + uv_from_pos: impl Fn(Pos2) -> Pos2, + out: &mut Mesh, +) { + if color == Color32::TRANSPARENT { + return; + } + + if out.is_empty() { + out.texture_id = texture_id; + } else { + assert_eq!( + out.texture_id, texture_id, + "Mixing different `texture_id` in the same " + ); + } + + let n = path.len() as u32; + if 0.0 < feathering { + if cw_signed_area(path) < 0.0 { + // Wrong winding order - fix: + path.reverse(); + for point in &mut *path { + point.normal = -point.normal; + } + } + + out.reserve_triangles(3 * n as usize); + out.reserve_vertices(2 * n as usize); + let color_outer = Color32::TRANSPARENT; + let idx_inner = out.vertices.len() as u32; + let idx_outer = idx_inner + 1; + + // The fill: + for i in 2..n { + out.add_triangle(idx_inner + 2 * (i - 1), idx_inner, idx_inner + 2 * i); + } + + // The feathering: + let mut i0 = n - 1; + for i1 in 0..n { + let p1 = &path[i1 as usize]; + let dm = 0.5 * feathering * p1.normal; + + let pos = p1.pos - dm; + out.vertices.push(Vertex { + pos, + uv: uv_from_pos(pos), + color, + }); + + let pos = p1.pos + dm; + out.vertices.push(Vertex { + pos, + uv: uv_from_pos(pos), + color: color_outer, + }); + + out.add_triangle(idx_inner + i1 * 2, idx_inner + i0 * 2, idx_outer + 2 * i0); + out.add_triangle(idx_outer + i0 * 2, idx_outer + i1 * 2, idx_inner + 2 * i1); + i0 = i1; + } + } else { + out.reserve_triangles(n as usize); + let idx = out.vertices.len() as u32; + out.vertices.extend(path.iter().map(|p| Vertex { + pos: p.pos, + uv: uv_from_pos(p.pos), + color, + })); + for i in 2..n { + out.add_triangle(idx, idx + i - 1, idx + i); + } + } +} + +/// Tessellate the given path as a stroke with thickness. +fn stroke_path( + feathering: f32, + path: &mut [PathPoint], + path_type: PathType, + stroke: &PathStroke, + out: &mut Mesh, +) { + let fill = Color32::TRANSPARENT; + stroke_and_fill_path(feathering, path, path_type, stroke, fill, out); +} + +/// Tessellate the given path as a stroke with thickness, with optional fill color. +/// +/// Calling this may reverse the vertices in the path if they are wrong winding order. +/// +/// The preferred winding order is clockwise. +fn stroke_and_fill_path( + feathering: f32, + path: &mut [PathPoint], + path_type: PathType, + stroke: &PathStroke, + color_fill: Color32, + out: &mut Mesh, +) { + let n = path.len() as u32; + + if n < 2 { + return; + } + + if stroke.width == 0.0 { + // Skip the stroke, just fill. + return fill_closed_path(feathering, path, color_fill, out); + } + + if color_fill != Color32::TRANSPARENT && cw_signed_area(path) < 0.0 { + // Wrong winding order - fix: + path.reverse(); + for point in &mut *path { + point.normal = -point.normal; + } + } + + if stroke.color == ColorMode::TRANSPARENT { + // Skip the stroke, just fill. But subtract the width from the path: + match stroke.kind { + StrokeKind::Inside => { + for point in &mut *path { + point.pos -= stroke.width * point.normal; + } + } + StrokeKind::Middle => { + for point in &mut *path { + point.pos -= 0.5 * stroke.width * point.normal; + } + } + StrokeKind::Outside => {} + } + + // Skip the stroke, just fill. + return fill_closed_path(feathering, path, color_fill, out); + } + + let idx = out.vertices.len() as u32; + + // Move the points so that the stroke is on middle of the path. + match stroke.kind { + StrokeKind::Inside => { + for point in &mut *path { + point.pos -= 0.5 * stroke.width * point.normal; + } + } + StrokeKind::Middle => { + // correct + } + StrokeKind::Outside => { + for point in &mut *path { + point.pos += 0.5 * stroke.width * point.normal; + } + } + } + + // Expand the bounding box to include the thickness of the path + let uv_bbox = if matches!(stroke.color, ColorMode::UV(_)) { + Rect::from_points(&path.iter().map(|p| p.pos).collect::>()) + .expand((stroke.width / 2.0) + feathering) + } else { + Rect::NAN + }; + let get_color = |col: &ColorMode, pos: Pos2| match col { + ColorMode::Solid(col) => *col, + ColorMode::UV(fun) => fun(uv_bbox, pos), + }; + + if 0.0 < feathering { + let color_outer = Color32::TRANSPARENT; + let color_middle = &stroke.color; + + // We add a bit of an epsilon here, because when we round to pixels, + // we can get rounding errors (unless pixels_per_point is an integer). + // And it's better to err on the side of the nicer rendering with line caps + // (the thin-line optimization has no line caps). + let thin_line = stroke.width <= 0.9 * feathering; + if thin_line { + // If the stroke is painted smaller than the pixel width (=feathering width), + // then we risk severe aliasing. + // Instead, we paint the stroke as a triangular ridge, two feather-widths wide, + // and lessen the opacity of the middle part instead of making it thinner. + if color_fill != Color32::TRANSPARENT && stroke.width < feathering { + // If this is filled shape, then we need to also compensate so that the + // filled area remains the same as it would have been without the + // artificially wide line. + for point in &mut *path { + point.pos += 0.5 * (feathering - stroke.width) * point.normal; + } + } + + // TODO(emilk): add line caps (if this is an open line). + + let opacity = stroke.width / feathering; + + /* + We paint the line using three edges: outer, middle, fill. + + . o m i outer, middle, fill + . |---| feathering (pixel width) + */ + + out.reserve_triangles(4 * n as usize); + out.reserve_vertices(3 * n as usize); + + let mut i0 = n - 1; + for i1 in 0..n { + let connect_with_previous = path_type == PathType::Closed || i1 > 0; + let p1 = path[i1 as usize]; + let p = p1.pos; + let n = p1.normal; + out.colored_vertex(p + n * feathering, color_outer); + out.colored_vertex(p, mul_color(get_color(color_middle, p), opacity)); + out.colored_vertex(p - n * feathering, color_fill); + + if connect_with_previous { + out.add_triangle(idx + 3 * i0 + 0, idx + 3 * i0 + 1, idx + 3 * i1 + 0); + out.add_triangle(idx + 3 * i0 + 1, idx + 3 * i1 + 0, idx + 3 * i1 + 1); + + out.add_triangle(idx + 3 * i0 + 1, idx + 3 * i0 + 2, idx + 3 * i1 + 1); + out.add_triangle(idx + 3 * i0 + 2, idx + 3 * i1 + 1, idx + 3 * i1 + 2); + } + + i0 = i1; + } + + if color_fill != Color32::TRANSPARENT { + out.reserve_triangles(n as usize - 2); + let idx_fill = idx + 2; + for i in 2..n { + out.add_triangle(idx_fill + 3 * (i - 1), idx_fill, idx_fill + 3 * i); + } + } + } else { + // thick anti-aliased line + + /* + We paint the line using four edges: outer, middle, middle, fill + + . o m p m f outer, middle, point, middle, fill + . |---| feathering (pixel width) + . |--------------| width + . |---------| outer_rad + . |-----| inner_rad + */ + + let inner_rad = 0.5 * (stroke.width - feathering); + let outer_rad = fast_midpoint(stroke.width, feathering); + + match path_type { + PathType::Closed => { + out.reserve_triangles(6 * n as usize); + out.reserve_vertices(4 * n as usize); + + let mut i0 = n - 1; + for i1 in 0..n { + let p1 = path[i1 as usize]; + let p = p1.pos; + let n = p1.normal; + out.colored_vertex(p + n * outer_rad, color_outer); + out.colored_vertex( + p + n * inner_rad, + get_color(color_middle, p + n * inner_rad), + ); + out.colored_vertex( + p - n * inner_rad, + get_color(color_middle, p - n * inner_rad), + ); + out.colored_vertex(p - n * outer_rad, color_fill); + + out.add_triangle(idx + 4 * i0 + 0, idx + 4 * i0 + 1, idx + 4 * i1 + 0); + out.add_triangle(idx + 4 * i0 + 1, idx + 4 * i1 + 0, idx + 4 * i1 + 1); + + out.add_triangle(idx + 4 * i0 + 1, idx + 4 * i0 + 2, idx + 4 * i1 + 1); + out.add_triangle(idx + 4 * i0 + 2, idx + 4 * i1 + 1, idx + 4 * i1 + 2); + + out.add_triangle(idx + 4 * i0 + 2, idx + 4 * i0 + 3, idx + 4 * i1 + 2); + out.add_triangle(idx + 4 * i0 + 3, idx + 4 * i1 + 2, idx + 4 * i1 + 3); + + i0 = i1; + } + + if color_fill != Color32::TRANSPARENT { + out.reserve_triangles(n as usize - 2); + let idx_fill = idx + 3; + for i in 2..n { + out.add_triangle(idx_fill + 4 * (i - 1), idx_fill, idx_fill + 4 * i); + } + } + } + PathType::Open => { + // Anti-alias the ends by extruding the outer edge and adding + // two more triangles to each end: + + // | aa | | aa | + // _________________ ___ + // | \ added / | feathering + // | \ ___p___ / | ___ + // | | | | + // | | opa | | + // | | que | | + // | | | | + + // (in the future it would be great with an option to add a circular end instead) + + // TODO(emilk): we should probably shrink before adding the line caps, + // so that we don't add to the area of the line. + // TODO(emilk): make line caps optional. + + out.reserve_triangles(6 * n as usize + 4); + out.reserve_vertices(4 * n as usize); + + { + let end = path[0]; + let p = end.pos; + let n = end.normal; + let back_extrude = n.rot90() * feathering; + out.colored_vertex(p + n * outer_rad + back_extrude, color_outer); + out.colored_vertex( + p + n * inner_rad, + get_color(color_middle, p + n * inner_rad), + ); + out.colored_vertex( + p - n * inner_rad, + get_color(color_middle, p - n * inner_rad), + ); + out.colored_vertex(p - n * outer_rad + back_extrude, color_outer); + + out.add_triangle(idx + 0, idx + 1, idx + 2); + out.add_triangle(idx + 0, idx + 2, idx + 3); + } + + let mut i0 = 0; + for i1 in 1..n - 1 { + let point = path[i1 as usize]; + let p = point.pos; + let n = point.normal; + out.colored_vertex(p + n * outer_rad, color_outer); + out.colored_vertex( + p + n * inner_rad, + get_color(color_middle, p + n * inner_rad), + ); + out.colored_vertex( + p - n * inner_rad, + get_color(color_middle, p - n * inner_rad), + ); + out.colored_vertex(p - n * outer_rad, color_outer); + + out.add_triangle(idx + 4 * i0 + 0, idx + 4 * i0 + 1, idx + 4 * i1 + 0); + out.add_triangle(idx + 4 * i0 + 1, idx + 4 * i1 + 0, idx + 4 * i1 + 1); + + out.add_triangle(idx + 4 * i0 + 1, idx + 4 * i0 + 2, idx + 4 * i1 + 1); + out.add_triangle(idx + 4 * i0 + 2, idx + 4 * i1 + 1, idx + 4 * i1 + 2); + + out.add_triangle(idx + 4 * i0 + 2, idx + 4 * i0 + 3, idx + 4 * i1 + 2); + out.add_triangle(idx + 4 * i0 + 3, idx + 4 * i1 + 2, idx + 4 * i1 + 3); + + i0 = i1; + } + + { + let i1 = n - 1; + let end = path[i1 as usize]; + let p = end.pos; + let n = end.normal; + let back_extrude = -n.rot90() * feathering; + out.colored_vertex(p + n * outer_rad + back_extrude, color_outer); + out.colored_vertex( + p + n * inner_rad, + get_color(color_middle, p + n * inner_rad), + ); + out.colored_vertex( + p - n * inner_rad, + get_color(color_middle, p - n * inner_rad), + ); + out.colored_vertex(p - n * outer_rad + back_extrude, color_outer); + + out.add_triangle(idx + 4 * i0 + 0, idx + 4 * i0 + 1, idx + 4 * i1 + 0); + out.add_triangle(idx + 4 * i0 + 1, idx + 4 * i1 + 0, idx + 4 * i1 + 1); + + out.add_triangle(idx + 4 * i0 + 1, idx + 4 * i0 + 2, idx + 4 * i1 + 1); + out.add_triangle(idx + 4 * i0 + 2, idx + 4 * i1 + 1, idx + 4 * i1 + 2); + + out.add_triangle(idx + 4 * i0 + 2, idx + 4 * i0 + 3, idx + 4 * i1 + 2); + out.add_triangle(idx + 4 * i0 + 3, idx + 4 * i1 + 2, idx + 4 * i1 + 3); + + // The extension: + out.add_triangle(idx + 4 * i1 + 0, idx + 4 * i1 + 1, idx + 4 * i1 + 2); + out.add_triangle(idx + 4 * i1 + 0, idx + 4 * i1 + 2, idx + 4 * i1 + 3); + } + } + } + } + } else { + // not anti-aliased: + out.reserve_triangles(2 * n as usize); + out.reserve_vertices(2 * n as usize); + + let last_index = if path_type == PathType::Closed { + n + } else { + n - 1 + }; + for i in 0..last_index { + out.add_triangle( + idx + (2 * i + 0) % (2 * n), + idx + (2 * i + 1) % (2 * n), + idx + (2 * i + 2) % (2 * n), + ); + out.add_triangle( + idx + (2 * i + 2) % (2 * n), + idx + (2 * i + 1) % (2 * n), + idx + (2 * i + 3) % (2 * n), + ); + } + + let thin_line = stroke.width <= feathering; + if thin_line { + // Fade out thin lines rather than making them thinner + let opacity = stroke.width / feathering; + let radius = feathering / 2.0; + for p in path.iter_mut() { + out.colored_vertex( + p.pos + radius * p.normal, + mul_color(get_color(&stroke.color, p.pos + radius * p.normal), opacity), + ); + out.colored_vertex( + p.pos - radius * p.normal, + mul_color(get_color(&stroke.color, p.pos - radius * p.normal), opacity), + ); + } + } else { + let radius = stroke.width / 2.0; + for p in path.iter_mut() { + out.colored_vertex( + p.pos + radius * p.normal, + get_color(&stroke.color, p.pos + radius * p.normal), + ); + out.colored_vertex( + p.pos - radius * p.normal, + get_color(&stroke.color, p.pos - radius * p.normal), + ); + } + } + + if color_fill != Color32::TRANSPARENT { + // We Need to create new vertices, because the ones we used for the stroke + // has the wrong color. + + // Shrink to ignore the stroke… + for point in &mut *path { + point.pos -= 0.5 * stroke.width * point.normal; + } + // …then fill: + fill_closed_path(feathering, path, color_fill, out); + } + } +} + +fn mul_color(color: Color32, factor: f32) -> Color32 { + // The fast gamma-space multiply also happens to be perceptually better. + // Win-win! + color.gamma_multiply(factor) +} + +// ---------------------------------------------------------------------------- + +/// Converts [`Shape`]s into triangles ([`Mesh`]). +/// +/// For performance reasons it is smart to reuse the same [`Tessellator`]. +#[derive(Clone)] +pub struct Tessellator { + pixels_per_point: f32, + options: TessellationOptions, + font_tex_size: [usize; 2], + + /// See [`crate::TextureAtlas::prepared_discs`]. + prepared_discs: Vec, + + /// size of feathering in points. normally the size of a physical pixel. 0.0 if disabled + feathering: f32, + + /// Only used for culling + clip_rect: Rect, + + scratchpad_points: Vec, + scratchpad_path: Path, +} + +impl Tessellator { + /// Create a new [`Tessellator`]. + /// + /// * `pixels_per_point`: number of physical pixels to each logical point + /// * `options`: tessellation quality + /// * `shapes`: what to tessellate + /// * `font_tex_size`: size of the font texture. Required to normalize glyph uv rectangles when tessellating text. + /// * `prepared_discs`: What [`crate::TextureAtlas::prepared_discs`] returns. Can safely be set to an empty vec. + pub fn new( + pixels_per_point: f32, + options: TessellationOptions, + font_tex_size: [usize; 2], + prepared_discs: Vec, + ) -> Self { + let feathering = if options.feathering { + let pixel_size = 1.0 / pixels_per_point; + options.feathering_size_in_pixels * pixel_size + } else { + 0.0 + }; + Self { + pixels_per_point, + options, + font_tex_size, + prepared_discs, + feathering, + clip_rect: Rect::EVERYTHING, + scratchpad_points: Default::default(), + scratchpad_path: Default::default(), + } + } + + /// Set the [`Rect`] to use for culling. + pub fn set_clip_rect(&mut self, clip_rect: Rect) { + self.clip_rect = clip_rect; + } + + /// Tessellate a clipped shape into a list of primitives. + pub fn tessellate_clipped_shape( + &mut self, + clipped_shape: ClippedShape, + out_primitives: &mut Vec, + ) { + let ClippedShape { clip_rect, shape } = clipped_shape; + + if !clip_rect.is_positive() { + return; // skip empty clip rectangles + } + + if let Shape::Vec(shapes) = shape { + for shape in shapes { + self.tessellate_clipped_shape(ClippedShape { clip_rect, shape }, out_primitives); + } + return; + } + + if let Shape::Callback(callback) = shape { + out_primitives.push(ClippedPrimitive { + clip_rect, + primitive: Primitive::Callback(callback), + }); + return; + } + + let start_new_mesh = match out_primitives.last() { + None => true, + Some(output_clipped_primitive) => { + output_clipped_primitive.clip_rect != clip_rect + || match &output_clipped_primitive.primitive { + Primitive::Mesh(output_mesh) => { + output_mesh.texture_id != shape.texture_id() + } + Primitive::Callback(_) => true, + } + } + }; + + if start_new_mesh { + out_primitives.push(ClippedPrimitive { + clip_rect, + primitive: Primitive::Mesh(Mesh::default()), + }); + } + + #[expect(clippy::unwrap_used)] // it's never empty + let out = out_primitives.last_mut().unwrap(); + + if let Primitive::Mesh(out_mesh) = &mut out.primitive { + self.clip_rect = clip_rect; + self.tessellate_shape(shape, out_mesh); + } else { + unreachable!(); + } + } + + /// Tessellate a single [`Shape`] into a [`Mesh`]. + /// + /// This call can panic the given shape is of [`Shape::Vec`] or [`Shape::Callback`]. + /// For that, use [`Self::tessellate_clipped_shape`] instead. + /// * `shape`: the shape to tessellate. + /// * `out`: triangles are appended to this. + pub fn tessellate_shape(&mut self, shape: Shape, out: &mut Mesh) { + match shape { + Shape::Noop => {} + Shape::Vec(vec) => { + for shape in vec { + self.tessellate_shape(shape, out); + } + } + Shape::Circle(circle) => { + self.tessellate_circle(circle, out); + } + Shape::Ellipse(ellipse) => { + self.tessellate_ellipse(ellipse, out); + } + Shape::Mesh(mesh) => { + profiling::scope!("mesh"); + + if self.options.validate_meshes && !mesh.is_valid() { + debug_assert!(false, "Invalid Mesh in Shape::Mesh"); + return; + } + // note: `append` still checks if the mesh is valid if extra asserts are enabled. + + if self.options.coarse_tessellation_culling + && !self.clip_rect.intersects(mesh.calc_bounds()) + { + return; + } + + out.append_ref(&mesh); + } + Shape::LineSegment { points, stroke } => { + self.tessellate_line_segment(points, stroke, out); + } + Shape::Path(path_shape) => { + self.tessellate_path(&path_shape, out); + } + Shape::Rect(rect_shape) => { + self.tessellate_rect(&rect_shape, out); + } + Shape::Text(text_shape) => { + if self.options.debug_paint_text_rects { + let rect = text_shape.galley.rect.translate(text_shape.pos.to_vec2()); + self.tessellate_rect( + &RectShape::stroke(rect, 2.0, (0.5, Color32::GREEN), StrokeKind::Outside), + out, + ); + } + self.tessellate_text(&text_shape, out); + } + Shape::QuadraticBezier(quadratic_shape) => { + self.tessellate_quadratic_bezier(&quadratic_shape, out); + } + Shape::CubicBezier(cubic_shape) => self.tessellate_cubic_bezier(&cubic_shape, out), + Shape::Callback(_) => { + panic!("Shape::Callback passed to Tessellator"); + } + } + } + + /// Tessellate a single [`CircleShape`] into a [`Mesh`]. + /// + /// * `shape`: the circle to tessellate. + /// * `out`: triangles are appended to this. + pub fn tessellate_circle(&mut self, shape: CircleShape, out: &mut Mesh) { + let CircleShape { + center, + radius, + mut fill, + stroke, + } = shape; + + if radius <= 0.0 { + return; + } + + if self.options.coarse_tessellation_culling + && !self + .clip_rect + .expand(radius + stroke.width) + .contains(center) + { + return; + } + + if self.options.prerasterized_discs && fill != Color32::TRANSPARENT { + let radius_px = radius * self.pixels_per_point; + // strike the right balance between some circles becoming too blurry, and some too sharp. + let cutoff_radius = radius_px * 2.0_f32.powf(0.25); + + // Find the right disc radius for a crisp edge: + // TODO(emilk): perhaps we can do something faster than this linear search. + for disc in &self.prepared_discs { + if cutoff_radius <= disc.r { + let side = radius_px * disc.w / (self.pixels_per_point * disc.r); + let rect = Rect::from_center_size(center, Vec2::splat(side)); + out.add_rect_with_uv(rect, disc.uv, fill); + + if stroke.is_empty() { + return; // we are done + } else { + // we still need to do the stroke + fill = Color32::TRANSPARENT; // don't fill again below + break; + } + } + } + } + + let path_stroke = PathStroke::from(stroke).outside(); + self.scratchpad_path.clear(); + self.scratchpad_path.add_circle(center, radius); + self.scratchpad_path + .fill_and_stroke(self.feathering, fill, &path_stroke, out); + } + + /// Tessellate a single [`EllipseShape`] into a [`Mesh`]. + /// + /// * `shape`: the ellipse to tessellate. + /// * `out`: triangles are appended to this. + pub fn tessellate_ellipse(&mut self, shape: EllipseShape, out: &mut Mesh) { + let EllipseShape { + center, + radius, + fill, + stroke, + angle, + } = shape; + + if radius.x <= 0.0 || radius.y <= 0.0 { + return; + } + + if self.options.coarse_tessellation_culling + && !self + .clip_rect + .expand2(radius + Vec2::splat(stroke.width)) + .contains(center) + { + return; + } + + // Get the max pixel radius + let max_radius = (radius.max_elem() * self.pixels_per_point) as u32; + + // Ensure there is at least 8 points in each quarter of the ellipse + let num_points = u32::max(8, max_radius / 16); + + // Create an ease ratio based the ellipses a and b + let ratio = ((radius.y / radius.x) / 2.0).clamp(0.0, 1.0); + + // Generate points between the 0 to pi/2 + let quarter: Vec = (1..num_points) + .map(|i| { + let percent = i as f32 / num_points as f32; + + // Ease the percent value, concentrating points around tight bends + let eased = 2.0 * (percent - percent.powf(2.0)) * ratio + percent.powf(2.0); + + // Scale the ease to the quarter + let t = eased * core::f32::consts::FRAC_PI_2; + Vec2::new(radius.x * f32::cos(t), radius.y * f32::sin(t)) + }) + .collect(); + + // Build the ellipse from the 4 known vertices filling arcs between + // them by mirroring the points between 0 and pi/2 + let mut points = Vec::new(); + points.push(center + Vec2::new(radius.x, 0.0)); + points.extend(quarter.iter().map(|p| center + *p)); + points.push(center + Vec2::new(0.0, radius.y)); + points.extend(quarter.iter().rev().map(|p| center + Vec2::new(-p.x, p.y))); + points.push(center + Vec2::new(-radius.x, 0.0)); + points.extend(quarter.iter().map(|p| center - *p)); + points.push(center + Vec2::new(0.0, -radius.y)); + points.extend(quarter.iter().rev().map(|p| center + Vec2::new(p.x, -p.y))); + + // Apply rotation if angle is non-zero + if angle != 0.0 { + let rot = emath::Rot2::from_angle(angle); + for point in &mut points { + *point = center + rot * (*point - center); + } + } + + let path_stroke = PathStroke::from(stroke).outside(); + self.scratchpad_path.clear(); + self.scratchpad_path.add_line_loop(&points); + self.scratchpad_path + .fill_and_stroke(self.feathering, fill, &path_stroke, out); + } + + /// Tessellate a single [`Mesh`] into a [`Mesh`]. + /// + /// * `mesh`: the mesh to tessellate. + /// * `out`: triangles are appended to this. + pub fn tessellate_mesh(&self, mesh: &Mesh, out: &mut Mesh) { + if !mesh.is_valid() { + debug_assert!(false, "Invalid Mesh in Shape::Mesh"); + return; + } + + if self.options.coarse_tessellation_culling + && !self.clip_rect.intersects(mesh.calc_bounds()) + { + return; + } + + out.append_ref(mesh); + } + + /// Tessellate a line segment between the two points with the given stroke into a [`Mesh`]. + /// + /// * `shape`: the mesh to tessellate. + /// * `out`: triangles are appended to this. + pub fn tessellate_line_segment( + &mut self, + mut points: [Pos2; 2], + stroke: impl Into, + out: &mut Mesh, + ) { + let stroke = stroke.into(); + if stroke.is_empty() { + return; + } + + if self.options.coarse_tessellation_culling + && !self + .clip_rect + .intersects(Rect::from_two_pos(points[0], points[1]).expand(stroke.width)) + { + return; + } + + if self.options.round_line_segments_to_pixels { + let feathering = self.feathering; + let pixels_per_point = self.pixels_per_point; + + let quarter_pixel = 0.25 * feathering; // Used to avoid fence post problem. + + let [a, b] = &mut points; + if a.x == b.x { + // Vertical line + let mut x = a.x; + stroke.round_center_to_pixel(self.pixels_per_point, &mut x); + a.x = x; + b.x = x; + + // Often the ends of the line are exactly on a pixel boundary, + // but we extend line segments with a cap that is a pixel wide… + // Solution: first shrink the line segment (on each end), + // then round to pixel center! + // We shrink by half-a-pixel n total (a quarter on each end), + // so that on average we avoid the fence-post-problem after rounding. + if a.y < b.y { + a.y = (a.y + quarter_pixel).round_to_pixel_center(pixels_per_point); + b.y = (b.y - quarter_pixel).round_to_pixel_center(pixels_per_point); + } else { + a.y = (a.y - quarter_pixel).round_to_pixel_center(pixels_per_point); + b.y = (b.y + quarter_pixel).round_to_pixel_center(pixels_per_point); + } + } + if a.y == b.y { + // Horizontal line + let mut y = a.y; + stroke.round_center_to_pixel(self.pixels_per_point, &mut y); + a.y = y; + b.y = y; + + // See earlier comment for vertical lines + if a.x < b.x { + a.x = (a.x + quarter_pixel).round_to_pixel_center(pixels_per_point); + b.x = (b.x - quarter_pixel).round_to_pixel_center(pixels_per_point); + } else { + a.x = (a.x - quarter_pixel).round_to_pixel_center(pixels_per_point); + b.x = (b.x + quarter_pixel).round_to_pixel_center(pixels_per_point); + } + } + } + + self.scratchpad_path.clear(); + self.scratchpad_path.add_line_segment(points); + self.scratchpad_path + .stroke_open(self.feathering, &stroke.into(), out); + } + + /// Tessellate a single [`PathShape`] into a [`Mesh`]. + /// + /// * `path_shape`: the path to tessellate. + /// * `out`: triangles are appended to this. + pub fn tessellate_path(&mut self, path_shape: &PathShape, out: &mut Mesh) { + if path_shape.points.len() < 2 { + return; + } + + if self.options.coarse_tessellation_culling + && !path_shape.visual_bounding_rect().intersects(self.clip_rect) + { + return; + } + + profiling::function_scope!(); + + let PathShape { + points, + closed, + fill, + stroke, + } = path_shape; + + self.scratchpad_path.clear(); + + if *closed { + self.scratchpad_path.add_line_loop(points); + + self.scratchpad_path + .fill_and_stroke(self.feathering, *fill, stroke, out); + } else { + debug_assert_eq!( + *fill, + Color32::TRANSPARENT, + "You asked to fill a path that is not closed. That makes no sense." + ); + + self.scratchpad_path.add_open_points(points); + + self.scratchpad_path + .stroke(self.feathering, PathType::Open, stroke, out); + } + } + + /// Tessellate a single [`Rect`] into a [`Mesh`]. + /// + /// * `rect`: the rectangle to tessellate. + /// * `out`: triangles are appended to this. + pub fn tessellate_rect(&mut self, rect_shape: &RectShape, out: &mut Mesh) { + if self.options.coarse_tessellation_culling + && !rect_shape.visual_bounding_rect().intersects(self.clip_rect) + { + return; + } + + let brush = rect_shape.brush.as_ref(); + let RectShape { + mut rect, + corner_radius, + mut fill, + mut stroke, + mut stroke_kind, + round_to_pixels, + mut blur_width, + brush: _, // brush is extracted on its own, because it is not Copy + angle, + } = *rect_shape; + + let mut corner_radius = CornerRadiusF32::from(corner_radius); + let round_to_pixels = round_to_pixels.unwrap_or(self.options.round_rects_to_pixels); + + if stroke.width == 0.0 { + stroke.color = Color32::TRANSPARENT; + } + + // It is common to (sometimes accidentally) create an infinitely sized rectangle. + // Make sure we can handle that: + rect.min = rect.min.at_least(pos2(-1e7, -1e7)); + rect.max = rect.max.at_most(pos2(1e7, 1e7)); + + if !stroke.is_empty() { + // Check if the stroke covers the whole rectangle + let rect_with_stroke = match stroke_kind { + StrokeKind::Inside => rect, + StrokeKind::Middle => rect.expand(stroke.width / 2.0), + StrokeKind::Outside => rect.expand(stroke.width), + }; + + if rect_with_stroke.size().min_elem() <= 2.0 * stroke.width + 0.5 * self.feathering { + // The stroke covers the fill. + // Change this to be a fill-only shape, using the stroke color as the new fill color. + rect = rect_with_stroke; + + // We blend so that if the stroke is semi-transparent, + // the fill still shines through. + fill = stroke.color; + + stroke = Stroke::NONE; + } + } + + if angle == 0.0 && stroke.is_empty() && out.texture_id == TextureId::default() { + // Approximate thin rectangles with line segments. + // This is important so that thin rectangles look good. + if rect.width() <= 2.0 * self.feathering { + return self.tessellate_line_segment( + [rect.center_top(), rect.center_bottom()], + (rect.width(), fill), + out, + ); + } + if rect.height() <= 2.0 * self.feathering { + return self.tessellate_line_segment( + [rect.left_center(), rect.right_center()], + (rect.height(), fill), + out, + ); + } + } + + // Important: round to pixels BEFORE modifying/applying stroke_kind + if round_to_pixels { + // The rounding is aware of the stroke kind. + // It is designed to be clever in trying to divine the intentions of the user. + match stroke_kind { + StrokeKind::Inside => { + // The stroke is inside the rect, so the rect defines the _outside_ of the stroke. + // We round the outside of the stroke on a pixel boundary. + // This will make the outside of the stroke crisp. + // + // Will make each stroke asymmetric if not an even multiple of physical pixels, + // but the left stroke will always be the mirror image of the right stroke, + // and the top stroke will always be the mirror image of the bottom stroke. + // + // This is so that a user can tile rectangles with `StrokeKind::Inside`, + // and get no pixel overlap between them. + rect = rect.round_to_pixels(self.pixels_per_point); + } + StrokeKind::Middle => { + // On this path we optimize for crisp and symmetric strokes. + stroke.round_rect_to_pixel(self.pixels_per_point, &mut rect); + } + StrokeKind::Outside => { + // Put the inside of the stroke on a pixel boundary. + // Makes the inside of the stroke and the filled rect crisp, + // but the outside of the stroke may become feathered (blurry). + // + // Will make each stroke asymmetric if not an even multiple of physical pixels, + // but the left stroke will always be the mirror image of the right stroke, + // and the top stroke will always be the mirror image of the bottom stroke. + rect = rect.round_to_pixels(self.pixels_per_point); + } + } + } + + let old_feathering = self.feathering; + + if self.feathering < blur_width { + // We accomplish the blur by using a larger-than-normal feathering. + // Feathering is usually used to make the edges of a shape softer for anti-aliasing. + + // The tessellator can't handle blurring/feathering larger than the smallest side of the rect. + let eps = 0.1; // avoid numerical problems + blur_width = blur_width + .at_most(rect.size().min_elem() - eps - 2.0 * stroke.width) + .at_least(0.0); + + corner_radius += 0.5 * blur_width; + + self.feathering = self.feathering.max(blur_width); + } + + { + // Modify `rect` so that it represents the OUTER border + // We do this because `path::rounded_rectangle` uses the + // corner radius to pick the fidelity/resolution of the corner. + + let original_cr = corner_radius; + + match stroke_kind { + StrokeKind::Inside => {} + StrokeKind::Middle => { + rect = rect.expand(stroke.width / 2.0); + corner_radius += stroke.width / 2.0; + } + StrokeKind::Outside => { + rect = rect.expand(stroke.width); + corner_radius += stroke.width; + } + } + + stroke_kind = StrokeKind::Inside; + + // A small corner_radius is incompatible with a wide stroke, + // because the small bend will be extruded inwards and cross itself. + // There are two ways to solve this (wile maintaining constant stroke width): + // either we increase the corner_radius, or we set it to zero. + // We choose the former: if the user asks for _any_ corner_radius, they should get it. + + let min_inside_cr = 0.1; // Large enough to avoid numerical issues + let min_outside_cr = stroke.width + min_inside_cr; + + let extra_cr_tweak = 0.4; // Otherwise is doesn't _feels_ enough. + + if original_cr.nw == 0.0 { + corner_radius.nw = 0.0; + } else { + corner_radius.nw += extra_cr_tweak; + corner_radius.nw = corner_radius.nw.at_least(min_outside_cr); + } + if original_cr.ne == 0.0 { + corner_radius.ne = 0.0; + } else { + corner_radius.ne += extra_cr_tweak; + corner_radius.ne = corner_radius.ne.at_least(min_outside_cr); + } + if original_cr.sw == 0.0 { + corner_radius.sw = 0.0; + } else { + corner_radius.sw += extra_cr_tweak; + corner_radius.sw = corner_radius.sw.at_least(min_outside_cr); + } + if original_cr.se == 0.0 { + corner_radius.se = 0.0; + } else { + corner_radius.se += extra_cr_tweak; + corner_radius.se = corner_radius.se.at_least(min_outside_cr); + } + } + + let path = &mut self.scratchpad_path; + path.clear(); + path::rounded_rectangle(&mut self.scratchpad_points, rect, corner_radius); + + // Apply rotation if angle is non-zero + if angle != 0.0 { + let rot = emath::Rot2::from_angle(angle); + let center = rect.center(); + for point in &mut self.scratchpad_points { + *point = center + rot * (*point - center); + } + } + + path.add_line_loop(&self.scratchpad_points); + + let path_stroke = PathStroke::from(stroke).with_kind(stroke_kind); + + if let Some(brush) = brush { + // Textured fill + + let fill_rect = match stroke_kind { + StrokeKind::Inside => rect.shrink(stroke.width), + StrokeKind::Middle => rect.shrink(stroke.width / 2.0), + StrokeKind::Outside => rect, + }; + + if fill_rect.is_positive() { + let crate::Brush { + fill_texture_id, + uv, + } = **brush; + let uv_from_pos = |p: Pos2| { + pos2( + remap(p.x, rect.x_range(), uv.x_range()), + remap(p.y, rect.y_range(), uv.y_range()), + ) + }; + path.fill_with_uv(self.feathering, fill, fill_texture_id, uv_from_pos, out); + } + + if !stroke.is_empty() { + path.stroke_closed(self.feathering, &path_stroke, out); + } + } else { + // Stroke and maybe fill + path.fill_and_stroke(self.feathering, fill, &path_stroke, out); + } + + self.feathering = old_feathering; // restore + } + + /// Tessellate a single [`TextShape`] into a [`Mesh`]. + /// * `text_shape`: the text to tessellate. + /// * `out`: triangles are appended to this. + pub fn tessellate_text(&mut self, text_shape: &TextShape, out: &mut Mesh) { + let TextShape { + pos: galley_pos, + galley, + underline, + override_text_color, + fallback_color, + opacity_factor, + angle, + } = text_shape; + + if galley.is_empty() { + return; + } + + if *opacity_factor <= 0.0 { + return; + } + + if galley.pixels_per_point != self.pixels_per_point { + log::warn!( + "epaint: WARNING: pixels_per_point (dpi scale) have changed between text layout and tessellation. \ + You must recreate your text shapes if pixels_per_point changes." + ); + } + + out.vertices.reserve(galley.num_vertices); + out.indices.reserve(galley.num_indices); + + // The contents of the galley are already snapped to pixel coordinates, + // but we need to make sure the galley ends up on the start of a physical pixel: + let galley_pos = if self.options.round_text_to_pixels { + galley_pos.round_to_pixels(self.pixels_per_point) + } else { + *galley_pos + }; + + let uv_normalizer = vec2( + 1.0 / self.font_tex_size[0] as f32, + 1.0 / self.font_tex_size[1] as f32, + ); + + let rotator = Rot2::from_angle(*angle); + + for row in &galley.rows { + if row.visuals.mesh.is_empty() { + continue; + } + + let final_row_pos = galley_pos + rotator * row.pos.to_vec2(); + + let mut row_rect = row.visuals.mesh_bounds; + if *angle != 0.0 { + row_rect = row_rect.rotate_bb(rotator); + } + row_rect = row_rect.translate(final_row_pos.to_vec2()); + + if self.options.coarse_tessellation_culling && !self.clip_rect.intersects(row_rect) { + // culling individual lines of text is important, since a single `Shape::Text` + // can span hundreds of lines. + continue; + } + + let index_offset = out.vertices.len() as u32; + + out.indices.extend( + row.visuals + .mesh + .indices + .iter() + .map(|index| index + index_offset), + ); + + out.vertices.extend( + row.visuals + .mesh + .vertices + .iter() + .enumerate() + .map(|(i, vertex)| { + let Vertex { pos, uv, mut color } = *vertex; + + if let Some(override_text_color) = override_text_color { + // Only override the glyph color (not background color, strike-through color, etc) + if row.visuals.glyph_vertex_range.contains(&i) { + color = *override_text_color; + } + } else if color == Color32::PLACEHOLDER { + color = *fallback_color; + } + + if *opacity_factor < 1.0 { + color = color.gamma_multiply(*opacity_factor); + } + + debug_assert!(color != Color32::PLACEHOLDER, "A placeholder color made it to the tessellator. You forgot to set a fallback color."); + + let offset = if *angle == 0.0 { + pos.to_vec2() + } else { + rotator * pos.to_vec2() + }; + + Vertex { + pos: final_row_pos + offset, + uv: (uv.to_vec2() * uv_normalizer).to_pos2(), + color, + } + }), + ); + + if *underline != Stroke::NONE { + self.tessellate_line_segment( + [row_rect.left_bottom(), row_rect.right_bottom()], + *underline, + out, + ); + } + } + } + + /// Tessellate a single [`QuadraticBezierShape`] into a [`Mesh`]. + /// + /// * `quadratic_shape`: the shape to tessellate. + /// * `out`: triangles are appended to this. + pub fn tessellate_quadratic_bezier( + &mut self, + quadratic_shape: &QuadraticBezierShape, + out: &mut Mesh, + ) { + let options = &self.options; + let clip_rect = self.clip_rect; + + if options.coarse_tessellation_culling + && !quadratic_shape.visual_bounding_rect().intersects(clip_rect) + { + return; + } + + let points = quadratic_shape.flatten(Some(options.bezier_tolerance)); + + self.tessellate_bezier_complete( + &points, + quadratic_shape.fill, + quadratic_shape.closed, + &quadratic_shape.stroke, + out, + ); + } + + /// Tessellate a single [`CubicBezierShape`] into a [`Mesh`]. + /// + /// * `cubic_shape`: the shape to tessellate. + /// * `out`: triangles are appended to this. + pub fn tessellate_cubic_bezier(&mut self, cubic_shape: &CubicBezierShape, out: &mut Mesh) { + let options = &self.options; + let clip_rect = self.clip_rect; + if options.coarse_tessellation_culling + && !cubic_shape.visual_bounding_rect().intersects(clip_rect) + { + return; + } + + let points_vec = + cubic_shape.flatten_closed(Some(options.bezier_tolerance), Some(options.epsilon)); + + for points in points_vec { + self.tessellate_bezier_complete( + &points, + cubic_shape.fill, + cubic_shape.closed, + &cubic_shape.stroke, + out, + ); + } + } + + fn tessellate_bezier_complete( + &mut self, + points: &[Pos2], + fill: Color32, + closed: bool, + stroke: &PathStroke, + out: &mut Mesh, + ) { + if points.len() < 2 { + return; + } + + self.scratchpad_path.clear(); + if closed { + self.scratchpad_path.add_line_loop(points); + + self.scratchpad_path + .fill_and_stroke(self.feathering, fill, stroke, out); + } else { + debug_assert_eq!( + fill, + Color32::TRANSPARENT, + "You asked to fill a bezier path that is not closed. That makes no sense." + ); + + self.scratchpad_path.add_open_points(points); + + self.scratchpad_path + .stroke(self.feathering, PathType::Open, stroke, out); + } + } +} + +impl Tessellator { + /// Turns [`Shape`]:s into sets of triangles. + /// + /// The given shapes will tessellated in the same order as they are given. + /// They will be batched together by clip rectangle. + /// + /// * `pixels_per_point`: number of physical pixels to each logical point + /// * `options`: tessellation quality + /// * `shapes`: what to tessellate + /// * `font_tex_size`: size of the font texture. Required to normalize glyph uv rectangles when tessellating text. + /// * `prepared_discs`: What [`crate::TextureAtlas::prepared_discs`] returns. Can safely be set to an empty vec. + /// + /// The implementation uses a [`Tessellator`]. + /// + /// ## Returns + /// A list of clip rectangles with matching [`Mesh`]. + #[allow(clippy::allow_attributes, unused_mut)] + pub fn tessellate_shapes(&mut self, mut shapes: Vec) -> Vec { + profiling::function_scope!(); + + #[cfg(feature = "rayon")] + if self.options.parallel_tessellation { + self.parallel_tessellation_of_large_shapes(&mut shapes); + } + + let mut clipped_primitives: Vec = Vec::default(); + + { + profiling::scope!("tessellate"); + for clipped_shape in shapes { + self.tessellate_clipped_shape(clipped_shape, &mut clipped_primitives); + } + } + + if self.options.debug_paint_clip_rects { + clipped_primitives = self.add_clip_rects(clipped_primitives); + } + + if self.options.debug_ignore_clip_rects { + for clipped_primitive in &mut clipped_primitives { + clipped_primitive.clip_rect = Rect::EVERYTHING; + } + } + + clipped_primitives.retain(|p| { + p.clip_rect.is_positive() + && match &p.primitive { + Primitive::Mesh(mesh) => !mesh.is_empty(), + Primitive::Callback(_) => true, + } + }); + + for clipped_primitive in &clipped_primitives { + if let Primitive::Mesh(mesh) = &clipped_primitive.primitive { + debug_assert!(mesh.is_valid(), "Tessellator generated invalid Mesh"); + } + } + + clipped_primitives + } + + /// Find large shapes and throw them on the rayon thread pool, + /// then replace the original shape with their tessellated meshes. + #[cfg(feature = "rayon")] + fn parallel_tessellation_of_large_shapes(&self, shapes: &mut [ClippedShape]) { + profiling::function_scope!(); + + use rayon::prelude::*; + + // We only parallelize large/slow stuff, because each tessellation job + // will allocate a new Mesh, and so it creates a lot of extra memory fragmentation + // and allocations that is only worth it for large shapes. + fn should_parallelize(shape: &Shape) -> bool { + match shape { + Shape::Vec(shapes) => 4 < shapes.len() || shapes.iter().any(should_parallelize), + + Shape::Path(path_shape) => 32 < path_shape.points.len(), + + Shape::QuadraticBezier(_) | Shape::CubicBezier(_) | Shape::Ellipse(_) => true, + + Shape::Noop + | Shape::Text(_) + | Shape::Circle(_) + | Shape::Mesh(_) + | Shape::LineSegment { .. } + | Shape::Rect(_) + | Shape::Callback(_) => false, + } + } + + let tessellated: Vec<(usize, Mesh)> = shapes + .par_iter() + .enumerate() + .filter(|(_, clipped_shape)| should_parallelize(&clipped_shape.shape)) + .map(|(index, clipped_shape)| { + profiling::scope!("tessellate_big_shape"); + // TODO(emilk): reuse tessellator in a thread local + let mut tessellator = (*self).clone(); + let mut mesh = Mesh::default(); + tessellator.tessellate_shape(clipped_shape.shape.clone(), &mut mesh); + (index, mesh) + }) + .collect(); + + profiling::scope!("distribute results", tessellated.len().to_string()); + for (index, mesh) in tessellated { + shapes[index].shape = Shape::Mesh(mesh.into()); + } + } + + fn add_clip_rects( + &mut self, + clipped_primitives: Vec, + ) -> Vec { + self.clip_rect = Rect::EVERYTHING; + let stroke = Stroke::new(2.0, Color32::from_rgb(150, 255, 150)); + + clipped_primitives + .into_iter() + .flat_map(|clipped_primitive| { + let mut clip_rect_mesh = Mesh::default(); + self.tessellate_shape( + Shape::rect_stroke( + clipped_primitive.clip_rect, + 0.0, + stroke, + StrokeKind::Outside, + ), + &mut clip_rect_mesh, + ); + + [ + clipped_primitive, + ClippedPrimitive { + clip_rect: Rect::EVERYTHING, // whatever + primitive: Primitive::Mesh(clip_rect_mesh), + }, + ] + }) + .collect() + } +} + +#[test] +fn test_tessellator() { + use crate::*; + + let mut shapes = Vec::with_capacity(2); + + let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + + let mut mesh = Mesh::with_texture(TextureId::Managed(1)); + mesh.add_rect_with_uv(rect, uv, Color32::WHITE); + shapes.push(Shape::mesh(mesh)); + + let mut mesh = Mesh::with_texture(TextureId::Managed(2)); + mesh.add_rect_with_uv(rect, uv, Color32::WHITE); + shapes.push(Shape::mesh(mesh)); + + let shape = Shape::Vec(shapes); + let clipped_shapes = vec![ClippedShape { + clip_rect: rect, + shape, + }]; + + let font_tex_size = [1024, 1024]; // unused + let prepared_discs = vec![]; // unused + + let primitives = Tessellator::new(1.0, Default::default(), font_tex_size, prepared_discs) + .tessellate_shapes(clipped_shapes); + + assert_eq!(primitives.len(), 2); +} + +#[test] +fn path_bounding_box() { + use crate::*; + + for i in 1..=100 { + let width = i as f32; + + let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(10.0, 10.0)); + let expected_rect = rect.expand((width / 2.0) + 1.5); + + let mut mesh = Mesh::default(); + + let mut path = Path::default(); + path.add_open_points(&[ + pos2(0.0, 0.0), + pos2(2.0, 0.0), + pos2(5.0, 5.0), + pos2(0.0, 5.0), + pos2(0.0, 7.0), + pos2(10.0, 10.0), + ]); + + path.stroke( + 1.5, + PathType::Closed, + &PathStroke::new_uv(width, move |r, p| { + assert_eq!(r, expected_rect); + // see https://github.com/emilk/egui/pull/4353#discussion_r1573879940 for why .contains() isn't used here. + // TL;DR rounding errors. + assert!( + r.distance_to_pos(p) <= 0.55, + "passed rect {r:?} didn't contain point {p:?} (distance: {})", + r.distance_to_pos(p) + ); + assert!( + expected_rect.distance_to_pos(p) <= 0.55, + "expected rect {expected_rect:?} didn't contain point {p:?}" + ); + Color32::WHITE + }), + &mut mesh, + ); + } +} diff --git a/crates/accshift-egui/vendor/epaint/src/text/cursor.rs b/crates/accshift-egui/vendor/epaint/src/text/cursor.rs new file mode 100644 index 0000000..1143fc5 --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/text/cursor.rs @@ -0,0 +1,111 @@ +//! Different types of text cursors, i.e. ways to point into a [`super::Galley`]. + +use super::index::CharIndex; + +/// Character cursor. +/// +/// The default cursor is zero. +#[derive(Clone, Copy, Debug, Default)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct CCursor { + /// Character offset (NOT byte offset!). + pub index: CharIndex, + + /// If this cursors sits right at the border of a wrapped row break (NOT paragraph break) + /// do we prefer the next row? + /// This is *almost* always what you want, *except* for when + /// explicitly clicking the end of a row or pressing the end key. + pub prefer_next_row: bool, +} + +impl CCursor { + #[inline] + pub fn new(index: impl Into) -> Self { + Self { + index: index.into(), + prefer_next_row: false, + } + } +} + +/// Two `CCursor`s are considered equal if they refer to the same character boundary, +/// even if one prefers the start of the next row. +impl PartialEq for CCursor { + #[inline] + fn eq(&self, other: &Self) -> bool { + self.index == other.index + } +} + +impl core::ops::Add for CCursor { + type Output = Self; + + fn add(self, rhs: usize) -> Self::Output { + Self { + index: self.index.saturating_add(rhs), + prefer_next_row: self.prefer_next_row, + } + } +} + +impl core::ops::Add for CCursor { + type Output = Self; + + fn add(self, rhs: CharIndex) -> Self::Output { + Self { + index: self.index + rhs, + prefer_next_row: self.prefer_next_row, + } + } +} + +impl core::ops::Sub for CCursor { + type Output = Self; + + fn sub(self, rhs: usize) -> Self::Output { + Self { + index: self.index.saturating_sub(rhs), + prefer_next_row: self.prefer_next_row, + } + } +} + +impl core::ops::Sub for CCursor { + type Output = Self; + + fn sub(self, rhs: CharIndex) -> Self::Output { + Self { + index: self.index - rhs, + prefer_next_row: self.prefer_next_row, + } + } +} + +impl core::ops::AddAssign for CCursor { + fn add_assign(&mut self, rhs: usize) { + self.index = self.index.saturating_add(rhs); + } +} + +impl core::ops::SubAssign for CCursor { + fn sub_assign(&mut self, rhs: usize) { + self.index = self.index.saturating_sub(rhs); + } +} + +/// Row/column cursor. +/// +/// This refers to rows and columns in layout terms--text wrapping creates multiple rows. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct LayoutCursor { + /// 0 is first row, and so on. + /// Note that a single paragraph can span multiple rows. + /// (a paragraph is text separated by `\n`). + pub row: usize, + + /// Character based (NOT bytes). + /// It is fine if this points to something beyond the end of the current row. + /// When moving up/down it may again be within the next row. + pub column: CharIndex, +} diff --git a/crates/accshift-egui/vendor/epaint/src/text/font.rs b/crates/accshift-egui/vendor/epaint/src/text/font.rs new file mode 100644 index 0000000..a37a10e --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/text/font.rs @@ -0,0 +1,1013 @@ +#![expect(clippy::mem_forget)] + +//! accshift patch (vendored epaint 0.36.2): a [`FontFace`] may carry a +//! [`GlyphRasterizer`], set from `FontData::rasterizer`. When it does: +//! +//! - `allocate_glyph_uncached` asks it for the glyph coverage first and falls +//! back to skrifa plus `vello_cpu` when it returns `None`; +//! - CJK glyphs get sub-pixel bins like every other glyph (Skia bins them +//! all), instead of being rounded to a whole pixel. +//! +//! Nothing else changes: advances, layout, the atlas and the colour transfer +//! function are upstream's. Every change is marked "accshift patch". + +use ecolor::Color32; +use emath::{GuiRounding as _, OrderedFloat, Vec2, vec2}; +use self_cell::self_cell; +use skrifa::{GlyphId, MetadataProvider as _}; +use std::collections::BTreeMap; +use std::sync::Arc; +use vello_cpu::{color, kurbo}; + +use crate::{ + TextOptions, TextureAtlas, + text::{ + FontTweak, VariationCoords, + fonts::{Blob, CachedFamily, FontFaceKey}, + }, +}; + +// ---------------------------------------------------------------------------- +// accshift patch: the glyph rasterizer hook. + +/// One glyph to rasterize. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GlyphRequest { + /// Glyph id in the font. + pub glyph_id: u16, + + /// Size of the em in device pixels. + pub ppem: f32, + + /// Sub-pixel x offset of the glyph origin, in device pixels: 0, 0.25, + /// 0.5 or 0.75. + pub x_offset: f32, +} + +/// The coverage of one glyph. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct GlyphImage { + /// Left edge of the bitmap, in device pixels right of the glyph origin. + pub left: i32, + + /// Top edge of the bitmap, in device pixels below the baseline (negative + /// above it). + pub top: i32, + + pub width: u32, + pub height: u32, + + /// `width * height` coverage values, rows top to bottom, 0 to 255, + /// linear (the atlas colour transfer function is applied on top). + pub coverage: Vec, +} + +/// Supplies the glyph coverage of one font face instead of skrifa and +/// `vello_cpu`. +pub trait GlyphRasterizer: Send + Sync { + /// The coverage of a glyph; an empty image for a glyph with no ink, + /// `None` to fall back to epaint's own rasterizer. + fn rasterize(&self, request: &GlyphRequest) -> Option; +} + +/// A shared [`GlyphRasterizer`]; two are equal when they are the same object. +#[derive(Clone)] +pub struct GlyphRasterizerRef(pub Arc); + +impl PartialEq for GlyphRasterizerRef { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } +} + +impl std::fmt::Debug for GlyphRasterizerRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("GlyphRasterizerRef") + } +} + +/// Copies a rasterizer's coverage into the atlas. +fn place_glyph_image( + atlas: &mut TextureAtlas, + metrics: &StyledMetrics, + image: &GlyphImage, +) -> UvRect { + let (width, height) = (image.width as usize, image.height as usize); + if width == 0 || height == 0 || image.coverage.len() < width * height { + return UvRect::default(); + } + let color_transfer_function = atlas.options().color_transfer_function; + let (glyph_pos, atlas_image) = atlas.allocate((width, height)); + for y in 0..height { + for x in 0..width { + let c = image.coverage[y * width + x]; + atlas_image[(x + glyph_pos.0, y + glyph_pos.1)] = color_transfer_function + .to_atlas_color(Color32::from_rgba_premultiplied(c, c, c, c)); + } + } + let offset_in_pixels = vec2(image.left as f32, image.top as f32); + UvRect { + offset: offset_in_pixels / metrics.pixels_per_point + + metrics.y_offset_in_points * Vec2::Y, + size: vec2(width as f32, height as f32) / metrics.pixels_per_point, + min: [glyph_pos.0 as u16, glyph_pos.1 as u16], + max: [(glyph_pos.0 + width) as u16, (glyph_pos.1 + height) as u16], + } +} + +// ---------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct UvRect { + /// X/Y offset for nice rendering (unit: points). + pub offset: Vec2, + + /// Screen size (in points) of this glyph. + /// Note that the height is different from the font height. + pub size: Vec2, + + /// Top left corner UV in texture. + pub min: [u16; 2], + + /// Bottom right corner (exclusive). + pub max: [u16; 2], +} + +impl UvRect { + pub fn is_nothing(&self) -> bool { + self.min == self.max + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct GlyphInfo { + /// Doesn't need to be unique. + /// + /// Is `None` for a special "invisible" glyph. + pub(crate) id: Option, + + /// In [`skrifa`]s "unscaled" coordinate system. + pub advance_width_unscaled: OrderedFloat, +} + +impl GlyphInfo { + /// A valid, but invisible, glyph of zero-width. + pub const INVISIBLE: Self = Self { + id: None, + advance_width_unscaled: OrderedFloat(0.0), + }; +} + +/// Result of resolving a `char` to a [`GlyphId`] within a single [`FontFace`]. +/// +/// Location-independent: only depends on the font's charmap and `FontTweak`, +/// not on variable-font variation coordinates. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(super) enum GlyphIdResolution { + /// A real, visible glyph. + Glyph(GlyphId), + + /// A valid char, but rendered as zero-width (control chars, joiners, …). + Invisible, +} + +/// A precomputed hash of a [`skrifa::instance::Location`]. +/// +/// Used as a cache key so that we don't have to re-hash the coordinate list +/// for every glyph lookup. Compute once per text run and reuse for every glyph +/// in the run. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub(crate) struct LocationHash(u64); + +impl nohash_hasher::IsEnabled for LocationHash {} + +impl LocationHash { + #[inline] + pub fn new(location: &skrifa::instance::Location) -> Self { + if location.coords().is_empty() { + // Fast path for the (common) default-coords case. + Self(0) + } else { + Self(crate::util::hash(location)) + } + } +} + +// Subpixel binning, taken from cosmic-text: +// https://github.com/pop-os/cosmic-text/blob/974ddaed96b334f560b606ebe5d2ca2d2f9f23ef/src/glyph_cache.rs + +/// Bin for subpixel positioning of glyphs. +/// +/// For accurate glyph positioning, we want to render each glyph at a subpixel coordinate. However, we also want to +/// cache each glyph's bitmap. As a compromise, we bin each subpixel offset into one of four fractional values. This +/// means one glyph can have up to four subpixel-positioned bitmaps in the cache. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +pub(super) enum SubpixelBin { + #[default] + Zero, + One, + Two, + Three, +} + +impl SubpixelBin { + /// Bin the given position and return the new integral coordinate. + fn new(pos: f32) -> (i32, Self) { + let trunc = pos as i32; + let fract = pos - trunc as f32; + + if pos.is_sign_negative() { + if fract > -0.125 { + (trunc, Self::Zero) + } else if fract > -0.375 { + (trunc - 1, Self::Three) + } else if fract > -0.625 { + (trunc - 1, Self::Two) + } else if fract > -0.875 { + (trunc - 1, Self::One) + } else { + (trunc - 1, Self::Zero) + } + } else { + if fract < 0.125 { + (trunc, Self::Zero) + } else if fract < 0.375 { + (trunc, Self::One) + } else if fract < 0.625 { + (trunc, Self::Two) + } else if fract < 0.875 { + (trunc, Self::Three) + } else { + (trunc + 1, Self::Zero) + } + } + } + + pub fn as_float(&self) -> f32 { + match self { + Self::Zero => 0.0, + Self::One => 0.25, + Self::Two => 0.5, + Self::Three => 0.75, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct GlyphAllocation { + /// UV rectangle for drawing. + pub uv_rect: UvRect, +} + +#[derive(Hash, PartialEq, Eq)] +struct GlyphCacheKey(u64); + +impl nohash_hasher::IsEnabled for GlyphCacheKey {} + +impl GlyphCacheKey { + #[inline] + fn new(glyph_id: GlyphId, metrics: &StyledMetrics, bin: SubpixelBin) -> Self { + let StyledMetrics { + pixels_per_point, + px_scale_factor, + location_hash, + .. + } = *metrics; + debug_assert!( + 0.0 < pixels_per_point && pixels_per_point.is_finite(), + "Bad pixels_per_point {pixels_per_point}" + ); + debug_assert!( + 0.0 < px_scale_factor && px_scale_factor.is_finite(), + "Bad px_scale_factor: {px_scale_factor}" + ); + Self(crate::util::hash(( + glyph_id, + pixels_per_point.to_bits(), + px_scale_factor.to_bits(), + bin, + location_hash, + ))) + } +} + +// ---------------------------------------------------------------------------- + +struct DependentFontData<'a> { + skrifa: skrifa::FontRef<'a>, + charmap: skrifa::charmap::Charmap<'a>, + outline_glyphs: skrifa::outline::OutlineGlyphCollection<'a>, + metrics: skrifa::metrics::Metrics, + hinting_instance: Option, +} + +self_cell! { + struct FontCell { + owner: Blob, + + #[covariant] + dependent: DependentFontData, + } +} + +impl FontCell { + fn px_scale_factor(&self, scale: f32) -> f32 { + let units_per_em = self.borrow_dependent().metrics.units_per_em as f32; + scale / units_per_em + } + + fn allocate_glyph_uncached( + &mut self, + atlas: &mut TextureAtlas, + metrics: &StyledMetrics, + glyph_id: GlyphId, + bin: SubpixelBin, + location: skrifa::instance::LocationRef<'_>, + hinting_target: skrifa::outline::Target, + rasterizer: Option<&GlyphRasterizerRef>, + ) -> Option { + debug_assert!( + glyph_id != skrifa::GlyphId::NOTDEF, + "Can't allocate glyph for id 0" + ); + + // accshift patch: the font's own rasterizer first. + if let Some(rasterizer) = rasterizer + && let Ok(id) = u16::try_from(glyph_id.to_u32()) + && let Some(image) = rasterizer.0.rasterize(&GlyphRequest { + glyph_id: id, + ppem: metrics.scale, + x_offset: bin.as_float(), + }) + { + return Some(GlyphAllocation { + uv_rect: place_glyph_image(atlas, metrics, &image), + }); + } + + let mut path = kurbo::BezPath::new(); + let mut pen = VelloPen { + path: &mut path, + x_offset: bin.as_float() as f64, + }; + + self.with_dependent_mut(|_, font_data| { + let outline = font_data.outline_glyphs.get(glyph_id)?; + + if let Some(hinting_instance) = &mut font_data.hinting_instance { + let size = skrifa::instance::Size::new(metrics.scale); + if hinting_instance.size() != size + || hinting_instance.location().coords() != location.coords() + || hinting_instance.target() != hinting_target + { + hinting_instance + .reconfigure(&font_data.outline_glyphs, size, location, hinting_target) + .ok()?; + } + let draw_settings = skrifa::outline::DrawSettings::hinted(hinting_instance, false); + outline.draw(draw_settings, &mut pen).ok()?; + } else { + let draw_settings = skrifa::outline::DrawSettings::unhinted( + skrifa::instance::Size::new(metrics.scale), + location, + ); + outline.draw(draw_settings, &mut pen).ok()?; + } + + Some(()) + })?; + + let bounds = path.control_box().expand(); + let width = bounds.width() as u16; + let height = bounds.height() as u16; + + let uv_rect = if width == 0 || height == 0 { + UvRect::default() + } else { + let mut ctx = vello_cpu::RenderContext::new(width, height); + ctx.set_transform(kurbo::Affine::translate((-bounds.x0, -bounds.y0))); + ctx.set_paint(color::OpaqueColor::::WHITE); + ctx.fill_path(&path); + let mut dest = vello_cpu::Pixmap::new(width, height); + let mut resources = vello_cpu::Resources::new(); + ctx.render(&mut dest, &mut resources); + + let glyph_pos = { + let color_transfer_function = atlas.options().color_transfer_function; + let (glyph_pos, image) = atlas.allocate((width as usize, height as usize)); + let pixels = dest.data_as_u8_slice(); + for y in 0..height as usize { + for x in 0..width as usize { + let pixel_offset = 4 * ((y * width as usize) + x); + image[(x + glyph_pos.0, y + glyph_pos.1)] = color_transfer_function + .to_atlas_color(Color32::from_rgba_premultiplied( + pixels[pixel_offset], + pixels[pixel_offset + 1], + pixels[pixel_offset + 2], + pixels[pixel_offset + 3], + )); + } + } + glyph_pos + }; + let offset_in_pixels = vec2(bounds.x0 as f32, bounds.y0 as f32); + let offset = + offset_in_pixels / metrics.pixels_per_point + metrics.y_offset_in_points * Vec2::Y; + UvRect { + offset, + size: vec2(width as f32, height as f32) / metrics.pixels_per_point, + min: [glyph_pos.0 as u16, glyph_pos.1 as u16], + max: [ + (glyph_pos.0 + width as usize) as u16, + (glyph_pos.1 + height as usize) as u16, + ], + } + }; + + Some(GlyphAllocation { uv_rect }) + } +} + +struct VelloPen<'a> { + path: &'a mut kurbo::BezPath, + x_offset: f64, +} + +impl skrifa::outline::OutlinePen for VelloPen<'_> { + fn move_to(&mut self, x: f32, y: f32) { + self.path.move_to((x as f64 + self.x_offset, -y as f64)); + } + + fn line_to(&mut self, x: f32, y: f32) { + self.path.line_to((x as f64 + self.x_offset, -y as f64)); + } + + fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { + self.path.quad_to( + (cx0 as f64 + self.x_offset, -cy0 as f64), + (x as f64 + self.x_offset, -y as f64), + ); + } + + fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { + self.path.curve_to( + (cx0 as f64 + self.x_offset, -cy0 as f64), + (cx1 as f64 + self.x_offset, -cy1 as f64), + (x as f64 + self.x_offset, -y as f64), + ); + } + + fn close(&mut self) { + self.path.close_path(); + } +} + +/// A specific font face. +/// The interface uses points as the unit for everything. +pub struct FontFace { + name: String, + font: FontCell, + tweak: FontTweak, + subpixel_binning: bool, + + /// Cached `harfrust` shaper data (parsed GSUB/GPOS tables). + /// `ShaperData` is `Copy` — lives outside the `self_cell`. + shaper_data: harfrust::ShaperData, + + /// Location-independent: `char → GlyphId | Invisible`. + /// + /// Only depends on the font's charmap + `FontTweak`. A miss means the char + /// is not in this face's repertoire and the fallback chain should be tried. + glyph_id_cache: ahash::HashMap, + + /// Location-dependent: `(char, LocationHash) → unscaled advance width`. + /// + /// Variable fonts can vary advance widths per axis (HVAR table), so this + /// must be re-keyed per resolved [`skrifa::instance::Location`]. + advance_width_cache: ahash::HashMap<(char, LocationHash), OrderedFloat>, + + glyph_alloc_cache: ahash::HashMap, + + /// accshift patch: supplies the glyph coverage when set. + rasterizer: Option, +} + +impl FontFace { + pub fn new( + options: TextOptions, + name: String, + font_data: Blob, + index: u32, + tweak: FontTweak, + ) -> Result> { + let font = FontCell::try_new(font_data, |font_data| { + let skrifa_font = + skrifa::FontRef::from_index(AsRef::<[u8]>::as_ref(font_data.as_ref()), index)?; + + let charmap = skrifa_font.charmap(); + let glyphs = skrifa_font.outline_glyphs(); + + // Note: We use default location here during initialization because + // the actual weight will be applied via the stored location during rendering. + // The metrics won't be significantly different at this unscaled size. + // TODO(emilk): heed location for vertical metrics too (HVAR/MVAR). + let metrics = skrifa_font.metrics( + skrifa::instance::Size::unscaled(), + skrifa::instance::LocationRef::default(), + ); + + let hinting_enabled = tweak.hinting.unwrap_or(options.font_hinting); + let hinting_instance = hinting_enabled + .then(|| { + // It doesn't really matter what we put here for options. Since the size is `unscaled()`, we will + // always reconfigure this hinting instance with the real options when rendering for the first time. + skrifa::outline::HintingInstance::new( + &glyphs, + skrifa::instance::Size::unscaled(), + skrifa::instance::LocationRef::default(), + skrifa::outline::Target::default(), + ) + .ok() + }) + .flatten(); + + Ok::, Box>(DependentFontData { + skrifa: skrifa_font, + charmap, + outline_glyphs: glyphs, + metrics, + hinting_instance, + }) + })?; + + let shaper_data = harfrust::ShaperData::new(&font.borrow_dependent().skrifa); + + let subpixel_binning = tweak.subpixel_binning.unwrap_or(options.subpixel_binning); + + Ok(Self { + name, + font, + tweak, + subpixel_binning, + shaper_data, + glyph_id_cache: Default::default(), + advance_width_cache: Default::default(), + glyph_alloc_cache: Default::default(), + rasterizer: None, + }) + } + + /// accshift patch: draw this face's glyphs with `rasterizer`. + pub(crate) fn with_rasterizer(mut self, rasterizer: Option) -> Self { + self.rasterizer = rasterizer; + self + } + + /// Code points that will always be replaced by the replacement character. + /// + /// See also [`invisible_char`]. + fn ignore_character(&self, chr: char) -> bool { + use crate::text::FontDefinitions; + + if !FontDefinitions::builtin_font_names().contains(&self.name.as_str()) { + return false; + } + + matches!( + chr, + // Strip out a religious symbol with secondary nefarious interpretation: + '\u{534d}' | '\u{5350}' | + + // Ignore ubuntu-specific stuff in `Ubuntu-Light.ttf`: + '\u{E0FF}' | '\u{EFFD}' | '\u{F0FF}' | '\u{F200}' + ) + } + + /// An un-ordered iterator over all supported characters. + fn characters(&self) -> impl Iterator + '_ { + self.font + .borrow_dependent() + .charmap + .mappings() + .filter_map(|(chr, _)| char::from_u32(chr).filter(|c| !self.ignore_character(*c))) + } + + /// Resolve a `char` to a [`GlyphId`] within this face. + /// + /// Location-independent. Returns `None` when this face cannot represent + /// the char (the caller should try the fallback chain). + /// + /// `\t` and thin spaces share `' '`s glyph id (they just have a custom advance). + pub(super) fn glyph_id_resolution(&mut self, c: char) -> Option { + if let Some(resolution) = self.glyph_id_cache.get(&c) { + return Some(*resolution); + } + + if self.ignore_character(c) { + return None; // these will result in the replacement character when rendering + } + + let resolution = if c == '\t' || c == '\u{2009}' || c == '\u{202F}' { + // `\t` and thin spaces are rendered as a space glyph with a custom advance. + self.glyph_id_resolution(' ')? + } else if invisible_char(c) { + GlyphIdResolution::Invisible + } else { + let glyph_id = self + .font + .borrow_dependent() + .charmap + .map(c) + .filter(|id| *id != GlyphId::NOTDEF)?; + GlyphIdResolution::Glyph(glyph_id) + }; + + self.glyph_id_cache.insert(c, resolution); + Some(resolution) + } + + /// Unscaled advance width for `c` at the given variation location. + /// + /// Location-dependent (variable fonts can vary advances via HVAR). + /// Cached per `(char, LocationHash)`. + fn advance_width_unscaled(&mut self, c: char, metrics: &StyledMetrics) -> f32 { + let cache_key = (c, metrics.location_hash); + if let Some(advance) = self.advance_width_cache.get(&cache_key) { + return advance.0; + } + + let advance = match c { + '\t' => self.tweak.tab_size * self.advance_width_unscaled(' ', metrics), + '\u{2009}' | '\u{202F}' => { + // Thin space (U+2009) and narrow no-break space (U+202F), + // often used as thousands separator. + self.tweak.thin_space_width * self.advance_width_unscaled(' ', metrics) + } + _ => { + let Some(GlyphIdResolution::Glyph(glyph_id)) = self.glyph_id_resolution(c) else { + return 0.0; + }; + let font_data = self.font.borrow_dependent(); + let glyph_metrics = font_data + .skrifa + .glyph_metrics(skrifa::instance::Size::unscaled(), &metrics.location); + glyph_metrics.advance_width(glyph_id).unwrap_or_default() + } + }; + + self.advance_width_cache.insert(cache_key, advance.into()); + advance + } + + /// `\n` will result in `None`. + /// + /// Caller must pass [`StyledMetrics`] resolved against *this* face so that + /// variable-font advance widths are looked up at the correct location. + pub(super) fn glyph_info(&mut self, c: char, metrics: &StyledMetrics) -> Option { + let resolution = self.glyph_id_resolution(c)?; + let glyph_info = match resolution { + GlyphIdResolution::Invisible => GlyphInfo::INVISIBLE, + GlyphIdResolution::Glyph(glyph_id) => GlyphInfo { + id: Some(glyph_id), + advance_width_unscaled: self.advance_width_unscaled(c, metrics).into(), + }, + }; + Some(glyph_info) + } + + #[inline(always)] + pub fn styled_metrics( + &self, + pixels_per_point: f32, + font_size: f32, + coords: &VariationCoords, + ) -> StyledMetrics { + let pt_scale_factor = self.font.px_scale_factor(font_size * self.tweak.scale); + let font_data = self.font.borrow_dependent(); + let ascent = (font_data.metrics.ascent * pt_scale_factor).round_ui(); + let descent = (font_data.metrics.descent * pt_scale_factor).round_ui(); + let line_gap = (font_data.metrics.leading * pt_scale_factor).round_ui(); + + let scale = font_size * self.tweak.scale * pixels_per_point; + let px_scale_factor = self.font.px_scale_factor(scale); + + let y_offset_in_points = ((font_size * self.tweak.scale * self.tweak.y_offset_factor) + + self.tweak.y_offset) + .round_ui(); + + let axes = font_data.skrifa.axes(); + // Override the default coordinates with ones specified via FontTweak, then the ones specified directly via the + // argument (probably from TextFormat). + let settings = core::iter::chain(self.tweak.coords.as_ref(), coords.as_ref()); + let location = axes.location(settings); + let location_hash = LocationHash::new(&location); + + StyledMetrics { + pixels_per_point, + px_scale_factor, + scale, + y_offset_in_points, + ascent, + row_height: ascent - descent + line_gap, + location, + location_hash, + } + } + + pub(crate) fn skrifa_font_ref(&self) -> &skrifa::FontRef<'_> { + &self.font.borrow_dependent().skrifa + } + + pub(crate) fn tweak(&self) -> &FontTweak { + &self.tweak + } + + pub(crate) fn shaper_data(&self) -> &harfrust::ShaperData { + &self.shaper_data + } + + pub fn allocate_glyph( + &mut self, + atlas: &mut TextureAtlas, + metrics: &StyledMetrics, + shaped: &ShapedGlyph, + ) -> (GlyphAllocation, i32) { + let ShapedGlyph { + glyph_id, + h_pos, + is_cjk, + } = *shaped; + + if glyph_id == GlyphId::NOTDEF { + // invisible + return (GlyphAllocation::default(), h_pos.round() as i32); + } + + // accshift patch: with a rasterizer, CJK glyphs are binned too. + let (h_pos_round, bin) = if self.subpixel_binning && (!is_cjk || self.rasterizer.is_some()) + { + SubpixelBin::new(h_pos) + } else { + // CJK scripts contain a lot of characters and could hog the glyph atlas + // if we stored 4 subpixel offsets per glyph. + (h_pos.round() as i32, SubpixelBin::Zero) + }; + + let cache_key = GlyphCacheKey::new(glyph_id, metrics, bin); + + let hinting_target = self.tweak.hinting_target.into(); + let alloc = *self.glyph_alloc_cache.entry(cache_key).or_insert_with(|| { + self.font + .allocate_glyph_uncached( + atlas, + metrics, + glyph_id, + bin, + (&metrics.location).into(), + hinting_target, + self.rasterizer.as_ref(), + ) + .unwrap_or_default() + }); + + (alloc, h_pos_round) + } +} + +/// Positioning info for a single glyph, ready for atlas allocation. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ShapedGlyph { + pub glyph_id: GlyphId, + + /// Horizontal position of the glyph origin, in physical pixels. + pub h_pos: f32, + + /// CJK glyphs skip subpixel positioning to save atlas space. + pub is_cjk: bool, +} + +// TODO(emilk): rename? +/// Wrapper over multiple [`FontFace`] (e.g. a primary + fallbacks for emojis) +pub struct Font<'a> { + pub(super) fonts_by_id: &'a mut nohash_hasher::IntMap, + pub(super) cached_family: &'a mut CachedFamily, + pub(super) atlas: &'a mut TextureAtlas, +} + +impl Font<'_> { + pub fn preload_characters(&mut self, s: &str) { + for c in s.chars() { + self.resolve_face(c); + } + } + + /// All supported characters, and in which font they are available in. + pub fn characters(&mut self) -> &BTreeMap> { + self.cached_family.characters.get_or_insert_with(|| { + let mut characters: BTreeMap> = Default::default(); + for font_id in &self.cached_family.fonts { + let font = self.fonts_by_id.get(font_id).expect("Nonexistent font ID"); + for chr in font.characters() { + characters.entry(chr).or_default().push(font.name.clone()); + } + } + characters + }) + } + + pub fn styled_metrics( + &self, + pixels_per_point: f32, + font_size: f32, + coords: &VariationCoords, + ) -> StyledMetrics { + self.cached_family + .fonts + .first() + .and_then(|key| self.fonts_by_id.get(key)) + .map(|font_face| font_face.styled_metrics(pixels_per_point, font_size, coords)) + .unwrap_or_default() + } + + /// Width of this character in points, at the font's default variation location. + pub fn glyph_width(&mut self, c: char, font_size: f32) -> f32 { + let face_key = self.resolve_face(c); + let Some(font_face) = self.fonts_by_id.get_mut(&face_key) else { + return 0.0; + }; + let metrics = font_face.styled_metrics(1.0, font_size, &VariationCoords::default()); + let Some(glyph_info) = font_face.glyph_info(c, &metrics) else { + return 0.0; + }; + glyph_info.advance_width_unscaled.0 * font_face.font.px_scale_factor(font_size) + } + + /// Can we display this glyph? + pub fn has_glyph(&mut self, c: char) -> bool { + // TODO(emilk): this is a false negative if the user asks about the replacement character itself 🤦‍♂️ + self.resolve_face(c) != self.cached_family.replacement_face_key + } + + /// Can we display all the glyphs in this text? + pub fn has_glyphs(&mut self, s: &str) -> bool { + s.chars().all(|c| self.has_glyph(c)) + } + + /// Find which face in the fallback chain owns `c`. + /// + /// Location-independent — fallback choice depends only on charmap support. + /// Falls back to the replacement-glyph face when no fallback face has `c`. + #[inline] + pub(crate) fn resolve_face(&mut self, c: char) -> FontFaceKey { + if let Some(font_key) = self.cached_family.face_cache.get(&c) { + return *font_key; + } + self.resolve_face_slow(c) + } + + #[cold] + fn resolve_face_slow(&mut self, c: char) -> FontFaceKey { + let font_key = self + .cached_family + .find_face_for_char(c, self.fonts_by_id) + .unwrap_or(self.cached_family.replacement_face_key); + self.cached_family.face_cache.insert(c, font_key); + font_key + } + + /// Resolve `c` to its (face, [`GlyphInfo`]) at the given face's location. + /// + /// `\n` will (intentionally) show up as the replacement character. + /// + /// `metrics` must be the resolved [`StyledMetrics`] for the face that ends + /// up owning `c`. Most callers pass the metrics of their text run's primary + /// face — that is correct as long as `c` is in that face. For correct + /// fallback-face advances, resolve the face first with [`Self::resolve_face`] + /// and build metrics for that face. + pub(crate) fn glyph_info( + &mut self, + c: char, + metrics: &StyledMetrics, + ) -> (FontFaceKey, GlyphInfo) { + let face_key = self.resolve_face(c); + let Some(face) = self.fonts_by_id.get_mut(&face_key) else { + return (face_key, GlyphInfo::INVISIBLE); + }; + let glyph_info = face.glyph_info(c, metrics).unwrap_or_else(|| { + // `c` is in no face — render the replacement character instead. + face.glyph_info(self.cached_family.replacement_char, metrics) + .unwrap_or(GlyphInfo::INVISIBLE) + }); + (face_key, glyph_info) + } +} + +/// Metrics for a font at a specific screen-space scale. +#[derive(Clone, Debug, PartialEq, Default)] +pub struct StyledMetrics { + /// The DPI part of the screen-space scale. + pub pixels_per_point: f32, + + /// Scale factor, relative to the font's units per em (so, probably much less than 1). + /// + /// Translates "unscaled" units to physical (screen) pixels. + pub px_scale_factor: f32, + + /// Absolute scale in screen pixels, for skrifa. + pub scale: f32, + + /// Vertical offset, in UI points (not screen-space). + pub y_offset_in_points: f32, + + /// This is the distance from the top to the baseline. + /// + /// Unit: points. + pub ascent: f32, + + /// Height of one row of text in points. + /// + /// Returns a value rounded to [`emath::GUI_ROUNDING`]. + pub row_height: f32, + + /// Resolved variation coordinates. + pub location: skrifa::instance::Location, + + /// Precomputed hash of [`Self::location`]. + /// + /// Hashed once per run of text so per-glyph cache lookups don't have to + /// re-hash the full coordinate list. + pub(crate) location_hash: LocationHash, +} + +/// Code points that will always be invisible (zero width). +/// +/// See also [`FontFace::ignore_character`]. +#[inline] +fn invisible_char(c: char) -> bool { + if c == '\r' { + // A character most vile and pernicious. Don't display it. + return true; + } + + // See https://github.com/emilk/egui/issues/336 + + // From https://www.fileformat.info/info/unicode/category/Cf/list.htm + + // TODO(emilk): heed bidi characters + + matches!( + c, + '\u{200B}' // ZERO WIDTH SPACE + | '\u{200C}' // ZERO WIDTH NON-JOINER + | '\u{200D}' // ZERO WIDTH JOINER + | '\u{200E}' // LEFT-TO-RIGHT MARK + | '\u{200F}' // RIGHT-TO-LEFT MARK + | '\u{202A}' // LEFT-TO-RIGHT EMBEDDING + | '\u{202B}' // RIGHT-TO-LEFT EMBEDDING + | '\u{202C}' // POP DIRECTIONAL FORMATTING + | '\u{202D}' // LEFT-TO-RIGHT OVERRIDE + | '\u{202E}' // RIGHT-TO-LEFT OVERRIDE + | '\u{2060}' // WORD JOINER + | '\u{2061}' // FUNCTION APPLICATION + | '\u{2062}' // INVISIBLE TIMES + | '\u{2063}' // INVISIBLE SEPARATOR + | '\u{2064}' // INVISIBLE PLUS + | '\u{2066}' // LEFT-TO-RIGHT ISOLATE + | '\u{2067}' // RIGHT-TO-LEFT ISOLATE + | '\u{2068}' // FIRST STRONG ISOLATE + | '\u{2069}' // POP DIRECTIONAL ISOLATE + | '\u{206A}' // INHIBIT SYMMETRIC SWAPPING + | '\u{206B}' // ACTIVATE SYMMETRIC SWAPPING + | '\u{206C}' // INHIBIT ARABIC FORM SHAPING + | '\u{206D}' // ACTIVATE ARABIC FORM SHAPING + | '\u{206E}' // NATIONAL DIGIT SHAPES + | '\u{206F}' // NOMINAL DIGIT SHAPES + | '\u{FEFF}' // ZERO WIDTH NO-BREAK SPACE + ) +} + +#[inline] +pub(super) fn is_cjk_ideograph(c: char) -> bool { + ('\u{4E00}' <= c && c <= '\u{9FFF}') + || ('\u{3400}' <= c && c <= '\u{4DBF}') + || ('\u{2B740}' <= c && c <= '\u{2B81F}') +} + +#[inline] +pub(super) fn is_kana(c: char) -> bool { + ('\u{3040}' <= c && c <= '\u{309F}') // Hiragana block + || ('\u{30A0}' <= c && c <= '\u{30FF}') // Katakana block +} + +#[inline] +pub(super) fn is_cjk(c: char) -> bool { + // TODO(bigfarts): Add support for Korean Hangul. + is_cjk_ideograph(c) || is_kana(c) +} + +#[inline] +pub(super) fn is_cjk_break_allowed(c: char) -> bool { + // See: https://en.wikipedia.org/wiki/Line_breaking_rules_in_East_Asian_languages#Characters_not_permitted_on_the_start_of_a_line. + !")]}〕〉》」』】〙〗〟'\"⦆»ヽヾーァィゥェォッャュョヮヵヶぁぃぅぇぉっゃゅょゎゕゖㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿ々〻‐゠–〜?!‼⁇⁈⁉・、:;,。.".contains(c) +} diff --git a/crates/accshift-egui/vendor/epaint/src/text/fonts.rs b/crates/accshift-egui/vendor/epaint/src/text/fonts.rs new file mode 100644 index 0000000..ad57d8f --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/text/fonts.rs @@ -0,0 +1,1541 @@ +use core::sync::atomic::{AtomicUsize, Ordering}; +use std::{borrow::Cow, collections::BTreeMap, sync::Arc}; + +use crate::{ + TextureAtlas, + text::{ + ByteIndex, Galley, LayoutJob, LayoutSection, Tag, TextOptions, VariationCoords, + font::{Font, FontFace, GlyphRasterizerRef}, + }, +}; +use emath::{NumExt as _, OrderedFloat, Rangef}; + +#[cfg(feature = "default_fonts")] +use epaint_default_fonts::{EMOJI_ICON, HACK_REGULAR, NOTO_EMOJI_REGULAR, UBUNTU_LIGHT}; + +// ---------------------------------------------------------------------------- + +/// How to select a sized font. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct FontId { + /// Height in points. + pub size: f32, + + /// What font family to use. + pub family: FontFamily, + // TODO(emilk): weight (bold), italics, … +} + +impl Default for FontId { + #[inline] + fn default() -> Self { + Self { + size: 14.0, + family: FontFamily::Proportional, + } + } +} + +impl FontId { + #[inline] + pub const fn new(size: f32, family: FontFamily) -> Self { + Self { size, family } + } + + #[inline] + pub const fn proportional(size: f32) -> Self { + Self::new(size, FontFamily::Proportional) + } + + #[inline] + pub const fn monospace(size: f32) -> Self { + Self::new(size, FontFamily::Monospace) + } +} + +impl core::hash::Hash for FontId { + #[inline(always)] + fn hash(&self, state: &mut H) { + let Self { size, family } = self; + emath::OrderedFloat(*size).hash(state); + family.hash(state); + } +} + +// ---------------------------------------------------------------------------- + +/// Font of unknown size. +/// +/// Which style of font: [`Monospace`][`FontFamily::Monospace`], [`Proportional`][`FontFamily::Proportional`], +/// or by user-chosen name. +#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum FontFamily { + /// A font where some characters are wider than other (e.g. 'w' is wider than 'i'). + /// + /// Proportional fonts are easier to read and should be the preferred choice in most situations. + #[default] + Proportional, + + /// A font where each character is the same width (`w` is the same width as `i`). + /// + /// Useful for code snippets, or when you need to align numbers or text. + Monospace, + + /// One of the names in [`FontDefinitions::families`]. + /// + /// ``` + /// # use epaint::FontFamily; + /// // User-chosen names: + /// FontFamily::Name("arial".into()); + /// FontFamily::Name("serif".into()); + /// ``` + Name(Arc), +} + +impl core::fmt::Display for FontFamily { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Monospace => "Monospace".fmt(f), + Self::Proportional => "Proportional".fmt(f), + Self::Name(name) => (*name).fmt(f), + } + } +} + +// ---------------------------------------------------------------------------- + +/// A `.ttf` or `.otf` file and a font face index. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct FontData { + /// The content of a `.ttf` or `.otf` file. + pub font: Cow<'static, [u8]>, + + /// Which font face in the file to use. + /// When in doubt, use `0`. + pub index: u32, + + /// Extra scale and vertical tweak to apply to all text of this font. + pub tweak: FontTweak, + + /// accshift patch: draws this font's glyphs instead of skrifa and + /// `vello_cpu` when set (see [`crate::text::GlyphRasterizer`]). Not serialized. + #[cfg_attr(feature = "serde", serde(skip))] + pub rasterizer: Option, +} + +impl FontData { + pub fn from_static(font: &'static [u8]) -> Self { + Self { + font: Cow::Borrowed(font), + index: 0, + tweak: Default::default(), + rasterizer: None, + } + } + + pub fn from_owned(font: Vec) -> Self { + Self { + font: Cow::Owned(font), + index: 0, + tweak: Default::default(), + rasterizer: None, + } + } + + /// accshift patch: supply this font's glyph coverage with `rasterizer`. + pub fn rasterizer(self, rasterizer: GlyphRasterizerRef) -> Self { + Self { + rasterizer: Some(rasterizer), + ..self + } + } + + pub fn tweak(self, tweak: FontTweak) -> Self { + Self { tweak, ..self } + } + + /// The variation axes of this font, e.g. `wght` (weight) and `wdth` (width). + /// + /// Use this to discover which axes a variable font supports, and their valid + /// ranges, so a UI can offer the right knobs instead of making the user guess + /// tags and values for [`FontTweak::coords`]. + /// + /// Returns an empty list for non-variable (static) fonts, or if the font data + /// fails to parse. + pub fn variation_axes(&self) -> Vec { + use skrifa::MetadataProvider as _; + + let Ok(font) = skrifa::FontRef::from_index(self.font.as_ref(), self.index) else { + return Vec::new(); + }; + + font.axes() + .iter() + .map(|axis| FontVariationAxis { + tag: axis.tag(), + name: font + .localized_strings(axis.name_id()) + .english_or_first() + .map(|name| name.chars().collect()), + range: Rangef::new(axis.min_value(), axis.max_value()), + default: axis.default_value(), + hidden: axis.is_hidden(), + }) + .collect() + } +} + +/// A single variation axis of a variable font, e.g. weight (`wght`) or width (`wdth`). +/// +/// Obtained via [`FontData::variation_axes`]. +#[derive(Clone, Debug, PartialEq)] +pub struct FontVariationAxis { + /// The axis tag, e.g. `wght` or `wdth`. + pub tag: Tag, + + /// Human-readable axis name, if the font provides one (e.g. "Weight"). + pub name: Option, + + /// Valid range of values for this axis, `min..=max`. + pub range: Rangef, + + /// The value used when the axis is not overridden. + pub default: f32, + + /// Whether the font recommends hiding this axis from user interfaces. + pub hidden: bool, +} + +impl AsRef<[u8]> for FontData { + fn as_ref(&self) -> &[u8] { + self.font.as_ref() + } +} + +// ---------------------------------------------------------------------------- + +/// Extra scale and vertical tweak to apply to all text of a certain font. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct FontTweak { + /// Scale the font's glyphs by this much. + /// this is only a visual effect and does not affect the text layout. + /// + /// Default: `1.0` (no scaling). + pub scale: f32, + + /// Shift font's glyphs downwards by this fraction of the font size (in points). + /// this is only a visual effect and does not affect the text layout. + /// + /// Affects larger font sizes more. + /// + /// A positive value shifts the text downwards. + /// A negative value shifts it upwards. + /// + /// Example value: `-0.2`. + pub y_offset_factor: f32, + + /// Shift font's glyphs downwards by this amount of logical points. + /// this is only a visual effect and does not affect the text layout. + /// + /// Affects all font sizes equally. + /// + /// Example value: `2.0`. + pub y_offset: f32, + + /// Override the global font hinting setting for this specific font. + /// + /// `None` means use the global setting in [`TextOptions::font_hinting`]. + pub hinting: Option, + + /// How to grid-fit the glyph outlines when hinting is enabled. + /// + /// Has no effect when hinting is disabled (see [`Self::hinting`]). + pub hinting_target: HintingTarget, + + /// Override the global sub-pixel binning setting for this specific font. + /// + /// `None` means use the global setting in [`TextOptions::subpixel_binning`]. + pub subpixel_binning: Option, + + /// Override the font's default variation coordinates for its axes ("wght", etc.). + pub coords: VariationCoords, + + /// Width of a thin space (`\u{2009}`) and narrow no-break space (`\u{202F}`), + /// as a fraction of the normal space width. + /// + /// Thin space is often used as a thousands separator: `1 234 567`. + /// + /// Default: `0.5` (half a normal space). + pub thin_space_width: f32, + + /// Width of a tab character (`\t`), measured in number of space widths. + /// + /// Default: `4.0`. + pub tab_size: f32, +} + +impl Default for FontTweak { + fn default() -> Self { + Self { + scale: 1.0, + y_offset_factor: 0.0, + y_offset: 0.0, + hinting: None, + hinting_target: HintingTarget::default(), + subpixel_binning: None, + coords: VariationCoords::default(), + thin_space_width: 0.5, + tab_size: 4.0, + } + } +} + +// ---------------------------------------------------------------------------- + +/// How to *hint* glyph outlines, i.e. how aggressively to nudge them onto the +/// pixel grid before rasterizing. Mirrors [`skrifa::outline::Target`]. +/// +/// Hinting trades shape fidelity for sharpness: snapping stems to whole pixels +/// makes text crisp at small sizes / low dpi, at the cost of slightly distorting +/// the designer's outlines. At high dpi it matters little. +/// +/// This only has an effect if the font is actually hinted — either it ships +/// TrueType instructions, or it was auto-hinted (see [`FontTweak::hinting`]). +/// +/// Used by [`FontTweak::hinting_target`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum HintingTarget { + /// Strongest hinting, designed for aliased 1-bit (black & white) rendering. + /// + /// Snaps stems hard to the pixel grid for maximum sharpness. egui always + /// renders anti-aliased, so in practice this looks much like [`Self::Smooth`] + /// here; it mostly exists for completeness. Maps to `skrifa`'s `Target::Mono`. + Mono, + + /// Hinting tuned for anti-aliased rendering. This is what you normally want, + /// and what egui uses by default. Maps to `skrifa`'s `Target::Smooth`. + Smooth(SmoothHinting), +} + +impl Default for HintingTarget { + fn default() -> Self { + Self::Smooth(SmoothHinting::default()) + } +} + +impl From for skrifa::outline::Target { + fn from(hinting_target: HintingTarget) -> Self { + use skrifa::outline::SmoothMode; + match hinting_target { + HintingTarget::Mono => Self::Mono, + HintingTarget::Smooth(SmoothHinting { + light, + symmetric_rendering, + preserve_linear_metrics, + }) => Self::Smooth { + mode: if light { + SmoothMode::Light + } else { + SmoothMode::Normal + }, + symmetric_rendering, + preserve_linear_metrics, + }, + } + } +} + +/// Tuning for [`HintingTarget::Smooth`], mirroring `skrifa`'s `Target::Smooth`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct SmoothHinting { + /// Hint only lightly: snap stems vertically but leave horizontal shapes + /// alone (`FreeType`'s "light" mode). Preserves the font's proportions + /// better, at the cost of a little horizontal sharpness. + /// + /// `false` uses the "normal" mode, which also fits horizontally. + /// Maps to `SmoothMode::Light` (`true`) vs `SmoothMode::Normal` (`false`). + pub light: bool, + + /// Render a glyph the same way regardless of its sub-pixel position. + /// + /// `true` makes glyphs position-independent (better for caching and + /// animation), but a font's instructions may then widen stems and look + /// slightly blurrier under an analytic rasterizer like egui's. + /// + /// **Only affects fonts hinted via the TrueType interpreter** (i.e. fonts + /// that ship their own instructions). It has no effect on the auto-hinter. + /// Mirrors `Target::Smooth { symmetric_rendering }`. + pub symmetric_rendering: bool, + + /// Keep advance widths independent of hinting (don't grid-fit horizontally + /// in a way that changes spacing). + /// + /// `true` keeps inter-glyph spacing identical to the unhinted font, so + /// layout never depends on hinting — but it also prevents horizontal + /// grid-fitting, leaving vertical stems softer on low-dpi screens. + /// + /// `false` lets the (auto)hinter snap horizontally for crisper stems. + /// egui positions glyphs from the shaper's advances, not the hinted + /// outline, so this mainly affects sharpness here, not layout. + /// Mirrors `Target::Smooth { preserve_linear_metrics }`. + pub preserve_linear_metrics: bool, +} + +impl Default for SmoothHinting { + fn default() -> Self { + // Matches the behavior egui had before the hinting target was configurable. + // Note this means horizontal grid-fitting is opt-in (see `preserve_linear_metrics`). + Self { + light: false, + symmetric_rendering: true, + preserve_linear_metrics: true, + } + } +} + +// ---------------------------------------------------------------------------- + +pub type Blob = Arc + Send + Sync>; + +fn blob_from_font_data(data: &FontData) -> Blob { + match data.clone().font { + Cow::Borrowed(bytes) => Arc::new(bytes) as Blob, + Cow::Owned(bytes) => Arc::new(bytes) as Blob, + } +} + +/// Describes the font data and the sizes to use. +/// +/// Often you would start with [`FontDefinitions::default()`] and then add/change the contents. +/// +/// This is how you install your own custom fonts: +/// ``` +/// # use {epaint::text::{FontDefinitions, FontFamily, FontData}}; +/// # struct FakeEguiCtx {}; +/// # impl FakeEguiCtx { fn set_fonts(&self, _: FontDefinitions) {} } +/// # let egui_ctx = FakeEguiCtx {}; +/// let mut fonts = FontDefinitions::default(); +/// +/// // Install my own font (maybe supporting non-latin characters): +/// fonts.font_data.insert("my_font".to_owned(), +/// std::sync::Arc::new( +/// // .ttf and .otf supported +/// FontData::from_static(include_bytes!("../../../epaint_default_fonts/fonts/Ubuntu-Light.ttf")) +/// ) +/// ); +/// +/// // Put my font first (highest priority): +/// fonts.families.get_mut(&FontFamily::Proportional).unwrap() +/// .insert(0, "my_font".to_owned()); +/// +/// // Put my font as last fallback for monospace: +/// fonts.families.get_mut(&FontFamily::Monospace).unwrap() +/// .push("my_font".to_owned()); +/// +/// egui_ctx.set_fonts(fonts); +/// ``` +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "serde", serde(default))] +pub struct FontDefinitions { + /// List of font names and their definitions. + /// + /// `epaint` has built-in-default for these, but you can override them if you like. + pub font_data: BTreeMap>, + + /// Which fonts (names) to use for each [`FontFamily`]. + /// + /// The list should be a list of keys into [`Self::font_data`]. + /// When looking for a character glyph `epaint` will start with + /// the first font and then move to the second, and so on. + /// So the first font is the primary, and then comes a list of fallbacks in order of priority. + pub families: BTreeMap>, +} + +#[derive(Debug, Clone)] +pub struct FontInsert { + /// Font name + pub name: String, + + /// A `.ttf` or `.otf` file and a font face index. + pub data: FontData, + + /// Sets the font family and priority + pub families: Vec, +} + +#[derive(Debug, Clone)] +pub struct InsertFontFamily { + /// Font family + pub family: FontFamily, + + /// Fallback or Primary font + pub priority: FontPriority, +} + +#[derive(Debug, Clone)] +pub enum FontPriority { + /// Prefer this font before all existing ones. + /// + /// If a desired glyph exists in this font, it will be used. + Highest, + + /// Use this font as a fallback, after all existing ones. + /// + /// This font will only be used if the glyph is not found in any of the previously installed fonts. + Lowest, +} + +impl FontInsert { + pub fn new(name: &str, data: FontData, families: Vec) -> Self { + Self { + name: name.to_owned(), + data, + families, + } + } +} + +impl Default for FontDefinitions { + /// Specifies the default fonts if the feature `default_fonts` is enabled, + /// otherwise this is the same as [`Self::empty`]. + #[cfg(not(feature = "default_fonts"))] + fn default() -> Self { + Self::empty() + } + + /// Specifies the default fonts if the feature `default_fonts` is enabled, + /// otherwise this is the same as [`Self::empty`]. + #[cfg(feature = "default_fonts")] + fn default() -> Self { + let mut font_data: BTreeMap> = BTreeMap::new(); + + let mut families = BTreeMap::new(); + + font_data.insert( + "Hack".to_owned(), + Arc::new(FontData::from_static(HACK_REGULAR)), + ); + + // Some good looking emojis. Use as first priority: + font_data.insert( + "NotoEmoji-Regular".to_owned(), + Arc::new(FontData::from_static(NOTO_EMOJI_REGULAR).tweak(FontTweak { + scale: 0.81, // Make smaller + ..Default::default() + })), + ); + + font_data.insert( + "Ubuntu-Light".to_owned(), + Arc::new(FontData::from_static(UBUNTU_LIGHT)), + ); + + // Bigger emojis, and more. : + font_data.insert( + "emoji-icon-font".to_owned(), + Arc::new(FontData::from_static(EMOJI_ICON).tweak(FontTweak { + scale: 0.90, // Make smaller + ..Default::default() + })), + ); + + families.insert( + FontFamily::Monospace, + vec![ + "Hack".to_owned(), + "Ubuntu-Light".to_owned(), // fallback for √ etc + "NotoEmoji-Regular".to_owned(), + "emoji-icon-font".to_owned(), + ], + ); + families.insert( + FontFamily::Proportional, + vec![ + "Ubuntu-Light".to_owned(), + "NotoEmoji-Regular".to_owned(), + "emoji-icon-font".to_owned(), + ], + ); + + Self { + font_data, + families, + } + } +} + +impl FontDefinitions { + /// No fonts. + pub fn empty() -> Self { + let mut families = BTreeMap::new(); + families.insert(FontFamily::Monospace, vec![]); + families.insert(FontFamily::Proportional, vec![]); + + Self { + font_data: Default::default(), + families, + } + } + + /// List of all the builtin font names used by `epaint`. + #[cfg(feature = "default_fonts")] + pub fn builtin_font_names() -> &'static [&'static str] { + &[ + "Ubuntu-Light", + "NotoEmoji-Regular", + "emoji-icon-font", + "Hack", + ] + } + + /// List of all the builtin font names used by `epaint`. + #[cfg(not(feature = "default_fonts"))] + pub fn builtin_font_names() -> &'static [&'static str] { + &[] + } +} + +/// Unique ID for looking up a single font face/file. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct FontFaceKey(u64); + +impl FontFaceKey { + pub const INVALID: Self = Self(0); + + fn new() -> Self { + static KEY_COUNTER: AtomicUsize = AtomicUsize::new(1); + Self(crate::util::hash( + KEY_COUNTER.fetch_add(1, Ordering::Relaxed), + )) + } +} + +// Safe, because we hash the value in the constructor. +impl nohash_hasher::IsEnabled for FontFaceKey {} + +/// Cached data for working with a font family (e.g. doing character lookups). +#[derive(Debug)] +pub(super) struct CachedFamily { + pub fonts: Vec, + + /// Lazily calculated. + pub characters: Option>>, + + /// The face used when no face in [`Self::fonts`] supports a char. + pub replacement_face_key: FontFaceKey, + + /// The char that [`Self::replacement_face_key`] actually contains. + /// + /// When the user asks about a char that no fallback face supports we + /// render this char in its place. + pub replacement_char: char, + + /// Cache: `char → which face in the fallback chain owns this char`. + /// + /// Location-independent (fallback choice depends only on charmap support, + /// not on variation coordinates). + pub face_cache: ahash::HashMap, +} + +impl CachedFamily { + fn new( + fonts: Vec, + fonts_by_id: &mut nohash_hasher::IntMap, + ) -> Self { + const PRIMARY_REPLACEMENT_CHAR: char = '◻'; // white medium square + const FALLBACK_REPLACEMENT_CHAR: char = '?'; // fallback for the fallback + + if fonts.is_empty() { + return Self { + fonts, + characters: None, + replacement_face_key: FontFaceKey::INVALID, + replacement_char: PRIMARY_REPLACEMENT_CHAR, + face_cache: Default::default(), + }; + } + + let mut slf = Self { + fonts, + characters: None, + replacement_face_key: FontFaceKey::INVALID, + replacement_char: PRIMARY_REPLACEMENT_CHAR, + face_cache: Default::default(), + }; + + let (replacement_face_key, replacement_char) = slf + .find_face_for_char(PRIMARY_REPLACEMENT_CHAR, fonts_by_id) + .map(|key| (key, PRIMARY_REPLACEMENT_CHAR)) + .or_else(|| { + slf.find_face_for_char(FALLBACK_REPLACEMENT_CHAR, fonts_by_id) + .map(|key| (key, FALLBACK_REPLACEMENT_CHAR)) + }) + .unwrap_or_else(|| { + log::warn!( + "Failed to find replacement characters {PRIMARY_REPLACEMENT_CHAR:?} or {FALLBACK_REPLACEMENT_CHAR:?}. Will use empty glyph." + ); + (FontFaceKey::INVALID, PRIMARY_REPLACEMENT_CHAR) + }); + slf.replacement_face_key = replacement_face_key; + slf.replacement_char = replacement_char; + + slf + } + + /// Walk the fallback chain and return the first face whose charmap supports `c`. + /// + /// Pure — does not touch any cache. Callers that want memoisation should + /// insert into [`Self::face_cache`] themselves. + pub(crate) fn find_face_for_char( + &self, + c: char, + fonts_by_id: &mut nohash_hasher::IntMap, + ) -> Option { + for font_key in &self.fonts { + let font_face = fonts_by_id.get_mut(font_key).expect("Nonexistent font ID"); + if font_face.glyph_id_resolution(c).is_some() { + return Some(*font_key); + } + } + None + } +} + +// ---------------------------------------------------------------------------- + +/// The collection of fonts used by `epaint`. +/// +/// Required in order to paint text. Create one and reuse. Cheap to clone. +/// +/// Each [`Fonts`] comes with a font atlas textures that needs to be used when painting. +/// +/// If you are using `egui`, use `egui::Context::set_fonts` and `egui::Context::fonts`. +/// +/// You need to call [`Self::begin_pass`] and [`Self::font_image_delta`] once every frame. +pub struct Fonts { + pub fonts: FontsImpl, + galley_cache: GalleyCache, +} + +impl Fonts { + /// Create a new [`Fonts`] for text layout. + /// This call is expensive, so only create one [`Fonts`] and then reuse it. + pub fn new(options: TextOptions, definitions: FontDefinitions) -> Self { + Self { + fonts: FontsImpl::new(options, definitions), + galley_cache: Default::default(), + } + } + + /// Call at the start of each frame with the latest known [`TextOptions`]. + /// + /// Call after painting the previous frame, but before using [`Fonts`] for the new frame. + /// + /// This function will react to changes in [`TextOptions`], + /// as well as notice when the font atlas is getting full, and handle that. + pub fn begin_pass(&mut self, options: TextOptions) { + let text_options_changed = self.fonts.options() != &options; + let font_atlas_almost_full = self.fonts.atlas.fill_ratio() > 0.8; + let needs_recreate = text_options_changed || font_atlas_almost_full; + + if needs_recreate { + let definitions = self.fonts.definitions.clone(); + + *self = Self { + fonts: FontsImpl::new(options, definitions), + galley_cache: Default::default(), + }; + } + + self.galley_cache.flush_cache(); + } + + /// Call at the end of each frame (before painting) to get the change to the font texture since last call. + pub fn font_image_delta(&mut self) -> Option { + self.fonts.atlas.take_delta() + } + + #[inline] + pub fn options(&self) -> &TextOptions { + self.texture_atlas().options() + } + + #[inline] + pub fn definitions(&self) -> &FontDefinitions { + &self.fonts.definitions + } + + /// The font atlas. + /// Pass this to [`crate::Tessellator`]. + pub fn texture_atlas(&self) -> &TextureAtlas { + &self.fonts.atlas + } + + /// The full font atlas image. + #[inline] + pub fn image(&self) -> crate::ColorImage { + self.fonts.atlas.image().clone() + } + + /// Current size of the font image. + /// Pass this to [`crate::Tessellator`]. + pub fn font_image_size(&self) -> [usize; 2] { + self.fonts.atlas.size() + } + + /// Can we display this glyph? + pub fn has_glyph(&mut self, font_id: &FontId, c: char) -> bool { + self.fonts.font(&font_id.family).has_glyph(c) + } + + /// Can we display all the glyphs in this text? + pub fn has_glyphs(&mut self, font_id: &FontId, s: &str) -> bool { + self.fonts.font(&font_id.family).has_glyphs(s) + } + + pub fn num_galleys_in_cache(&self) -> usize { + self.galley_cache.num_galleys_in_cache() + } + + /// How full is the font atlas? + /// + /// This increases as new fonts and/or glyphs are used, + /// but can also decrease in a call to [`Self::begin_pass`]. + pub fn font_atlas_fill_ratio(&self) -> f32 { + self.fonts.atlas.fill_ratio() + } + + /// Returns a [`FontsView`] with the given `pixels_per_point` that can be used to do text layout. + pub fn with_pixels_per_point(&mut self, pixels_per_point: f32) -> FontsView<'_> { + FontsView { + fonts: &mut self.fonts, + galley_cache: &mut self.galley_cache, + pixels_per_point, + } + } +} + +// ---------------------------------------------------------------------------- + +/// The context's collection of fonts, with this context's `pixels_per_point`. This is what you use to do text layout. +pub struct FontsView<'a> { + pub fonts: &'a mut FontsImpl, + galley_cache: &'a mut GalleyCache, + pixels_per_point: f32, +} + +impl FontsView<'_> { + #[inline] + pub fn options(&self) -> &TextOptions { + self.fonts.options() + } + + #[inline] + pub fn definitions(&self) -> &FontDefinitions { + &self.fonts.definitions + } + + /// The full font atlas image. + #[inline] + pub fn image(&self) -> crate::ColorImage { + self.fonts.atlas.image().clone() + } + + /// Current size of the font image. + /// Pass this to [`crate::Tessellator`]. + pub fn font_image_size(&self) -> [usize; 2] { + self.fonts.atlas.size() + } + + /// Width of this character in points. + /// + /// If the font doesn't exist, this will return `0.0`. + pub fn glyph_width(&mut self, font_id: &FontId, c: char) -> f32 { + self.fonts + .font(&font_id.family) + .glyph_width(c, font_id.size) + } + + /// Can we display this glyph? + pub fn has_glyph(&mut self, font_id: &FontId, c: char) -> bool { + self.fonts.font(&font_id.family).has_glyph(c) + } + + /// Can we display all the glyphs in this text? + pub fn has_glyphs(&mut self, font_id: &FontId, s: &str) -> bool { + self.fonts.font(&font_id.family).has_glyphs(s) + } + + /// Height of one row of text in points. + /// + /// Returns a value rounded to [`emath::GUI_ROUNDING`]. + #[inline] + pub fn row_height(&mut self, font_id: &FontId) -> f32 { + self.fonts + .font(&font_id.family) + .styled_metrics( + self.pixels_per_point, + font_id.size, + // TODO(valadaptive): use font variation coords when calculating row height + &VariationCoords::default(), + ) + .row_height + } + + /// List of all known font families. + pub fn families(&self) -> Vec { + self.fonts.definitions.families.keys().cloned().collect() + } + + /// Layout some text. + /// + /// This is the most advanced layout function. + /// See also [`Self::layout`], [`Self::layout_no_wrap`] and + /// [`Self::layout_delayed_color`]. + /// + /// The implementation uses memoization so repeated calls are cheap. + #[inline] + pub fn layout_job(&mut self, job: LayoutJob) -> Arc { + let allow_split_paragraphs = true; // Optimization for editing text with many paragraphs. + self.galley_cache.layout( + self.fonts, + self.pixels_per_point, + job, + allow_split_paragraphs, + ) + } + + pub fn num_galleys_in_cache(&self) -> usize { + self.galley_cache.num_galleys_in_cache() + } + + /// How full is the font atlas? + /// + /// This increases as new fonts and/or glyphs are used, + /// but can also decrease in a call to [`Fonts::begin_pass`]. + pub fn font_atlas_fill_ratio(&self) -> f32 { + self.fonts.atlas.fill_ratio() + } + + /// Will wrap text at the given width and line break at `\n`. + /// + /// The implementation uses memoization so repeated calls are cheap. + #[inline] + pub fn layout( + &mut self, + text: String, + font_id: FontId, + color: crate::Color32, + wrap_width: f32, + ) -> Arc { + let job = LayoutJob::simple(text, font_id, color, wrap_width); + self.layout_job(job) + } + + /// Will line break at `\n`. + /// + /// The implementation uses memoization so repeated calls are cheap. + #[inline] + pub fn layout_no_wrap( + &mut self, + text: String, + font_id: FontId, + color: crate::Color32, + ) -> Arc { + let job = LayoutJob::simple(text, font_id, color, f32::INFINITY); + self.layout_job(job) + } + + /// Like [`Self::layout`], made for when you want to pick a color for the text later. + /// + /// The implementation uses memoization so repeated calls are cheap. + #[inline] + pub fn layout_delayed_color( + &mut self, + text: String, + font_id: FontId, + wrap_width: f32, + ) -> Arc { + self.layout(text, font_id, crate::Color32::PLACEHOLDER, wrap_width) + } +} + +// ---------------------------------------------------------------------------- + +/// The collection of fonts used by `epaint`. +/// +/// Required in order to paint text. +pub struct FontsImpl { + definitions: FontDefinitions, + atlas: TextureAtlas, + fonts_by_id: nohash_hasher::IntMap, + fonts_by_name: ahash::HashMap, + family_cache: ahash::HashMap, + + /// Recycled `harfrust` shaping buffer to avoid per-layout allocations. + shape_buffer: Option, +} + +impl FontsImpl { + /// Create a new [`FontsImpl`] for text layout. + /// This call is expensive, so only create one [`FontsImpl`] and then reuse it. + pub fn new(options: TextOptions, definitions: FontDefinitions) -> Self { + let texture_width = options.max_texture_side.at_most(16 * 1024); + let initial_height = 32; // Keep initial font atlas small, so it is fast to upload to GPU. This will expand as needed anyways. + let atlas = TextureAtlas::new([texture_width, initial_height], options); + + let mut fonts_by_id: nohash_hasher::IntMap = Default::default(); + let mut fonts_by_name: ahash::HashMap = Default::default(); + for (name, font_data) in &definitions.font_data { + let blob = blob_from_font_data(font_data); + let font_face = FontFace::new( + options, + name.clone(), + blob, + font_data.index, + font_data.tweak.clone(), + ) + .unwrap_or_else(|err| panic!("Error parsing {name:?} TTF/OTF font file: {err}")) + // accshift patch + .with_rasterizer(font_data.rasterizer.clone()); + let key = FontFaceKey::new(); + fonts_by_id.insert(key, font_face); + fonts_by_name.insert(name.clone(), key); + } + + Self { + definitions, + atlas, + fonts_by_id, + fonts_by_name, + family_cache: Default::default(), + shape_buffer: Some(harfrust::UnicodeBuffer::new()), + } + } + + pub fn options(&self) -> &TextOptions { + self.atlas.options() + } + + /// Take the recycled shaping buffer (or create a new one if already taken). + pub fn take_shape_buffer(&mut self) -> harfrust::UnicodeBuffer { + self.shape_buffer.take().unwrap_or_default() + } + + /// Return a shaping buffer for reuse. + pub fn return_shape_buffer(&mut self, buffer: harfrust::UnicodeBuffer) { + self.shape_buffer = Some(buffer); + } + + /// Get the right font implementation from [`FontFamily`]. + pub fn font(&mut self, family: &FontFamily) -> Font<'_> { + let cached_family = self.family_cache.entry(family.clone()).or_insert_with(|| { + let fonts = &self.definitions.families.get(family); + let fonts = + fonts.unwrap_or_else(|| panic!("FontFamily::{family:?} is not bound to any fonts")); + + let fonts: Vec = fonts + .iter() + .map(|font_name| { + *self + .fonts_by_name + .get(font_name) + .unwrap_or_else(|| panic!("No font data found for {font_name:?}")) + }) + .collect(); + + CachedFamily::new(fonts, &mut self.fonts_by_id) + }); + Font { + fonts_by_id: &mut self.fonts_by_id, + cached_family, + atlas: &mut self.atlas, + } + } +} + +// ---------------------------------------------------------------------------- + +struct CachedGalley { + /// When it was last used + last_used: u32, + + /// Hashes of all other entries this one depends on for quick re-layout. + /// Their `last_used`s should be updated alongside this one to make sure they're + /// not evicted. + children: Option>, + + galley: Arc, +} + +#[derive(Default)] +struct GalleyCache { + /// Frame counter used to do garbage collection on the cache + generation: u32, + cache: nohash_hasher::IntMap, +} + +impl GalleyCache { + fn layout_internal( + &mut self, + fonts: &mut FontsImpl, + mut job: LayoutJob, + pixels_per_point: f32, + allow_split_paragraphs: bool, + ) -> (u64, Arc) { + if job.wrap.max_width.is_finite() { + // Protect against rounding errors in egui layout code. + + // Say the user asks to wrap at width 200.0. + // The text layout wraps, and reports that the final width was 196.0 points. + // This then trickles up the `Ui` chain and gets stored as the width for a tooltip (say). + // On the next frame, this is then set as the max width for the tooltip, + // and we end up calling the text layout code again, this time with a wrap width of 196.0. + // Except, somewhere in the `Ui` chain with added margins etc, a rounding error was introduced, + // so that we actually set a wrap-width of 195.9997 instead. + // Now the text that fit perfrectly at 196.0 needs to wrap one word earlier, + // and so the text re-wraps and reports a new width of 185.0 points. + // And then the cycle continues. + + // So we limit max_width to integers. + + // Related issues: + // * https://github.com/emilk/egui/issues/4927 + // * https://github.com/emilk/egui/issues/4928 + // * https://github.com/emilk/egui/issues/5084 + // * https://github.com/emilk/egui/issues/5163 + + job.wrap.max_width = job.wrap.max_width.round(); + } + + let hash = crate::util::hash((&job, OrderedFloat(pixels_per_point))); // TODO(emilk): even faster hasher? + + let galley = match self.cache.entry(hash) { + std::collections::hash_map::Entry::Occupied(entry) => { + // The job was found in cache - no need to re-layout. + let cached = entry.into_mut(); + cached.last_used = self.generation; + + let galley = Arc::clone(&cached.galley); + if let Some(children) = &cached.children { + // The point of `allow_split_paragraphs` is to split large jobs into paragraph, + // and then cache each paragraph individually. + // That way, if we edit a single paragraph, only that paragraph will be re-layouted. + // For that to work we need to keep all the child/paragraph + // galleys alive while the parent galley is alive: + for child_hash in Arc::clone(children).iter() { + if let Some(cached_child) = self.cache.get_mut(child_hash) { + cached_child.last_used = self.generation; + } + } + } + + galley + } + std::collections::hash_map::Entry::Vacant(entry) => { + let job = Arc::new(job); + if allow_split_paragraphs && should_cache_each_paragraph_individually(&job) { + let (child_galleys, child_hashes) = + self.layout_each_paragraph_individually(fonts, &job, pixels_per_point); + debug_assert_eq!( + child_hashes.len(), + child_galleys.len(), + "Bug in `layout_each_paragraph_individually`" + ); + let galley = Arc::new(Galley::concat(job, &child_galleys, pixels_per_point)); + + self.cache.insert( + hash, + CachedGalley { + last_used: self.generation, + children: Some(child_hashes.into()), + galley: Arc::clone(&galley), + }, + ); + galley + } else { + let galley = super::layout(fonts, pixels_per_point, job); + let galley = Arc::new(galley); + entry.insert(CachedGalley { + last_used: self.generation, + children: None, + galley: Arc::clone(&galley), + }); + galley + } + } + }; + + (hash, galley) + } + + fn layout( + &mut self, + fonts: &mut FontsImpl, + pixels_per_point: f32, + job: LayoutJob, + allow_split_paragraphs: bool, + ) -> Arc { + self.layout_internal(fonts, job, pixels_per_point, allow_split_paragraphs) + .1 + } + + /// Split on `\n` and lay out (and cache) each paragraph individually. + fn layout_each_paragraph_individually( + &mut self, + fonts: &mut FontsImpl, + job: &LayoutJob, + pixels_per_point: f32, + ) -> (Vec>, Vec) { + profiling::function_scope!(); + + let mut current_section = 0; + let mut start = 0; + let mut max_rows_remaining = job.wrap.max_rows; + let mut child_galleys = Vec::new(); + let mut child_hashes = Vec::new(); + + while start < job.text.len() { + let is_first_paragraph = start == 0; + // `end` will not include the `\n` since we don't want to create an empty row in our + // split galley + let mut end = job.text[start..] + .find('\n') + .map_or(job.text.len(), |i| start + i); + if end == job.text.len() - 1 && job.text.ends_with('\n') { + end += 1; // If the text ends with a newline, we include it in the last paragraph. + } + + let mut paragraph_job = LayoutJob { + text: job.text[start..end].to_owned(), + wrap: crate::text::TextWrapping { + max_rows: max_rows_remaining, + ..job.wrap + }, + sections: Vec::new(), + break_on_newline: job.break_on_newline, + halign: job.halign, + justify: job.justify, + first_row_min_height: if is_first_paragraph { + job.first_row_min_height + } else { + 0.0 + }, + round_output_to_gui: job.round_output_to_gui, + keep_trailing_whitespace: job.keep_trailing_whitespace, + }; + + // Add overlapping sections: + for section in &job.sections[current_section..job.sections.len()] { + let LayoutSection { + leading_space, + byte_range: section_range, + format, + } = section; + + // `start` and `end` are the byte range of the current paragraph. + // How does the current section overlap with the paragraph range? + + if section_range.end <= ByteIndex(start) { + // The section is behind us + current_section += 1; + } else if ByteIndex(end) < section_range.start { + break; // Haven't reached this one yet. + } else { + // Section range overlaps with paragraph range + debug_assert!( + section_range.start <= section_range.end, + "Bad byte_range: {section_range:?}" + ); + let new_range = section_range.start.saturating_sub(start) + ..(section_range.end.min(ByteIndex(end))).saturating_sub(start); + debug_assert!( + new_range.start <= new_range.end, + "Bad new section range: {new_range:?}" + ); + paragraph_job.sections.push(LayoutSection { + leading_space: if ByteIndex(start) <= section_range.start { + *leading_space + } else { + 0.0 + }, + byte_range: new_range, + format: format.clone(), + }); + } + } + + // TODO(emilk): we could lay out each paragraph in parallel to get a nice speedup on multicore machines. + let (hash, galley) = + self.layout_internal(fonts, paragraph_job, pixels_per_point, false); + child_hashes.push(hash); + + // This will prevent us from invalidating cache entries unnecessarily: + if max_rows_remaining != usize::MAX { + max_rows_remaining -= galley.rows.len(); + } + + let elided = galley.elided; + child_galleys.push(galley); + if elided { + break; + } + + start = end + 1; + } + + (child_galleys, child_hashes) + } + + pub fn num_galleys_in_cache(&self) -> usize { + self.cache.len() + } + + /// Must be called once per frame to clear the [`Galley`] cache. + pub fn flush_cache(&mut self) { + let current_generation = self.generation; + self.cache.retain(|_key, cached| { + cached.last_used == current_generation // only keep those that were used this frame + }); + self.generation = self.generation.wrapping_add(1); + } +} + +/// If true, lay out and cache each paragraph (sections separated by newlines) individually. +/// +/// This makes it much faster to re-layout the full text when only a portion of it has changed since last frame, i.e. when editing somewhere in a file with thousands of lines/paragraphs. +fn should_cache_each_paragraph_individually(job: &LayoutJob) -> bool { + // We currently don't support this elided text, i.e. when `max_rows` is set. + // Most often, elided text is elided to one row, + // and so will always be fast to lay out. + job.break_on_newline && job.wrap.max_rows == usize::MAX && job.text.contains('\n') +} + +#[cfg(feature = "default_fonts")] +#[cfg(test)] +mod tests { + use core::f32; + + use super::*; + use crate::text::{TextWrapping, layout}; + use crate::{Stroke, text::TextFormat}; + use ecolor::Color32; + use emath::Align; + + fn jobs() -> Vec { + vec![ + LayoutJob::simple( + String::default(), + FontId::new(14.0, FontFamily::Monospace), + Color32::WHITE, + f32::INFINITY, + ), + LayoutJob::simple( + "ends with newlines\n\n".to_owned(), + FontId::new(14.0, FontFamily::Monospace), + Color32::WHITE, + f32::INFINITY, + ), + LayoutJob::simple( + "Simple test.".to_owned(), + FontId::new(14.0, FontFamily::Monospace), + Color32::WHITE, + f32::INFINITY, + ), + { + let mut job = LayoutJob::simple( + "hi".to_owned(), + FontId::default(), + Color32::WHITE, + f32::INFINITY, + ); + job.append("\n", 0.0, TextFormat::default()); + job.append("\n", 0.0, TextFormat::default()); + job.append("world", 0.0, TextFormat::default()); + job.wrap.max_rows = 2; + job + }, + { + let mut job = LayoutJob::simple( + "Test text with a lot of words\n and a newline.".to_owned(), + FontId::new(14.0, FontFamily::Monospace), + Color32::WHITE, + 40.0, + ); + job.first_row_min_height = 30.0; + job + }, + LayoutJob::simple( + "This some text that may be long.\nDet kanske också finns lite ÅÄÖ här.".to_owned(), + FontId::new(14.0, FontFamily::Proportional), + Color32::WHITE, + 50.0, + ), + { + let mut job = LayoutJob { + first_row_min_height: 20.0, + ..Default::default() + }; + job.append( + "1st paragraph has underline and strikethrough, and has some non-ASCII characters:\n ÅÄÖ.", + 0.0, + TextFormat { + font_id: FontId::new(15.0, FontFamily::Monospace), + underline: Stroke::new(1.0, Color32::RED), + strikethrough: Stroke::new(1.0, Color32::GREEN), + ..Default::default() + }, + ); + job.append( + "2nd paragraph has some leading space.\n", + 16.0, + TextFormat { + font_id: FontId::new(14.0, FontFamily::Proportional), + ..Default::default() + }, + ); + job.append( + "3rd paragraph is kind of boring, but has italics.\nAnd a newline", + 0.0, + TextFormat { + font_id: FontId::new(10.0, FontFamily::Proportional), + italics: true, + ..Default::default() + }, + ); + + job + }, + { + // Regression test for + let mut job = LayoutJob::default(); + job.append("\n", 0.0, TextFormat::default()); + job.append("", 0.0, TextFormat::default()); + job + }, + ] + } + + #[expect(clippy::print_stdout)] + #[test] + fn test_split_paragraphs() { + for pixels_per_point in [1.0, 2.0_f32.sqrt(), 2.0] { + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + + for halign in [Align::Min, Align::Center, Align::Max] { + for justify in [false, true] { + for mut job in jobs() { + job.halign = halign; + job.justify = justify; + + let whole = GalleyCache::default().layout( + &mut fonts, + pixels_per_point, + job.clone(), + false, + ); + + let split = GalleyCache::default().layout( + &mut fonts, + pixels_per_point, + job.clone(), + true, + ); + + for (i, row) in whole.rows.iter().enumerate() { + println!( + "Whole row {i}: section_index_at_start={}, first glyph section_index: {:?}", + row.row.section_index_at_start, + row.row.glyphs.first().map(|g| g.section_index) + ); + } + for (i, row) in split.rows.iter().enumerate() { + println!( + "Split row {i}: section_index_at_start={}, first glyph section_index: {:?}", + row.row.section_index_at_start, + row.row.glyphs.first().map(|g| g.section_index) + ); + } + + // Don't compare for equaliity; but format with a specific precision and make sure we hit that. + // NOTE: we use a rather low precision, because as long as we're within a pixel I think it's good enough. + similar_asserts::assert_eq!( + format!("{:#.1?}", split), + format!("{:#.1?}", whole), + "pixels_per_point: {pixels_per_point:.2}, input text: '{}'", + job.text + ); + } + } + } + } + } + + #[test] + fn test_intrinsic_size() { + let pixels_per_point = [1.0, 1.3, 2.0, 0.867]; + let max_widths = [40.0, 80.0, 133.0, 200.0]; + let rounded_output_to_gui = [false, true]; + + for pixels_per_point in pixels_per_point { + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + + for &max_width in &max_widths { + for round_output_to_gui in rounded_output_to_gui { + for mut job in jobs() { + job.wrap = TextWrapping::wrap_at_width(max_width); + + job.round_output_to_gui = round_output_to_gui; + + let galley_wrapped = + layout(&mut fonts, pixels_per_point, job.clone().into()); + + job.wrap = TextWrapping::no_max_width(); + + let text = job.text.clone(); + let galley_unwrapped = layout(&mut fonts, pixels_per_point, job.into()); + + let intrinsic_size = galley_wrapped.intrinsic_size(); + let unwrapped_size = galley_unwrapped.size(); + + let difference = (intrinsic_size - unwrapped_size).length().abs(); + similar_asserts::assert_eq!( + format!("{intrinsic_size:.4?}"), + format!("{unwrapped_size:.4?}"), + "Wrapped intrinsic size should almost match unwrapped size. Intrinsic: {intrinsic_size:.8?} vs unwrapped: {unwrapped_size:.8?} + Difference: {difference:.8?} + wrapped rows: {}, unwrapped rows: {} + pixels_per_point: {pixels_per_point}, text: {text:?}, max_width: {max_width}, round_output_to_gui: {round_output_to_gui}", + galley_wrapped.rows.len(), + galley_unwrapped.rows.len() + ); + similar_asserts::assert_eq!( + format!("{intrinsic_size:.4?}"), + format!("{unwrapped_size:.4?}"), + "Unwrapped galley intrinsic size should exactly match its size. \ + {:.8?} vs {:8?}", + galley_unwrapped.intrinsic_size(), + galley_unwrapped.size(), + ); + } + } + } + } + } + + #[test] + fn test_fallback_glyph_width() { + let mut fonts = Fonts::new(TextOptions::default(), FontDefinitions::empty()); + let mut view = fonts.with_pixels_per_point(1.0); + + let width = view.glyph_width(&FontId::new(12.0, FontFamily::Proportional), ' '); + assert_eq!(width, 0.0); + } +} diff --git a/crates/accshift-egui/vendor/epaint/src/text/index.rs b/crates/accshift-egui/vendor/epaint/src/text/index.rs new file mode 100644 index 0000000..0f14507 --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/text/index.rs @@ -0,0 +1,202 @@ +//! Strongly-typed offsets into text. +//! +//! UTF-8 text can be indexed either by _byte_ offset or by _character_ +//! (Unicode scalar) offset. Mixing the two is a common source of bugs, +//! so we use distinct types to keep them apart. + +use core::ops::Range; + +/// A byte offset into a UTF-8 string. +/// +/// This is what you use to slice a [`str`] (e.g. `&text[range.start.0..range.end.0]`). +/// Not to be confused with [`CharIndex`], which counts characters instead of bytes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + feature = "serde", + derive(serde::Deserialize, serde::Serialize), + serde(transparent) +)] +pub struct ByteIndex(pub usize); + +/// A character (Unicode scalar) offset into a string. +/// +/// Counts characters, not bytes, so it is independent of the UTF-8 encoding. +/// Not to be confused with [`ByteIndex`]. See also [`super::cursor::CCursor`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr( + feature = "serde", + derive(serde::Deserialize, serde::Serialize), + serde(transparent) +)] +pub struct CharIndex(pub usize); + +macro_rules! impl_text_index { + ($Type:ident) => { + impl $Type { + /// The zero offset, i.e. the very start of the text. + pub const ZERO: Self = Self(0); + + /// Saturating integer addition. + #[inline] + pub fn saturating_add(self, rhs: usize) -> Self { + Self(self.0.saturating_add(rhs)) + } + + /// Saturating integer subtraction. + #[inline] + pub fn saturating_sub(self, rhs: usize) -> Self { + Self(self.0.saturating_sub(rhs)) + } + } + + impl From for $Type { + #[inline] + fn from(index: usize) -> Self { + Self(index) + } + } + + impl From<$Type> for usize { + #[inline] + fn from(index: $Type) -> Self { + index.0 + } + } + + impl core::ops::Add for $Type { + type Output = Self; + + #[inline] + fn add(self, rhs: usize) -> Self { + Self(self.0 + rhs) + } + } + + /// Compose offsets, e.g. a base position plus a relative one. + impl core::ops::Add<$Type> for $Type { + type Output = Self; + + #[inline] + fn add(self, rhs: Self) -> Self { + Self(self.0 + rhs.0) + } + } + + impl core::ops::Sub for $Type { + type Output = Self; + + #[inline] + fn sub(self, rhs: usize) -> Self { + Self(self.0 - rhs) + } + } + + impl core::ops::Sub<$Type> for $Type { + type Output = Self; + + #[inline] + fn sub(self, rhs: Self) -> Self { + Self(self.0 - rhs.0) + } + } + + impl core::ops::AddAssign for $Type { + #[inline] + fn add_assign(&mut self, rhs: usize) { + self.0 += rhs; + } + } + + impl core::ops::AddAssign<$Type> for $Type { + #[inline] + fn add_assign(&mut self, rhs: Self) { + self.0 += rhs.0; + } + } + + impl core::ops::SubAssign for $Type { + #[inline] + fn sub_assign(&mut self, rhs: usize) { + self.0 -= rhs; + } + } + + impl core::fmt::Display for $Type { + #[inline] + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + self.0.fmt(f) + } + } + }; +} + +impl_text_index!(ByteIndex); +impl_text_index!(CharIndex); + +/// A range of [`ByteIndex`], i.e. a byte range into a [`str`]. +pub type ByteRange = Range; + +/// A range of [`CharIndex`], i.e. a character range into a [`str`]. +pub type CharRange = Range; + +/// Extension methods for a [`ByteRange`]. +pub trait ByteRangeExt { + /// The full byte range covering `text`, i.e. `0..text.len()`. + fn full(text: &str) -> Self; + + /// The `start..end` byte range as plain `usize`, for slicing a [`str`]. + fn as_usize(&self) -> Range; + + /// Slice the given string by this byte range. + fn slice<'s>(&self, text: &'s str) -> &'s str; +} + +impl ByteRangeExt for ByteRange { + #[inline] + fn full(text: &str) -> Self { + ByteIndex::ZERO..ByteIndex(text.len()) + } + + #[inline] + fn as_usize(&self) -> Range { + self.start.0..self.end.0 + } + + #[inline] + fn slice<'s>(&self, text: &'s str) -> &'s str { + &text[self.as_usize()] + } +} + +/// Extension methods for a [`CharRange`]. +pub trait CharRangeExt { + /// The full character range covering `text`, i.e. `0..text.chars().count()`. + fn full(text: &str) -> Self; +} + +impl CharRangeExt for CharRange { + #[inline] + fn full(text: &str) -> Self { + CharIndex::ZERO..CharIndex(text.chars().count()) + } +} + +#[cfg(test)] +mod tests { + use super::CharIndex; + + #[test] + fn arithmetic() { + // Add a relative offset to a base position: + assert_eq!(CharIndex(2) + CharIndex(3), CharIndex(5)); + assert_eq!(CharIndex(2) + 3, CharIndex(5)); + + let mut idx = CharIndex(2); + idx += CharIndex(3); + assert_eq!(idx, CharIndex(5)); + + // Subtract a relative offset from a position: + assert_eq!(CharIndex(5) - CharIndex(2), CharIndex(3)); + assert_eq!(CharIndex(5) - 2, CharIndex(3)); + } +} diff --git a/crates/accshift-egui/vendor/epaint/src/text/mod.rs b/crates/accshift-egui/vendor/epaint/src/text/mod.rs new file mode 100644 index 0000000..4d8c6fb --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/text/mod.rs @@ -0,0 +1,67 @@ +//! Everything related to text, fonts, text layout, cursors etc. + +pub mod cursor; +mod font; +mod fonts; +mod index; +mod text_layout; +mod text_layout_types; + +pub use { + // accshift patch: the glyph rasterizer hook. + font::{GlyphImage, GlyphRasterizer, GlyphRasterizerRef, GlyphRequest}, + fonts::{ + FontData, FontDefinitions, FontFamily, FontId, FontInsert, FontPriority, FontTweak, + FontVariationAxis, Fonts, FontsImpl, FontsView, HintingTarget, InsertFontFamily, + SmoothHinting, + }, + index::{ByteIndex, ByteRange, ByteRangeExt, CharIndex, CharRange, CharRangeExt}, + text_layout::*, + text_layout_types::*, +}; + +/// Suggested character to use to replace those in password text fields. +pub const PASSWORD_REPLACEMENT_CHAR: char = '•'; + +/// Controls how we render text +#[derive(Clone, Copy, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct TextOptions { + /// Maximum size of the font texture. + pub max_texture_side: usize, + + /// Controls how to convert glyph colors when writing to the font atlas. + pub color_transfer_function: crate::FontColorTransferFunction, + + /// Whether to enable font hinting + /// + /// (round some font coordinates to pixels for sharper text). + /// + /// Default is `true`. + pub font_hinting: bool, + + /// Enable sub-pixel binning for glyphs. + /// + /// Sub-pixel binning renders each glyph at up to four fractional horizontal offsets, + /// giving more even kerning at the cost of more atlas space. + /// + /// It also lead to text looking more blurry. + /// + /// This is always disabled for CJK characters (which have too many unique glyphs). + /// + /// Can be overridden per font with [`FontTweak::subpixel_binning`]. + /// + /// Default: `true`. + pub subpixel_binning: bool, +} + +impl Default for TextOptions { + fn default() -> Self { + Self { + max_texture_side: 2048, // Small but portable + color_transfer_function: crate::FontColorTransferFunction::default(), + font_hinting: true, + subpixel_binning: true, + } + } +} diff --git a/crates/accshift-egui/vendor/epaint/src/text/text_layout.rs b/crates/accshift-egui/vendor/epaint/src/text/text_layout.rs new file mode 100644 index 0000000..06ddc3b --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/text/text_layout.rs @@ -0,0 +1,1938 @@ +#![expect(clippy::unwrap_used)] // TODO(emilk): remove unwraps + +use core::{iter, ops::Range}; +use std::sync::Arc; + +use emath::{Align, GuiRounding as _, NumExt as _, Pos2, Rect, Vec2, pos2, vec2}; + +use crate::{ + Color32, Mesh, Stroke, Vertex, + stroke::PathStroke, + text::{ + ByteIndex, ByteRange, + font::{StyledMetrics, UvRect, is_cjk, is_cjk_break_allowed}, + fonts::FontFaceKey, + }, +}; + +use super::{ + ByteRangeExt as _, FontsImpl, Galley, Glyph, LayoutJob, LayoutSection, PlacedRow, Row, + RowVisuals, VariationCoords, + font::{Font, FontFace, ShapedGlyph}, +}; + +// ---------------------------------------------------------------------------- + +/// Returns `true` if the character is a Unicode combining mark (categories Mn, Mc, Me). +/// +/// These characters modify the preceding base character and should not be +/// rendered as standalone replacement glyphs when the shaper can't handle them. +#[inline] +fn is_combining_mark(c: char) -> bool { + use unicode_general_category::{GeneralCategory, get_general_category}; + matches!( + get_general_category(c), + GeneralCategory::NonspacingMark + | GeneralCategory::SpacingMark + | GeneralCategory::EnclosingMark + ) +} + +/// Represents GUI scale and convenience methods for rounding to pixels. +#[derive(Clone, Copy)] +struct PointScale { + pub pixels_per_point: f32, +} + +impl PointScale { + #[inline(always)] + pub fn new(pixels_per_point: f32) -> Self { + Self { pixels_per_point } + } + + #[inline(always)] + pub fn pixels_per_point(&self) -> f32 { + self.pixels_per_point + } + + #[inline(always)] + pub fn round_to_pixel(&self, point: f32) -> f32 { + (point * self.pixels_per_point).round() / self.pixels_per_point + } + + #[inline(always)] + pub fn floor_to_pixel(&self, point: f32) -> f32 { + (point * self.pixels_per_point).floor() / self.pixels_per_point + } +} + +// ---------------------------------------------------------------------------- + +/// Temporary storage before line-wrapping. +#[derive(Clone)] +struct Paragraph { + /// Start of the next glyph to be added. In screen-space / physical pixels. + pub cursor_x_px: f32, + + /// This is included in case there are no glyphs + pub section_index_at_start: u32, + + pub glyphs: Vec, + + /// In case of an empty paragraph ("\n"), use this as height. + pub empty_paragraph_height: f32, +} + +impl Paragraph { + pub fn from_section_index(section_index_at_start: u32) -> Self { + Self { + cursor_x_px: 0.0, + section_index_at_start, + glyphs: vec![], + empty_paragraph_height: 0.0, + } + } +} + +/// Layout text into a [`Galley`]. +/// +/// In most cases you should use [`crate::FontsView::layout_job`] instead +/// since that memoizes the input, making subsequent layouting of the same text much faster. +pub fn layout(fonts: &mut FontsImpl, pixels_per_point: f32, job: Arc) -> Galley { + profiling::function_scope!(); + + job.debug_sanity_check(); + + if job.wrap.max_rows == 0 { + // Early-out: no text + return Galley { + job, + rows: Default::default(), + rect: Rect::ZERO, + mesh_bounds: Rect::NOTHING, + num_vertices: 0, + num_indices: 0, + pixels_per_point, + elided: true, + intrinsic_size: Vec2::ZERO, + }; + } + + // For most of this we ignore the y coordinate: + + let mut paragraphs = vec![Paragraph::from_section_index(0)]; + { + let mut shape_buffer = fonts.take_shape_buffer(); + for (section_index, section) in job.sections.iter().enumerate() { + let mut font = fonts.font(§ion.format.font_id.family); + shape_buffer = layout_section( + &mut font, + shape_buffer, + pixels_per_point, + &job, + section_index as u32, + section, + &mut paragraphs, + ); + } + fonts.return_shape_buffer(shape_buffer); + } + + let point_scale = PointScale::new(pixels_per_point); + + let intrinsic_size = calculate_intrinsic_size(point_scale, &job, ¶graphs); + + let mut elided = false; + let mut rows = rows_from_paragraphs(paragraphs, &job, pixels_per_point, &mut elided); + if elided && let Some(last_placed) = rows.last_mut() { + let last_row = Arc::make_mut(&mut last_placed.row); + replace_last_glyph_with_overflow_character(fonts, pixels_per_point, &job, last_row); + if let Some(last) = last_row.glyphs.last() { + last_row.size.x = last.max_x(); + } + } + + let justify = job.justify && job.wrap.max_width.is_finite(); + + if justify || job.halign != Align::LEFT { + let num_rows = rows.len(); + for (i, placed_row) in rows.iter_mut().enumerate() { + let is_last_row = i + 1 == num_rows; + let justify_row = justify && !placed_row.ends_with_newline && !is_last_row; + halign_and_justify_row( + point_scale, + placed_row, + job.halign, + job.wrap.max_width, + justify_row, + job.keep_trailing_whitespace, + ); + } + } + + // Calculate the Y positions and tessellate the text: + galley_from_rows(point_scale, job, rows, elided, intrinsic_size) +} + +/// Shared context for emitting shaped glyphs into a [`Paragraph`]. +struct ShapingContext { + pixels_per_point: f32, + font_size: f32, + line_height: f32, + extra_letter_spacing: f32, + section_index: u32, + font_metrics: StyledMetrics, + is_first_glyph_in_section: bool, + prev_cluster: Option, +} + +impl ShapingContext { + fn glyph( + &self, + chr: char, + physical_x: i32, + advance_width_px: f32, + face_metrics: &StyledMetrics, + uv_rect: UvRect, + ) -> Glyph { + Glyph { + chr, + pos: pos2(physical_x as f32 / self.pixels_per_point, f32::NAN), + advance_width: advance_width_px / self.pixels_per_point, + line_height: self.line_height, + font_face_height: face_metrics.row_height, + font_face_ascent: face_metrics.ascent, + font_height: self.font_metrics.row_height, + font_ascent: self.font_metrics.ascent, + uv_rect, + section_index: self.section_index, + first_vertex: 0, + } + } +} + +/// Produced by [`segment_into_runs`] for text shaping. +#[derive(Debug)] +struct TextRun { + /// Which font face should shape this run. + font_key: FontFaceKey, + + /// Byte range within the section text. + byte_range: ByteRange, +} + +/// Emit shaped glyphs from a [`harfrust::GlyphBuffer`] into a [`Paragraph`]. +/// +/// When a cluster maps multiple characters to fewer glyphs (e.g. flag emojis, +/// ligatures), zero-width "continuation" glyphs are emitted for the extra +/// characters so that `glyphs.len() == char_count` — an invariant that all +/// cursor and selection code relies on. +fn layout_shaped_run( + font: &mut Font<'_>, + run: &TextRun, + run_text: &str, + glyph_buffer: &harfrust::GlyphBuffer, + face_metrics: &StyledMetrics, + ctx: &mut ShapingContext, + paragraph: &mut Paragraph, +) { + let px_scale = face_metrics.px_scale_factor; + + // Reset cluster tracking — cluster values are byte offsets within run_text, + // so they are not comparable across runs. + ctx.prev_cluster = None; + + // Track how many glyphs we emit per cluster so we can add zero-width + // continuation glyphs when a cluster has more chars than glyphs. + let mut cluster_start_byte: usize = 0; + let mut cluster_glyph_count: usize = 0; + + for (info, pos) in iter::zip(glyph_buffer.glyph_infos(), glyph_buffer.glyph_positions()) { + let glyph_id = skrifa::GlyphId::new(info.glyph_id); + let cluster = info.cluster; + let mut advance_width_px = pos.x_advance as f32 * px_scale; + let x_offset_px = pos.x_offset as f32 * px_scale; + let y_offset_px = -(pos.y_offset as f32 * px_scale); // harfrust Y+ up → screen Y+ down + + let chr = run_text + .get(cluster as usize..) + .and_then(|s| s.chars().next()) + .unwrap_or('\u{FFFD}'); // Unicode Replacement Character + + // Tab is a layout concept, not a glyph — the shaper doesn't know about tab stops. + // Override the advance width using the font's configured tab size. + if chr == '\t' { + let tweak = font.fonts_by_id.get(&run.font_key).map(|ff| ff.tweak()); + let tab_size = tweak.map_or(4.0, |t| t.tab_size); + let (_, space_info) = font.glyph_info(' ', face_metrics); + let space_width_px = space_info.advance_width_unscaled.0 * px_scale; + advance_width_px = tab_size * space_width_px; + } + + // Thin space (U+2009) and narrow no-break space (U+202F): + // override the shaper's advance width with the configured fraction of a space. + if chr == '\u{2009}' || chr == '\u{202F}' { + let tweak = font.fonts_by_id.get(&run.font_key).map(|ff| ff.tweak()); + let thin_space_width = tweak.map_or(0.5, |t| t.thin_space_width); + let (_, space_info) = font.glyph_info(' ', face_metrics); + let space_width_px = space_info.advance_width_unscaled.0 * px_scale; + advance_width_px = thin_space_width * space_width_px; + } + + // Apply extra_letter_spacing only at cluster boundaries, + // never between glyphs within the same cluster (e.g. base + mark). + let is_new_cluster = ctx.prev_cluster.is_none_or(|pc| pc != cluster); + if is_new_cluster { + if ctx.prev_cluster.is_some() { + emit_continuation_glyphs( + ctx, + paragraph, + run_text, + cluster_start_byte..cluster as usize, + cluster_glyph_count, + face_metrics, + ); + } + if !ctx.is_first_glyph_in_section { + paragraph.cursor_x_px += ctx.extra_letter_spacing * ctx.pixels_per_point; + } + cluster_start_byte = cluster as usize; + cluster_glyph_count = 0; + ctx.is_first_glyph_in_section = false; + } + ctx.prev_cluster = Some(cluster); + + let glyph = if glyph_id == skrifa::GlyphId::NOTDEF { + // The shaper couldn't map this character. Drop combining marks + // (Unicode category M) and duplicate NOTDEF glyphs within the same + // cluster — only the first base character gets a replacement glyph. + if is_combining_mark(chr) || !is_new_cluster { + continue; + } + + // Use the fallback font face (not run.font_key which returned NOTDEF). + let fallback_key = font.resolve_face(chr); + let fallback_metrics = font + .fonts_by_id + .get(&fallback_key) + .map(|ff| { + ff.styled_metrics(ctx.pixels_per_point, ctx.font_size, &Default::default()) + }) + .unwrap_or_default(); + let (_, glyph_info) = font.glyph_info(chr, &fallback_metrics); + let advance_width_px = + glyph_info.advance_width_unscaled.0 * fallback_metrics.px_scale_factor; + let (glyph_alloc, physical_x) = + if let Some(ff) = font.fonts_by_id.get_mut(&fallback_key) { + ff.allocate_glyph( + font.atlas, + &fallback_metrics, + &ShapedGlyph { + glyph_id: glyph_info.id.unwrap_or(skrifa::GlyphId::NOTDEF), + h_pos: paragraph.cursor_x_px, + is_cjk: is_cjk(chr), + }, + ) + } else { + Default::default() + }; + + paragraph.cursor_x_px += advance_width_px; + + ctx.glyph( + chr, + physical_x, + advance_width_px, + &fallback_metrics, + glyph_alloc.uv_rect, + ) + } else { + let (mut glyph_alloc, physical_x) = + if let Some(ff) = font.fonts_by_id.get_mut(&run.font_key) { + ff.allocate_glyph( + font.atlas, + face_metrics, + &ShapedGlyph { + glyph_id, + h_pos: paragraph.cursor_x_px + x_offset_px, + is_cjk: is_cjk(chr), + }, + ) + } else { + Default::default() + }; + + // Apply shaper y_offset — this varies per glyph instance so it + // is not part of the cached ShapedGlyph / GlyphAllocation. + glyph_alloc.uv_rect.offset.y += y_offset_px / ctx.pixels_per_point; + + paragraph.cursor_x_px += advance_width_px; + + ctx.glyph( + chr, + physical_x, + advance_width_px, + face_metrics, + glyph_alloc.uv_rect, + ) + }; + paragraph.glyphs.push(glyph); + cluster_glyph_count += 1; + } + + // Emit continuation glyphs for the last cluster in the run. + if ctx.prev_cluster.is_some() { + emit_continuation_glyphs( + ctx, + paragraph, + run_text, + cluster_start_byte..run_text.len(), + cluster_glyph_count, + face_metrics, + ); + } +} + +/// Emit zero-width continuation glyphs when a cluster has more characters than +/// shaped glyphs. +/// +/// This preserves the invariant `glyphs.len() == char_count` that all cursor +/// and text-selection code depends on. Continuation glyphs have +/// [`UvRect::default()`] so [`tessellate_glyphs`] skips them entirely. +fn emit_continuation_glyphs( + ctx: &ShapingContext, + paragraph: &mut Paragraph, + run_text: &str, + cluster_bytes: Range, + cluster_glyph_count: usize, + face_metrics: &StyledMetrics, +) { + let Some(cluster_text) = run_text.get(cluster_bytes) else { + return; + }; + let char_count = cluster_text.chars().count(); + if char_count <= cluster_glyph_count { + return; + } + + let physical_x = paragraph.cursor_x_px.round() as i32; + + for chr in cluster_text.chars().skip(cluster_glyph_count) { + paragraph + .glyphs + .push(ctx.glyph(chr, physical_x, 0.0, face_metrics, UvRect::default())); + } +} + +// Ignores the Y coordinate. +#[must_use] +fn layout_section( + font: &mut Font<'_>, + mut shape_buffer: harfrust::UnicodeBuffer, + pixels_per_point: f32, + job: &LayoutJob, + section_index: u32, + section: &LayoutSection, + out_paragraphs: &mut Vec, +) -> harfrust::UnicodeBuffer { + let LayoutSection { + leading_space, + byte_range, + format, + } = section; + + let font_size = format.font_id.size; + let font_metrics = font.styled_metrics(pixels_per_point, font_size, &format.coords); + let line_height = section + .format + .line_height + .unwrap_or(font_metrics.row_height); + let extra_letter_spacing = section.format.extra_letter_spacing; + + let mut paragraph = out_paragraphs.last_mut().unwrap(); + if paragraph.glyphs.is_empty() { + paragraph.empty_paragraph_height = line_height; + } + paragraph.cursor_x_px += leading_space * pixels_per_point; + + let section_text = &job.text[byte_range.as_usize()]; + let mut ctx = ShapingContext { + pixels_per_point, + font_size, + line_height, + extra_letter_spacing, + section_index, + font_metrics, + is_first_glyph_in_section: paragraph.glyphs.is_empty(), + prev_cluster: None, + }; + let mut runs = Vec::new(); + + // Process each paragraph segment (split on newlines — the shaper can't handle them). + for (seg_idx, segment) in SplitOrWhole::new(section_text, job.break_on_newline).enumerate() { + if 0 < seg_idx { + paragraph = out_paragraphs.push_mut(Paragraph::from_section_index(section_index)); + paragraph.empty_paragraph_height = line_height; + ctx.is_first_glyph_in_section = true; + } + + if segment.is_empty() { + continue; + } + + segment_into_runs(font, segment, &mut runs); + + let num_runs = runs.len(); + for (run_idx, run) in runs.iter().enumerate() { + let run_text = &segment[run.byte_range.as_usize()]; + let Some(font_face) = font.fonts_by_id.get(&run.font_key) else { + continue; + }; + + let face_metrics = + font_face.styled_metrics(pixels_per_point, font_size, &format.coords); + + // Set buffer flags for paragraph boundary context. + let mut flags = harfrust::BufferFlags::empty(); + if run_idx == 0 { + flags |= harfrust::BufferFlags::BEGINNING_OF_TEXT; + } + if run_idx + 1 == num_runs { + flags |= harfrust::BufferFlags::END_OF_TEXT; + } + + let glyph_buffer = shape_text(font_face, run_text, &format.coords, shape_buffer, flags); + + layout_shaped_run( + font, + run, + run_text, + &glyph_buffer, + &face_metrics, + &mut ctx, + paragraph, + ); + + shape_buffer = glyph_buffer.clear(); + } + } + + shape_buffer +} + +/// Iterator that either splits on `'\n'` or yields the whole string once. +/// Avoids `Box` and `Vec<&str>` allocation. +enum SplitOrWhole<'a> { + Split(core::str::Split<'a, char>), + Whole(iter::Once<&'a str>), +} + +impl<'a> SplitOrWhole<'a> { + fn new(text: &'a str, split: bool) -> Self { + if split { + Self::Split(text.split('\n')) + } else { + Self::Whole(iter::once(text)) + } + } +} + +impl<'a> Iterator for SplitOrWhole<'a> { + type Item = &'a str; + + fn next(&mut self) -> Option<&'a str> { + match self { + Self::Split(iter) => iter.next(), + Self::Whole(iter) => iter.next(), + } + } +} + +/// Calculate the intrinsic size of the text. +/// +/// The result is eventually passed to `Response::intrinsic_size`. +/// This works by calculating the size of each `Paragraph` (instead of each `Row`). +fn calculate_intrinsic_size( + point_scale: PointScale, + job: &LayoutJob, + paragraphs: &[Paragraph], +) -> Vec2 { + let mut intrinsic_size = Vec2::ZERO; + for (idx, paragraph) in paragraphs.iter().enumerate() { + // Use the precise cursor position instead of `last_glyph.max_x()`, + // because glyph positions are pixel-snapped but the cursor tracks + // the exact subpixel advance. This ensures that when two galleys are + // placed side-by-side, the gap matches what it would be within a + // single galley. + let width = paragraph.cursor_x_px / point_scale.pixels_per_point; + intrinsic_size.x = f32::max(intrinsic_size.x, width); + + let mut height = paragraph + .glyphs + .iter() + .map(|g| g.line_height) + .max_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal)) + .unwrap_or(paragraph.empty_paragraph_height); + if idx == 0 { + height = f32::max(height, job.first_row_min_height); + } + intrinsic_size.y += point_scale.round_to_pixel(height); + } + intrinsic_size +} + +// Ignores the Y coordinate. +fn rows_from_paragraphs( + paragraphs: Vec, + job: &LayoutJob, + pixels_per_point: f32, + elided: &mut bool, +) -> Vec { + let num_paragraphs = paragraphs.len(); + + let mut rows = vec![]; + + for (i, paragraph) in paragraphs.into_iter().enumerate() { + if job.wrap.max_rows <= rows.len() { + *elided = true; + break; + } + + let is_last_paragraph = (i + 1) == num_paragraphs; + + if paragraph.glyphs.is_empty() { + rows.push(PlacedRow { + pos: pos2(0.0, f32::NAN), + row: Arc::new(Row { + section_index_at_start: paragraph.section_index_at_start, + glyphs: vec![], + visuals: Default::default(), + size: vec2(0.0, paragraph.empty_paragraph_height), + }), + ends_with_newline: !is_last_paragraph, + }); + } else { + // Use precise cursor position for width instead of pixel-snapped + // `last_glyph.max_x()`, so that side-by-side galleys have the same + // spacing as characters within a single galley. + let paragraph_width = paragraph.cursor_x_px / pixels_per_point; + if paragraph_width <= job.effective_wrap_width() { + // Early-out optimization: the whole paragraph fits on one row. + rows.push(PlacedRow { + pos: pos2(0.0, f32::NAN), + row: Arc::new(Row { + section_index_at_start: paragraph.section_index_at_start, + glyphs: paragraph.glyphs, + visuals: Default::default(), + size: vec2(paragraph_width, 0.0), + }), + ends_with_newline: !is_last_paragraph, + }); + } else { + line_break(¶graph, job, &mut rows, elided); + let placed_row = rows.last_mut().unwrap(); + placed_row.ends_with_newline = !is_last_paragraph; + } + } + } + + rows +} + +fn line_break( + paragraph: &Paragraph, + job: &LayoutJob, + out_rows: &mut Vec, + elided: &mut bool, +) { + let wrap_width = job.effective_wrap_width(); + + // Keeps track of good places to insert row break if we exceed `wrap_width`. + let mut row_break_candidates = RowBreakCandidates::default(); + + let mut first_row_indentation = paragraph.glyphs[0].pos.x; + let mut row_start_x = 0.0; + let mut row_start_idx = 0; + + for i in 0..paragraph.glyphs.len() { + if job.wrap.max_rows <= out_rows.len() { + *elided = true; + break; + } + + let potential_row_width = paragraph.glyphs[i].max_x() - row_start_x; + + if wrap_width < potential_row_width { + // Row break: + + if first_row_indentation > 0.0 + && !row_break_candidates.has_good_candidate(job.wrap.break_anywhere) + { + // Allow the first row to be completely empty, because we know there will be more space on the next row: + // TODO(emilk): this records the height of this first row as zero, though that is probably fine since first_row_indentation usually comes with a first_row_min_height. + out_rows.push(PlacedRow { + pos: pos2(0.0, f32::NAN), + row: Arc::new(Row { + section_index_at_start: paragraph.section_index_at_start, + glyphs: vec![], + visuals: Default::default(), + size: Vec2::ZERO, + }), + ends_with_newline: false, + }); + row_start_x += first_row_indentation; + first_row_indentation = 0.0; + } else if let Some(last_kept_index) = row_break_candidates.get(job.wrap.break_anywhere) + { + let glyphs: Vec = paragraph.glyphs[row_start_idx..=last_kept_index] + .iter() + .copied() + .map(|mut glyph| { + glyph.pos.x -= row_start_x; + glyph + }) + .collect(); + + let section_index_at_start = glyphs[0].section_index; + let paragraph_max_x = glyphs.last().unwrap().max_x(); + + out_rows.push(PlacedRow { + pos: pos2(0.0, f32::NAN), + row: Arc::new(Row { + section_index_at_start, + glyphs, + visuals: Default::default(), + size: vec2(paragraph_max_x, 0.0), + }), + ends_with_newline: false, + }); + + // Start a new row: + row_start_idx = last_kept_index + 1; + row_start_x = paragraph.glyphs[row_start_idx].pos.x; + row_break_candidates.forget_before_idx(row_start_idx); + } else { + // Found no place to break, so we have to overrun wrap_width. + } + } + + row_break_candidates.add(i, ¶graph.glyphs[i..]); + } + + if row_start_idx < paragraph.glyphs.len() { + // Final row of text: + + if job.wrap.max_rows <= out_rows.len() { + *elided = true; // can't fit another row + } else { + let paragraph_min_x = paragraph.glyphs[row_start_idx].pos.x - row_start_x; + let paragraph_max_x = paragraph.glyphs.last().unwrap().max_x() - row_start_x; + + let glyphs: Vec = paragraph.glyphs[row_start_idx..] + .iter() + .copied() + .map(|mut glyph| { + glyph.pos.x -= row_start_x + paragraph_min_x; + glyph + }) + .collect(); + + let section_index_at_start = glyphs[0].section_index; + + out_rows.push(PlacedRow { + pos: pos2(paragraph_min_x, 0.0), + row: Arc::new(Row { + section_index_at_start, + glyphs, + visuals: Default::default(), + size: vec2(paragraph_max_x - paragraph_min_x, 0.0), + }), + ends_with_newline: false, + }); + } + } +} + +/// Trims the last glyphs in the row and replaces it with an overflow character (e.g. `…`). +/// +/// Called before we have any Y coordinates. +fn replace_last_glyph_with_overflow_character( + fonts: &mut FontsImpl, + pixels_per_point: f32, + job: &LayoutJob, + row: &mut Row, +) { + let Some(overflow_character) = job.wrap.overflow_character else { + return; + }; + + let mut section_index = row + .glyphs + .last() + .map(|g| g.section_index) + .unwrap_or(row.section_index_at_start); + loop { + let section = &job.sections[section_index as usize]; + let extra_letter_spacing = section.format.extra_letter_spacing; + let mut font = fonts.font(§ion.format.font_id.family); + let font_size = section.format.font_id.size; + + let font_id = font.resolve_face(overflow_character); + let font_face_metrics = font + .fonts_by_id + .get(&font_id) + .map(|f| f.styled_metrics(pixels_per_point, font_size, §ion.format.coords)) + .unwrap_or_default(); + let (_, glyph_info) = font.glyph_info(overflow_character, &font_face_metrics); + let mut font_face = font.fonts_by_id.get_mut(&font_id); + + let overflow_glyph_x = if let Some(prev_glyph) = row.glyphs.last() { + prev_glyph.max_x() + extra_letter_spacing + } else { + 0.0 // TODO(emilk): heed paragraph leading_space 😬 + }; + + let advance_width_px = + glyph_info.advance_width_unscaled.0 * font_face_metrics.px_scale_factor; + let replacement_glyph_width = advance_width_px / pixels_per_point; + + // Check if we're within width budget: + if overflow_glyph_x + replacement_glyph_width <= job.effective_wrap_width() + || row.glyphs.is_empty() + { + // we are done + + let (replacement_glyph_alloc, physical_x) = font_face + .as_mut() + .map(|f| { + f.allocate_glyph( + font.atlas, + &font_face_metrics, + &ShapedGlyph { + glyph_id: glyph_info.id.unwrap_or(skrifa::GlyphId::NOTDEF), + h_pos: overflow_glyph_x * pixels_per_point, + is_cjk: is_cjk(overflow_character), + }, + ) + }) + .unwrap_or_default(); + + let font_metrics = + font.styled_metrics(pixels_per_point, font_size, §ion.format.coords); + let line_height = section + .format + .line_height + .unwrap_or(font_metrics.row_height); + + row.glyphs.push(Glyph { + chr: overflow_character, + pos: pos2(physical_x as f32 / pixels_per_point, f32::NAN), + advance_width: advance_width_px / pixels_per_point, + line_height, + font_face_height: font_face_metrics.row_height, + font_face_ascent: font_face_metrics.ascent, + font_height: font_metrics.row_height, + font_ascent: font_metrics.ascent, + uv_rect: replacement_glyph_alloc.uv_rect, + section_index, + first_vertex: 0, // filled in later + }); + return; + } + + // We didn't fit - pop the last glyph and try again. + if let Some(last_glyph) = row.glyphs.pop() { + section_index = last_glyph.section_index; + } else { + section_index = row.section_index_at_start; + } + } +} + +/// Horizontally aligned the text on a row. +/// +/// Ignores the Y coordinate. +fn halign_and_justify_row( + point_scale: PointScale, + placed_row: &mut PlacedRow, + halign: Align, + wrap_width: f32, + justify: bool, + keep_trailing_whitespace: bool, +) { + #![expect(clippy::useless_let_if_seq)] // False positive + + let row = Arc::make_mut(&mut placed_row.row); + + if row.glyphs.is_empty() { + return; + } + + let num_leading_spaces = row + .glyphs + .iter() + .take_while(|glyph| glyph.chr.is_whitespace()) + .count(); + + let glyph_range = if num_leading_spaces == row.glyphs.len() { + // There is only whitespace + (0, row.glyphs.len()) + } else if keep_trailing_whitespace { + (num_leading_spaces, row.glyphs.len()) + } else { + let num_trailing_spaces = row + .glyphs + .iter() + .rev() + .take_while(|glyph| glyph.chr.is_whitespace()) + .count(); + + (num_leading_spaces, row.glyphs.len() - num_trailing_spaces) + }; + let num_glyphs_in_range = glyph_range.1 - glyph_range.0; + assert!(num_glyphs_in_range > 0, "Should have at least one glyph"); + + let original_min_x = row.glyphs[glyph_range.0].logical_rect().min.x; + let original_max_x = row.glyphs[glyph_range.1 - 1].logical_rect().max.x; + let original_width = original_max_x - original_min_x; + + let target_width = if justify && num_glyphs_in_range > 1 { + wrap_width + } else { + original_width + }; + + let (target_min_x, target_max_x) = match halign { + Align::LEFT => (0.0, target_width), + Align::Center => (-target_width / 2.0, target_width / 2.0), + Align::RIGHT => (-target_width, 0.0), + }; + + let num_spaces_in_range = row.glyphs[glyph_range.0..glyph_range.1] + .iter() + .filter(|glyph| glyph.chr.is_whitespace()) + .count(); + + let mut extra_x_per_glyph = if num_glyphs_in_range == 1 { + 0.0 + } else { + (target_width - original_width) / (num_glyphs_in_range as f32 - 1.0) + }; + extra_x_per_glyph = extra_x_per_glyph.at_least(0.0); // Don't contract + + let mut extra_x_per_space = 0.0; + if 0 < num_spaces_in_range && num_spaces_in_range < num_glyphs_in_range { + // Add an integral number of pixels between each glyph, + // and add the balance to the spaces: + + extra_x_per_glyph = point_scale.floor_to_pixel(extra_x_per_glyph); + + extra_x_per_space = (target_width + - original_width + - extra_x_per_glyph * (num_glyphs_in_range as f32 - 1.0)) + / (num_spaces_in_range as f32); + } + + placed_row.pos.x = point_scale.round_to_pixel(target_min_x); + let mut translate_x = -original_min_x - extra_x_per_glyph * glyph_range.0 as f32; + + for glyph in &mut row.glyphs { + glyph.pos.x += translate_x; + glyph.pos.x = point_scale.round_to_pixel(glyph.pos.x); + translate_x += extra_x_per_glyph; + if glyph.chr.is_whitespace() { + translate_x += extra_x_per_space; + } + } + + // Note we ignore the leading/trailing whitespace here! + row.size.x = target_max_x - target_min_x; +} + +/// Calculate the Y positions and tessellate the text. +fn galley_from_rows( + point_scale: PointScale, + job: Arc, + mut rows: Vec, + elided: bool, + intrinsic_size: Vec2, +) -> Galley { + let mut first_row_min_height = job.first_row_min_height; + let mut cursor_y = 0.0; + + for placed_row in &mut rows { + let mut max_row_height = first_row_min_height.at_least(placed_row.height()); + let row = Arc::make_mut(&mut placed_row.row); + + first_row_min_height = 0.0; + for glyph in &row.glyphs { + max_row_height = max_row_height.at_least(glyph.line_height); + } + max_row_height = point_scale.round_to_pixel(max_row_height); + + // Now position each glyph vertically: + for glyph in &mut row.glyphs { + let format = &job.sections[glyph.section_index as usize].format; + + glyph.pos.y = glyph.font_face_ascent + + // Apply valign to the different in height of the entire row, and the height of this `Font`: + + format.valign.to_factor() * (max_row_height - glyph.line_height) + + // When mixing different `FontImpl` (e.g. latin and emojis), + // we always center the difference: + + 0.5 * (glyph.font_height - glyph.font_face_height); + + glyph.pos.y = point_scale.round_to_pixel(glyph.pos.y); + } + + placed_row.pos.y = cursor_y; + row.size.y = max_row_height; + + cursor_y += max_row_height; + cursor_y = point_scale.round_to_pixel(cursor_y); // TODO(emilk): it would be better to do the calculations in pixels instead. + } + + let format_summary = format_summary(&job); + + let mut rect = Rect::ZERO; + let mut mesh_bounds = Rect::NOTHING; + let mut num_vertices = 0; + let mut num_indices = 0; + + for placed_row in &mut rows { + rect |= placed_row.rect(); + + let row = Arc::make_mut(&mut placed_row.row); + row.visuals = tessellate_row(point_scale, &job, &format_summary, row); + + mesh_bounds |= row.visuals.mesh_bounds.translate(placed_row.pos.to_vec2()); + num_vertices += row.visuals.mesh.vertices.len(); + num_indices += row.visuals.mesh.indices.len(); + + row.section_index_at_start = u32::MAX; // No longer in use. + for glyph in &mut row.glyphs { + glyph.section_index = u32::MAX; // No longer in use. + } + } + + let mut galley = Galley { + job, + rows, + elided, + rect, + mesh_bounds, + num_vertices, + num_indices, + pixels_per_point: point_scale.pixels_per_point, + intrinsic_size, + }; + + if galley.job.round_output_to_gui { + galley.round_output_to_gui(); + } + + galley +} + +#[derive(Default)] +struct FormatSummary { + any_background: bool, + any_underline: bool, + any_strikethrough: bool, +} + +fn format_summary(job: &LayoutJob) -> FormatSummary { + let mut format_summary = FormatSummary::default(); + for section in &job.sections { + format_summary.any_background |= section.format.background != Color32::TRANSPARENT; + format_summary.any_underline |= section.format.underline != Stroke::NONE; + format_summary.any_strikethrough |= section.format.strikethrough != Stroke::NONE; + } + format_summary +} + +fn tessellate_row( + point_scale: PointScale, + job: &LayoutJob, + format_summary: &FormatSummary, + row: &mut Row, +) -> RowVisuals { + if row.glyphs.is_empty() { + return Default::default(); + } + + let mut mesh = Mesh::default(); + + mesh.reserve_triangles(row.glyphs.len() * 2); + mesh.reserve_vertices(row.glyphs.len() * 4); + + if format_summary.any_background { + add_row_backgrounds(point_scale, job, row, &mut mesh); + } + + let glyph_index_start = mesh.indices.len(); + let glyph_vertex_start = mesh.vertices.len(); + tessellate_glyphs(point_scale, job, row, &mut mesh); + let glyph_vertex_end = mesh.vertices.len(); + + if format_summary.any_underline { + add_row_hline(point_scale, row, &mut mesh, |glyph| { + let format = &job.sections[glyph.section_index as usize].format; + let stroke = format.underline; + let y = glyph.logical_rect().bottom(); + (stroke, y) + }); + } + + if format_summary.any_strikethrough { + add_row_hline(point_scale, row, &mut mesh, |glyph| { + let format = &job.sections[glyph.section_index as usize].format; + let stroke = format.strikethrough; + let y = glyph.logical_rect().center().y; + (stroke, y) + }); + } + + let mesh_bounds = mesh.calc_bounds(); + + RowVisuals { + mesh, + mesh_bounds, + glyph_index_start, + glyph_vertex_range: glyph_vertex_start..glyph_vertex_end, + } +} + +/// Create background for glyphs that have them. +/// Creates as few rectangular regions as possible. +fn add_row_backgrounds(point_scale: PointScale, job: &LayoutJob, row: &Row, mesh: &mut Mesh) { + if row.glyphs.is_empty() { + return; + } + + let mut end_run = |start: Option<(Color32, Rect, f32)>, stop_x: f32| { + if let Some((color, start_rect, expand)) = start { + let rect = Rect::from_min_max(start_rect.left_top(), pos2(stop_x, start_rect.bottom())); + let rect = rect.expand(expand); + let rect = rect.round_to_pixels(point_scale.pixels_per_point()); + mesh.add_colored_rect(rect, color); + } + }; + + let mut run_start = None; + let mut last_rect = Rect::NAN; + + for glyph in &row.glyphs { + let format = &job.sections[glyph.section_index as usize].format; + let color = format.background; + let rect = glyph.logical_rect(); + + if color == Color32::TRANSPARENT { + end_run(run_start.take(), last_rect.right()); + } else if let Some((existing_color, start, expand)) = run_start { + if existing_color == color + && start.top() == rect.top() + && start.bottom() == rect.bottom() + && format.expand_bg == expand + { + // continue the same background rectangle + } else { + end_run(run_start.take(), last_rect.right()); + run_start = Some((color, rect, format.expand_bg)); + } + } else { + run_start = Some((color, rect, format.expand_bg)); + } + + last_rect = rect; + } + + end_run(run_start.take(), last_rect.right()); +} + +fn tessellate_glyphs(point_scale: PointScale, job: &LayoutJob, row: &mut Row, mesh: &mut Mesh) { + for glyph in &mut row.glyphs { + glyph.first_vertex = mesh.vertices.len() as u32; + let uv_rect = glyph.uv_rect; + if !uv_rect.is_nothing() { + let mut left_top = glyph.pos + uv_rect.offset; + left_top.x = point_scale.round_to_pixel(left_top.x); + left_top.y = point_scale.round_to_pixel(left_top.y); + + let rect = Rect::from_min_max(left_top, left_top + uv_rect.size); + let uv = Rect::from_min_max( + pos2(uv_rect.min[0] as f32, uv_rect.min[1] as f32), + pos2(uv_rect.max[0] as f32, uv_rect.max[1] as f32), + ); + + let format = &job.sections[glyph.section_index as usize].format; + + let color = format.color; + + if format.italics { + let idx = mesh.vertices.len() as u32; + mesh.add_triangle(idx, idx + 1, idx + 2); + mesh.add_triangle(idx + 2, idx + 1, idx + 3); + + let top_offset = rect.height() * 0.25 * Vec2::X; + + mesh.vertices.push(Vertex { + pos: rect.left_top() + top_offset, + uv: uv.left_top(), + color, + }); + mesh.vertices.push(Vertex { + pos: rect.right_top() + top_offset, + uv: uv.right_top(), + color, + }); + mesh.vertices.push(Vertex { + pos: rect.left_bottom(), + uv: uv.left_bottom(), + color, + }); + mesh.vertices.push(Vertex { + pos: rect.right_bottom(), + uv: uv.right_bottom(), + color, + }); + } else { + mesh.add_rect_with_uv(rect, uv, color); + } + } + } +} + +/// Add a horizontal line over a row of glyphs with a stroke and y decided by a callback. +fn add_row_hline( + point_scale: PointScale, + row: &Row, + mesh: &mut Mesh, + stroke_and_y: impl Fn(&Glyph) -> (Stroke, f32), +) { + let mut path = crate::tessellator::Path::default(); // reusing path to avoid re-allocations. + + let mut end_line = |start: Option<(Stroke, Pos2)>, stop_x: f32| { + if let Some((stroke, start)) = start { + let stop = pos2(stop_x, start.y); + path.clear(); + path.add_line_segment([start, stop]); + let feathering = 1.0 / point_scale.pixels_per_point(); + path.stroke_open(feathering, &PathStroke::from(stroke), mesh); + } + }; + + let mut line_start = None; + let mut last_right_x = f32::NAN; + + for glyph in &row.glyphs { + let (stroke, mut y) = stroke_and_y(glyph); + stroke.round_center_to_pixel(point_scale.pixels_per_point, &mut y); + + if stroke.is_empty() { + end_line(line_start.take(), last_right_x); + } else if let Some((existing_stroke, start)) = line_start { + if existing_stroke == stroke && start.y == y { + // continue the same line + } else { + end_line(line_start.take(), last_right_x); + line_start = Some((stroke, pos2(glyph.pos.x, y))); + } + } else { + line_start = Some((stroke, pos2(glyph.pos.x, y))); + } + + last_right_x = glyph.max_x(); + } + + end_line(line_start.take(), last_right_x); +} + +// ---------------------------------------------------------------------------- + +/// Keeps track of good places to break a long row of text. +/// Will focus primarily on spaces, secondarily on things like `-` +#[derive(Clone, Copy, Default)] +struct RowBreakCandidates { + /// Breaking at ` ` or other whitespace + /// is always the primary candidate. + space: Option, + + /// Logograms (single character representing a whole word) or kana (Japanese hiragana and katakana) are good candidates for line break. + cjk: Option, + + /// Breaking anywhere before a CJK character is acceptable too. + pre_cjk: Option, + + /// Breaking at a dash is a super- + /// good idea. + dash: Option, + + /// This is nicer for things like URLs, e.g. www. + /// example.com. + punctuation: Option, + + /// Breaking after just random character is some + /// times necessary. + any: Option, +} + +impl RowBreakCandidates { + fn add(&mut self, index: usize, glyphs: &[Glyph]) { + let chr = glyphs[0].chr; + const NON_BREAKING_SPACE: char = '\u{A0}'; + if chr.is_whitespace() && chr != NON_BREAKING_SPACE { + self.space = Some(index); + } else if is_cjk(chr) && (glyphs.len() == 1 || is_cjk_break_allowed(glyphs[1].chr)) { + self.cjk = Some(index); + } else if chr == '-' { + self.dash = Some(index); + } else if chr.is_ascii_punctuation() { + self.punctuation = Some(index); + } else if glyphs.len() > 1 && is_cjk(glyphs[1].chr) { + self.pre_cjk = Some(index); + } + self.any = Some(index); + } + + fn word_boundary(&self) -> Option { + [self.space, self.cjk, self.pre_cjk] + .into_iter() + .max() + .flatten() + } + + fn has_good_candidate(&self, break_anywhere: bool) -> bool { + if break_anywhere { + self.any.is_some() + } else { + self.word_boundary().is_some() + } + } + + fn get(&self, break_anywhere: bool) -> Option { + if break_anywhere { + self.any + } else { + self.word_boundary() + .or(self.dash) + .or(self.punctuation) + .or(self.any) + } + } + + fn forget_before_idx(&mut self, index: usize) { + let Self { + space, + cjk, + pre_cjk, + dash, + punctuation, + any, + } = self; + if space.is_some_and(|s| s < index) { + *space = None; + } + if cjk.is_some_and(|s| s < index) { + *cjk = None; + } + if pre_cjk.is_some_and(|s| s < index) { + *pre_cjk = None; + } + if dash.is_some_and(|s| s < index) { + *dash = None; + } + if punctuation.is_some_and(|s| s < index) { + *punctuation = None; + } + if any.is_some_and(|s| s < index) { + *any = None; + } + } +} + +// ---------------------------------------------------------------------------- + +/// Segment text into runs where each run uses a single font face. +/// +/// Grapheme clusters are never split across runs: if a combining mark +/// falls back to a different font than its base character, it stays +/// with the base character's font (the shaper will handle it). +/// +/// NOTE: Segmentation is by font face, not by Unicode script. A run may +/// mix scripts (e.g. Latin + Cyrillic) when they share the same font. +/// This is acceptable for scripts with similar shaping rules, but would +/// need script-aware splitting once RTL/bidi support is added. +/// +/// Results are appended to `out` (which is cleared first) to allow +/// the caller to reuse the allocation across calls. +fn segment_into_runs(font: &mut Font<'_>, text: &str, out: &mut Vec) { + use unicode_segmentation::UnicodeSegmentation as _; + + out.clear(); + + for (byte_offset, grapheme_str) in text.grapheme_indices(true) { + let byte_offset = ByteIndex(byte_offset); + let byte_end = byte_offset + grapheme_str.len(); + + let base_char = grapheme_str.chars().next().unwrap_or(' '); + let font_key = font.resolve_face(base_char); + + if let Some(last_run) = out.last_mut() + && last_run.font_key == font_key + { + last_run.byte_range.end = byte_end; + continue; + } + out.push(TextRun { + font_key, + byte_range: byte_offset..byte_end, + }); + } +} + +/// Shape a text run and return the raw [`harfrust::GlyphBuffer`]. +/// +/// The caller should iterate `glyph_infos()` / `glyph_positions()` (both +/// `Copy` slices) and convert font units to pixels using `metrics.px_scale_factor`. +/// After iteration, recycle the buffer via `glyph_buffer.clear()`. +fn shape_text( + font_face: &FontFace, + text: &str, + coords: &VariationCoords, + mut buffer: harfrust::UnicodeBuffer, + flags: harfrust::BufferFlags, +) -> harfrust::GlyphBuffer { + let font_ref = font_face.skrifa_font_ref(); + let tweak = font_face.tweak(); + + // Build shaper with variable font instance if variation coordinates are set. + let variations: Vec = iter::chain(tweak.coords.as_ref(), coords.as_ref()) + .map(|&(tag, value)| harfrust::Variation { tag, value }) + .collect(); + + let instance = if variations.is_empty() { + None + } else { + Some(harfrust::ShaperInstance::from_variations( + font_ref, variations, + )) + }; + + let shaper = font_face + .shaper_data() + .shaper(font_ref) + .instance(instance.as_ref()) + .build(); + + buffer.set_flags(flags); + buffer.push_str(text); + buffer.guess_segment_properties(); + + shaper.shape(buffer, harfrust::ShapeOptions::new()) +} + +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use core::iter; + + use super::{super::*, *}; + use crate::text::cursor::CCursor; + + #[test] + fn test_zero_max_width() { + let pixels_per_point = 1.0; + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + let mut layout_job = LayoutJob::single_section("W".into(), TextFormat::default()); + layout_job.wrap.max_width = 0.0; + let galley = layout(&mut fonts, pixels_per_point, layout_job.into()); + assert_eq!(galley.rows.len(), 1); + } + + #[test] + fn test_truncate_with_newline() { + // No matter where we wrap, we should be appending the newline character. + + let pixels_per_point = 1.0; + + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + let text_format = TextFormat { + font_id: FontId::monospace(12.0), + ..Default::default() + }; + + for text in ["Hello\nworld", "\nfoo"] { + for break_anywhere in [false, true] { + for max_width in [0.0, 5.0, 10.0, 20.0, f32::INFINITY] { + let mut layout_job = + LayoutJob::single_section(text.into(), text_format.clone()); + layout_job.wrap.max_width = max_width; + layout_job.wrap.max_rows = 1; + layout_job.wrap.break_anywhere = break_anywhere; + + let galley = layout(&mut fonts, pixels_per_point, layout_job.into()); + + assert!(galley.elided); + assert_eq!(galley.rows.len(), 1); + let row_text = galley.rows[0].text(); + assert!( + row_text.ends_with('…'), + "Expected row to end with `…`, got {row_text:?} when line-breaking the text {text:?} with max_width {max_width} and break_anywhere {break_anywhere}.", + ); + } + } + } + + { + let mut layout_job = LayoutJob::single_section("Hello\nworld".into(), text_format); + layout_job.wrap.max_width = 50.0; + layout_job.wrap.max_rows = 1; + layout_job.wrap.break_anywhere = false; + + let galley = layout(&mut fonts, pixels_per_point, layout_job.into()); + + assert!(galley.elided); + assert_eq!(galley.rows.len(), 1); + let row_text = galley.rows[0].text(); + assert_eq!(row_text, "Hello…"); + } + } + + #[test] + fn test_cjk() { + let pixels_per_point = 1.0; + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + let mut layout_job = LayoutJob::single_section( + "日本語とEnglishの混在した文章".into(), + TextFormat::default(), + ); + layout_job.wrap.max_width = 90.0; + let galley = layout(&mut fonts, pixels_per_point, layout_job.into()); + assert_eq!( + galley.rows.iter().map(|row| row.text()).collect::>(), + vec!["日本語と", "Englishの混在", "した文章"] + ); + } + + #[test] + fn test_pre_cjk() { + let pixels_per_point = 1.0; + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + let mut layout_job = LayoutJob::single_section( + "日本語とEnglishの混在した文章".into(), + TextFormat::default(), + ); + layout_job.wrap.max_width = 110.0; + let galley = layout(&mut fonts, pixels_per_point, layout_job.into()); + assert_eq!( + galley.rows.iter().map(|row| row.text()).collect::>(), + vec!["日本語とEnglish", "の混在した文章"] + ); + } + + #[test] + fn test_truncate_width() { + let pixels_per_point = 1.0; + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + let mut layout_job = + LayoutJob::single_section("# DNA\nMore text".into(), TextFormat::default()); + layout_job.wrap.max_width = f32::INFINITY; + layout_job.wrap.max_rows = 1; + layout_job.round_output_to_gui = false; + let galley = layout(&mut fonts, pixels_per_point, layout_job.into()); + assert!(galley.elided); + assert_eq!( + galley.rows.iter().map(|row| row.text()).collect::>(), + vec!["# DNA…"] + ); + let row = &galley.rows[0]; + assert_eq!(row.pos, Pos2::ZERO); + assert_eq!(row.rect().max.x, row.glyphs.last().unwrap().max_x()); + } + + #[test] + fn test_truncate_with_pixels_per_point() { + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + + for pixels_per_point in [ + 0.33, 0.5, 0.67, 1.0, 1.25, 1.33, 1.5, 1.75, 2.0, 3.0, 4.0, 5.0, + ] { + for ch in ['W', 'A', 'n', 't', 'i'] { + let target_width = 50.0; + let text = (0..20).map(|_| ch).collect::(); + + let mut job = LayoutJob::single_section(text, TextFormat::default()); + job.wrap.max_width = target_width; + job.wrap.max_rows = 1; + let elided_galley = layout(&mut fonts, pixels_per_point, job.into()); + assert!(elided_galley.elided); + + let test_galley = layout( + &mut fonts, + pixels_per_point, + Arc::new(LayoutJob::single_section( + iter::chain( + (0..elided_galley.rows[0].char_count_excluding_newline().0).map(|_| ch), + iter::once('…'), + ) + .collect::(), + TextFormat::default(), + )), + ); + + assert!(elided_galley.size().x >= 0.0); + assert!(elided_galley.size().x <= target_width); + assert!(test_galley.size().x > target_width); + } + } + } + + #[test] + fn test_empty_row() { + let pixels_per_point = 1.0; + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + + let font_id = FontId::default(); + let font_height = fonts + .font(&font_id.family) + .styled_metrics(pixels_per_point, font_id.size, &VariationCoords::default()) + .row_height; + + let job = LayoutJob::simple(String::new(), font_id, Color32::WHITE, f32::INFINITY); + + let galley = layout(&mut fonts, pixels_per_point, job.into()); + + assert_eq!(galley.rows.len(), 1, "Expected one row"); + assert_eq!( + galley.rows[0].row.glyphs.len(), + 0, + "Expected no glyphs in the empty row" + ); + assert_eq!( + galley.size(), + Vec2::new(0.0, font_height.round()), + "Unexpected galley size" + ); + assert_eq!( + galley.intrinsic_size(), + Vec2::new(0.0, font_height.round()), + "Unexpected intrinsic size" + ); + } + + #[test] + fn test_end_with_newline() { + let pixels_per_point = 1.0; + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + + let font_id = FontId::default(); + let font_height = fonts + .font(&font_id.family) + .styled_metrics(pixels_per_point, font_id.size, &VariationCoords::default()) + .row_height; + + let job = LayoutJob::simple("Hi!\n".to_owned(), font_id, Color32::WHITE, f32::INFINITY); + + let galley = layout(&mut fonts, pixels_per_point, job.into()); + + assert_eq!(galley.rows.len(), 2, "Expected two rows"); + assert_eq!( + galley.rows[1].row.glyphs.len(), + 0, + "Expected no glyphs in the empty row" + ); + assert_eq!( + galley.size().round(), + Vec2::new(17.0, font_height.round() * 2.0), + "Unexpected galley size" + ); + assert_eq!( + galley.intrinsic_size().round(), + Vec2::new(17.0, font_height.round() * 2.0), + "Unexpected intrinsic size" + ); + } + + #[test] + fn test_combining_diacritics() { + // ɔ̃ = U+0254 (LATIN SMALL LETTER OPEN O) + U+0303 (COMBINING TILDE) + // With text shaping, the combining tilde should NOT produce a separate + // advance — it should be positioned above ɔ via GPOS anchors. + // Note: the default fonts don't contain U+0254, so the replacement glyph + // is used. The key test is that the combining mark does NOT add extra width. + let pixels_per_point = 1.0; + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + + let job_combined = LayoutJob::simple( + "ɔ\u{0303}".to_owned(), + FontId::proportional(14.0), + Color32::WHITE, + f32::INFINITY, + ); + let galley_combined = layout(&mut fonts, pixels_per_point, job_combined.into()); + + let job_base = LayoutJob::simple( + "ɔ".to_owned(), + FontId::proportional(14.0), + Color32::WHITE, + f32::INFINITY, + ); + let galley_base = layout(&mut fonts, pixels_per_point, job_base.into()); + + let width_combined = galley_combined.size().x; + let width_base = galley_base.size().x; + + assert!( + (width_combined - width_base).abs() < 2.0, + "Combining diacritic should not add significant width. \ + Base width: {width_base}, Combined width: {width_combined}" + ); + + let glyphs = &galley_combined.rows[0].row.glyphs; + assert!(!glyphs.is_empty(), "Expected at least 1 glyph for ɔ̃"); + } + + #[test] + fn test_shaping_basic_latin() { + // Basic test: shaped Latin text should produce the same number of glyphs as characters. + let pixels_per_point = 1.0; + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + + let job = LayoutJob::simple( + "Hello".to_owned(), + FontId::proportional(14.0), + Color32::WHITE, + f32::INFINITY, + ); + let galley = layout(&mut fonts, pixels_per_point, job.into()); + + assert_eq!(galley.rows.len(), 1); + assert_eq!(galley.rows[0].row.glyphs.len(), 5); + assert!(galley.size().x > 0.0); + } + + #[test] + fn test_shaping_empty_string() { + let pixels_per_point = 1.0; + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + + let job = LayoutJob::simple( + String::new(), + FontId::proportional(14.0), + Color32::WHITE, + f32::INFINITY, + ); + let galley = layout(&mut fonts, pixels_per_point, job.into()); + + assert_eq!(galley.rows.len(), 1); + assert_eq!(galley.rows[0].row.glyphs.len(), 0); + } + + #[test] + fn test_shaping_multiple_newlines() { + let pixels_per_point = 1.0; + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + + let job = LayoutJob::simple( + "A\n\nB".to_owned(), + FontId::proportional(14.0), + Color32::WHITE, + f32::INFINITY, + ); + let galley = layout(&mut fonts, pixels_per_point, job.into()); + + assert_eq!(galley.rows.len(), 3, "Expected 3 rows for 'A\\n\\nB'"); + assert_eq!(galley.rows[0].row.glyphs.len(), 1); // "A" + assert_eq!(galley.rows[1].row.glyphs.len(), 0); // empty line + assert_eq!(galley.rows[2].row.glyphs.len(), 1); // "B" + } + + #[test] + fn test_shaping_mixed_font_fallback() { + // Text with both Latin and emoji should work without panicking, + // even though they use different font faces. + let pixels_per_point = 1.0; + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + + let job = LayoutJob::simple( + "Hi 🎉 bye".to_owned(), + FontId::proportional(14.0), + Color32::WHITE, + f32::INFINITY, + ); + let galley = layout(&mut fonts, pixels_per_point, job.into()); + + assert_eq!(galley.rows.len(), 1); + // "Hi " (3) + "🎉" (1) + " bye" (4) = at least 8 glyphs + assert!( + galley.rows[0].row.glyphs.len() >= 8, + "Expected >= 8 glyphs, got {}", + galley.rows[0].row.glyphs.len() + ); + } + + #[test] + fn test_gpos_kerning() { + // GPOS kerning: pairs like "AV", "VA", "AT" should be tighter than + // the sum of individual character widths. Without text shaping, egui + // only uses the legacy `kern` table, so these pairs had diff ≈ 0. + // With harfrust, GPOS kerning applies proper negative adjustments. + let pixels_per_point = 1.0; + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + let font_id = FontId::proportional(14.0); + + for pair in ["AV", "VA", "AT"] { + let (pair_w, _, _) = measure_text(&mut fonts, pair, &font_id, pixels_per_point); + let chars: Vec = pair.chars().collect(); + let (w1, _, _) = measure_text( + &mut fonts, + &chars[0].to_string(), + &font_id, + pixels_per_point, + ); + let (w2, _, _) = measure_text( + &mut fonts, + &chars[1].to_string(), + &font_id, + pixels_per_point, + ); + let sum = w1 + w2; + let kern_adjustment = sum - pair_w; + + assert!( + kern_adjustment > 0.5, + "GPOS kerning for '{pair}': expected pair to be noticeably tighter \ + than sum of individuals. pair_width={pair_w:.2}, sum={sum:.2}, \ + kern_adjustment={kern_adjustment:.2} (should be > 0.5)", + ); + } + } + + /// Regression test for . + /// + /// Multi-codepoint grapheme clusters (flag emojis, combining marks) must + /// produce exactly as many glyphs as characters so that cursor positioning + /// and text selection remain correct. + #[test] + fn test_grapheme_cluster_glyph_count() { + let pixels_per_point = 1.0; + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + let font_id = FontId::default(); + + // Each test case: (input text, expected char count) + let cases: &[(&str, usize)] = &[ + // Flag emoji: two Regional Indicator codepoints → one visual glyph + ("\u{1F1EF}\u{1F1F5}", 2), // 🇯🇵 + // Flag surrounded by ASCII + ("A\u{1F1EB}\u{1F1F7}B", 4), // A🇫🇷B + // Base char + combining acute accent + ("e\u{0301}", 2), // é as decomposed + // Multiple combining marks + ("o\u{0302}\u{0323}", 3), // ộ + // Plain ASCII (sanity check) + ("Hello", 5), + ]; + + for &(text, expected_chars) in cases { + let job = LayoutJob::simple( + text.to_owned(), + font_id.clone(), + Color32::WHITE, + f32::INFINITY, + ); + let galley = layout(&mut fonts, pixels_per_point, job.into()); + + let total_glyphs: usize = galley.rows.iter().map(|r| r.row.glyphs.len()).sum(); + + assert_eq!( + total_glyphs, + expected_chars, + "Glyph count mismatch for {text:?}: \ + expected {expected_chars} glyphs (one per char), got {total_glyphs}. \ + Glyphs: {:?}", + galley.rows[0] + .row + .glyphs + .iter() + .map(|g| (g.chr, g.advance_width)) + .collect::>(), + ); + + // Verify that Row::text() reconstructs the input text. + let row_text: String = galley.rows.iter().map(|r| r.text()).collect(); + assert_eq!(row_text, text, "Row::text() mismatch for {text:?}"); + + // Verify cursor round-trip: end cursor index == char count. + assert_eq!( + galley.end().index.0, + expected_chars, + "Galley::end().index mismatch for {text:?}", + ); + } + } + + /// Verify that cursor positioning round-trips correctly for text + /// containing multi-codepoint grapheme clusters (regression test for #8087). + #[test] + fn test_grapheme_cluster_cursor_roundtrip() { + let pixels_per_point = 1.0; + let mut fonts = FontsImpl::new(TextOptions::default(), FontDefinitions::default()); + let font_id = FontId::default(); + + // "A" + flag emoji (2 codepoints) + "B" = 4 chars + let text = "A\u{1F1EF}\u{1F1F5}B"; + let job = LayoutJob::simple( + text.to_owned(), + font_id.clone(), + Color32::WHITE, + f32::INFINITY, + ); + let galley = layout(&mut fonts, pixels_per_point, job.into()); + + // Walking through every cursor index should produce valid positions. + for i in 0..=galley.end().index.0 { + let cursor = CCursor { + index: CharIndex(i), + prefer_next_row: false, + }; + let rect = galley.pos_from_cursor(cursor); + assert!( + rect.is_finite(), + "pos_from_cursor returned non-finite rect for index {i}", + ); + + // Round-trip: position → cursor → position should be stable. + let cursor2 = galley.cursor_from_pos(Vec2::new(rect.center().x, rect.center().y)); + let rect2 = galley.pos_from_cursor(cursor2); + assert!( + (rect.min.x - rect2.min.x).abs() < 1.0, + "Cursor round-trip unstable at index {i}: \ + first={}, second={}, cursor2.index={}", + rect.min.x, + rect2.min.x, + cursor2.index, + ); + } + } + + fn measure_text( + fonts: &mut FontsImpl, + text: &str, + font_id: &FontId, + pixels_per_point: f32, + ) -> (f32, usize, Vec<(char, f32)>) { + let job = LayoutJob::simple( + text.to_owned(), + font_id.clone(), + Color32::WHITE, + f32::INFINITY, + ); + let galley = layout(fonts, pixels_per_point, job.into()); + let glyphs = &galley.rows[0].row.glyphs; + let details: Vec<_> = glyphs.iter().map(|g| (g.chr, g.advance_width)).collect(); + (galley.size().x, glyphs.len(), details) + } +} diff --git a/crates/accshift-egui/vendor/epaint/src/text/text_layout_types.rs b/crates/accshift-egui/vendor/epaint/src/text/text_layout_types.rs new file mode 100644 index 0000000..e51c0e1 --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/text/text_layout_types.rs @@ -0,0 +1,1452 @@ +use core::{ops::Range, str::FromStr as _}; +use std::sync::Arc; + +use super::{ + cursor::{CCursor, LayoutCursor}, + font::UvRect, + index::{ByteIndex, ByteRange, ByteRangeExt as _, CharIndex}, +}; +use crate::{Color32, FontId, Mesh, Stroke, text::FontsView}; +use emath::{Align, GuiRounding as _, NumExt as _, OrderedFloat, Pos2, Rect, Vec2, pos2, vec2}; +pub use font_types::Tag; +use smallvec::SmallVec; + +/// Describes the task of laying out text. +/// +/// This supports mixing different fonts, color and formats (underline etc). +/// +/// Pass this to [`crate::FontsView::layout_job`] or [`crate::text::layout`]. +/// +/// ## Example: +/// ``` +/// use epaint::{Color32, text::{LayoutJob, TextFormat}, FontFamily, FontId}; +/// +/// let mut job = LayoutJob::default(); +/// job.append( +/// "Hello ", +/// 0.0, +/// TextFormat { +/// font_id: FontId::new(14.0, FontFamily::Proportional), +/// color: Color32::WHITE, +/// ..Default::default() +/// }, +/// ); +/// job.append( +/// "World!", +/// 0.0, +/// TextFormat { +/// font_id: FontId::new(14.0, FontFamily::Monospace), +/// color: Color32::BLACK, +/// ..Default::default() +/// }, +/// ); +/// ``` +/// +/// As you can see, constructing a [`LayoutJob`] is currently a lot of work. +/// It would be nice to have a helper macro for it! +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct LayoutJob { + /// The complete text of this job, referenced by [`LayoutSection`]. + pub text: String, + + /// The different section, which can have different fonts, colors, etc. + /// + /// Invariant: the sections are ordered by their `byte_range`, + /// and together cover the whole of [`Self::text`] with no gaps and no overlaps. + /// That is: the first section starts at byte 0, the last section ends at `text.len()`, + /// and each section starts exactly where the previous one ended. + /// This is checked by [`Self::debug_sanity_check`]. + pub sections: Vec, + + /// Controls the text wrapping and elision. + pub wrap: TextWrapping, + + /// The first row must be at least this high. + /// This is in case we lay out text that is the continuation + /// of some earlier text (sharing the same row), + /// in which case this will be the height of the earlier text. + /// In other cases, set this to `0.0`. + pub first_row_min_height: f32, + + /// If `true`, all `\n` characters will result in a new _paragraph_, + /// starting on a new row. + /// + /// If `false`, all `\n` characters will be ignored + /// and show up as the replacement character. + /// + /// Default: `true`. + pub break_on_newline: bool, + + /// How to horizontally align the text (`Align::LEFT`, `Align::Center`, `Align::RIGHT`). + pub halign: Align, + + /// Justify text so that word-wrapped rows fill the whole [`TextWrapping::max_width`]. + pub justify: bool, + + /// Round output sizes using [`emath::GuiRounding`], to avoid rounding errors in layout code. + pub round_output_to_gui: bool, + + /// If `false` (default), trailing whitespace is ignored when computing + /// horizontal alignment ([`Self::halign`]). + /// This is desirable for labels so that e.g. "Hello " centers the same as "Hello". + /// + /// If `true`, trailing whitespace is included in the row width used for alignment. + /// This is desirable for text editors where the user expects to see their spaces. + pub keep_trailing_whitespace: bool, +} + +impl Default for LayoutJob { + #[inline] + fn default() -> Self { + Self { + text: Default::default(), + sections: Default::default(), + wrap: Default::default(), + first_row_min_height: 0.0, + break_on_newline: true, + halign: Align::LEFT, + justify: false, + round_output_to_gui: true, + keep_trailing_whitespace: false, + } + } +} + +impl LayoutJob { + /// Clear the text and sections while preserving the layout settings. + #[inline] + pub fn clear(&mut self) { + self.text.clear(); + self.sections.clear(); + } + + /// Break on `\n` and at the given wrap width. + #[inline] + pub fn simple(text: String, font_id: FontId, color: Color32, wrap_width: f32) -> Self { + Self { + sections: vec![LayoutSection { + leading_space: 0.0, + byte_range: ByteRange::full(&text), + format: TextFormat::simple(font_id, color), + }], + text, + wrap: TextWrapping { + max_width: wrap_width, + ..Default::default() + }, + break_on_newline: true, + ..Default::default() + } + } + + /// Break on `\n` + #[inline] + pub fn simple_format(text: String, format: TextFormat) -> Self { + Self { + sections: vec![LayoutSection { + leading_space: 0.0, + byte_range: ByteRange::full(&text), + format, + }], + text, + break_on_newline: true, + ..Default::default() + } + } + + /// Does not break on `\n`, but shows the replacement character instead. + #[inline] + pub fn simple_singleline(text: String, font_id: FontId, color: Color32) -> Self { + Self { + sections: vec![LayoutSection { + leading_space: 0.0, + byte_range: ByteRange::full(&text), + format: TextFormat::simple(font_id, color), + }], + text, + wrap: Default::default(), + break_on_newline: false, + ..Default::default() + } + } + + #[inline] + pub fn single_section(text: String, format: TextFormat) -> Self { + Self { + sections: vec![LayoutSection { + leading_space: 0.0, + byte_range: ByteRange::full(&text), + format, + }], + text, + wrap: Default::default(), + break_on_newline: true, + ..Default::default() + } + } + + #[inline] + pub fn is_empty(&self) -> bool { + self.sections.is_empty() + } + + /// Helper for adding a new section when building a [`LayoutJob`]. + /// + /// If the appended text has the same [`TextFormat`] as the last section and no + /// `leading_space`, it is merged into that section instead of adding a new one. + /// This keeps the section count down and lets text shaping (e.g. kerning) work + /// across the appended text, since shaping is done per [`LayoutSection`]. + pub fn append(&mut self, text: &str, leading_space: f32, format: TextFormat) { + let start = self.text.len(); + self.text += text; + let byte_range = ByteIndex(start)..ByteIndex(self.text.len()); + + // Optimization: merge into the previous section if it has the same format + // and this one adds no leading space. + if leading_space == 0.0 + && let Some(last) = self.sections.last_mut() + && last.format == format + { + last.byte_range.end = byte_range.end; + return; + } + + self.sections.push(LayoutSection { + leading_space, + byte_range, + format, + }); + } + + /// The [`TextFormat`] of the section containing the character starting at the given byte index. + /// + /// If the index is past the end, the format of the last section is returned. + /// + /// Panics if the job has no sections. + /// Assumes [`LayoutJob::sections`] are ordered by increasing `byte_range` (as produced by [`Self::append`]). + pub fn format_at_byte(&self, byte_idx: ByteIndex) -> &TextFormat { + self.debug_sanity_check(); + let last = self.sections.last().expect("LayoutJob has no sections"); + let idx = self + .sections + .partition_point(|section| section.byte_range.end <= byte_idx); + let section = self.sections.get(idx).unwrap_or(last); + §ion.format + } + + /// Check the [`Self::sections`] invariant: the sections are ordered and together + /// cover the whole of [`Self::text`] with no gaps and no overlaps. + /// + /// Only does anything in debug builds. + #[cfg_attr(not(debug_assertions), expect(clippy::unused_self))] + pub fn debug_sanity_check(&self) { + #[cfg(debug_assertions)] + { + if self.sections.is_empty() { + assert!( + self.text.is_empty(), + "LayoutJob has text but no sections: {:?}", + self.text + ); + return; + } + + assert_eq!( + self.sections + .first() + .expect("checked above") + .byte_range + .start, + ByteIndex::ZERO, + "First LayoutSection must start at byte 0" + ); + assert_eq!( + self.sections.last().expect("checked above").byte_range.end, + ByteIndex(self.text.len()), + "Last LayoutSection must end at the end of the text" + ); + + for section in &self.sections { + let Range { start, end } = section.byte_range; + assert!(start <= end, "LayoutSection has a reversed byte_range"); + } + for (prev, next) in core::iter::zip(&self.sections, self.sections.iter().skip(1)) { + assert_eq!( + prev.byte_range.end, next.byte_range.start, + "LayoutSections must be ordered with no gaps and no overlaps" + ); + } + } + } + + /// The height of the tallest font used in the job. + /// + /// Returns a value rounded to [`emath::GUI_ROUNDING`]. + pub fn font_height(&self, fonts: &mut FontsView<'_>) -> f32 { + let mut max_height = 0.0_f32; + for section in &self.sections { + max_height = max_height.max(fonts.row_height(§ion.format.font_id)); + } + max_height + } + + /// The wrap with, with a small margin in some cases. + pub fn effective_wrap_width(&self) -> f32 { + if self.round_output_to_gui { + // On a previous pass we may have rounded down by at most 0.5 and reported that as a width. + // egui may then set that width as the max width for subsequent frames, and it is important + // that we then don't wrap earlier. + self.wrap.max_width + 0.5 + } else { + self.wrap.max_width + } + } +} + +impl core::hash::Hash for LayoutJob { + #[inline] + fn hash(&self, state: &mut H) { + let Self { + text, + sections, + wrap, + first_row_min_height, + break_on_newline, + halign, + justify, + round_output_to_gui, + keep_trailing_whitespace, + } = self; + + text.hash(state); + sections.hash(state); + wrap.hash(state); + emath::OrderedFloat(*first_row_min_height).hash(state); + break_on_newline.hash(state); + halign.hash(state); + justify.hash(state); + round_output_to_gui.hash(state); + keep_trailing_whitespace.hash(state); + } +} + +// ---------------------------------------------------------------------------- + +/// A contiguous range of [`LayoutJob::text`] that shares the same [`TextFormat`]. +/// +/// The sections of a [`LayoutJob`] are ordered and together cover the whole text +/// with no gaps and no overlaps. See [`LayoutJob::sections`] for the full invariant. +/// +/// Text is shaped on a per-section basis: each section is an independent shaping run. +/// This means kerning (and ligatures) are only correct _within_ a single section, +/// and not across the boundary between two adjacent sections. +/// For this reason [`LayoutJob::append`] merges consecutive sections when possible. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct LayoutSection { + /// Can be used for first row indentation. + pub leading_space: f32, + + /// Range into [`LayoutJob::text`]. + pub byte_range: ByteRange, + + /// How to format the text in this section (font, color, etc). + pub format: TextFormat, +} + +impl core::hash::Hash for LayoutSection { + #[inline] + fn hash(&self, state: &mut H) { + let Self { + leading_space, + byte_range, + format, + } = self; + OrderedFloat(*leading_space).hash(state); + byte_range.hash(state); + format.hash(state); + } +} + +// ---------------------------------------------------------------------------- + +/// Helper trait for all types that can be parsed as a [`font_types::Tag`]. +pub trait IntoTag { + fn into_tag(self) -> font_types::Tag; +} + +impl IntoTag for font_types::Tag { + #[inline(always)] + fn into_tag(self) -> font_types::Tag { + self + } +} + +impl IntoTag for u32 { + #[inline(always)] + fn into_tag(self) -> font_types::Tag { + font_types::Tag::from_u32(self) + } +} + +impl IntoTag for [u8; 4] { + #[inline(always)] + fn into_tag(self) -> font_types::Tag { + font_types::Tag::new_checked(&self).expect("Invalid variation axis tag") + } +} + +impl IntoTag for &[u8; 4] { + #[inline(always)] + fn into_tag(self) -> font_types::Tag { + font_types::Tag::new_checked(self).expect("Invalid variation axis tag") + } +} + +impl IntoTag for &str { + #[inline(always)] + fn into_tag(self) -> font_types::Tag { + font_types::Tag::from_str(self).expect("Invalid variation axis tag") + } +} + +/// List of font variation coordinates by axis tag. If more than one coordinate for a given axis is provided, the last +/// one added is used. +#[derive(Clone, Debug, PartialEq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct VariationCoords(SmallVec<[(font_types::Tag, f32); 2]>); + +impl VariationCoords { + /// Create a list of variation coordinates from a sequence of (tag, value) pairs. + /// + /// ## Example: + /// ``` + /// use epaint::text::VariationCoords; + /// + /// let coords = VariationCoords::new([ + /// (b"wght", 500.0), + /// (b"wdth", 75.0), + /// ]); + /// ``` + pub fn new(values: impl IntoIterator) -> Self { + Self(values.into_iter().map(|(t, c)| (t.into_tag(), c)).collect()) + } + + /// Add a variation coordinate to the list. + #[inline(always)] + pub fn push(&mut self, tag: impl IntoTag, coord: f32) { + self.0.push((tag.into_tag(), coord)); + } + + /// Remove the coordinate at the given index. + pub fn remove(&mut self, index: usize) { + self.0.remove(index); + } + + pub fn clear(&mut self) { + self.0.clear(); + } +} + +impl AsRef<[(font_types::Tag, f32)]> for VariationCoords { + #[inline(always)] + fn as_ref(&self) -> &[(font_types::Tag, f32)] { + &self.0 + } +} + +impl AsMut<[(font_types::Tag, f32)]> for VariationCoords { + fn as_mut(&mut self) -> &mut [(font_types::Tag, f32)] { + &mut self.0 + } +} + +impl core::hash::Hash for VariationCoords { + fn hash(&self, state: &mut H) { + self.0.len().hash(state); + for (tag, coord) in &self.0 { + tag.hash(state); + OrderedFloat(*coord).hash(state); + } + } +} + +/// Formatting option for a section of text. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct TextFormat { + pub font_id: FontId, + + /// Extra spacing between letters, in points. + /// + /// Default: 0.0. + pub extra_letter_spacing: f32, + + /// Explicit line height of the text in points. + /// + /// This is the distance between the bottom row of two subsequent lines of text. + /// + /// If `None` (the default), the line height is determined by the font. + /// + /// For even text it is recommended you round this to an even number of _pixels_. + pub line_height: Option, + + /// Text color + pub color: Color32, + + pub background: Color32, + + /// Amount to expand background fill by. + /// + /// Default: 1.0 + pub expand_bg: f32, + + pub coords: VariationCoords, + + pub italics: bool, + + pub underline: Stroke, + + pub strikethrough: Stroke, + + /// If you use a small font and [`Align::TOP`] you + /// can get the effect of raised text. + /// + /// If you use a small font and [`Align::BOTTOM`] + /// you get the effect of a subscript. + /// + /// If you use [`Align::Center`], you get text that is centered + /// around a common center-line, which is nice when mixining emojis + /// and normal text in e.g. a button. + pub valign: Align, +} + +impl Default for TextFormat { + #[inline] + fn default() -> Self { + Self { + font_id: FontId::default(), + extra_letter_spacing: 0.0, + line_height: None, + color: Color32::GRAY, + background: Color32::TRANSPARENT, + expand_bg: 1.0, + coords: VariationCoords::default(), + italics: false, + underline: Stroke::NONE, + strikethrough: Stroke::NONE, + valign: Align::BOTTOM, + } + } +} + +impl core::hash::Hash for TextFormat { + #[inline] + fn hash(&self, state: &mut H) { + let Self { + font_id, + extra_letter_spacing, + line_height, + color, + background, + expand_bg, + coords, + italics, + underline, + strikethrough, + valign, + } = self; + font_id.hash(state); + emath::OrderedFloat(*extra_letter_spacing).hash(state); + if let Some(line_height) = *line_height { + emath::OrderedFloat(line_height).hash(state); + } + color.hash(state); + background.hash(state); + emath::OrderedFloat(*expand_bg).hash(state); + coords.hash(state); + italics.hash(state); + underline.hash(state); + strikethrough.hash(state); + valign.hash(state); + } +} + +impl TextFormat { + #[inline] + pub fn simple(font_id: FontId, color: Color32) -> Self { + Self { + font_id, + color, + ..Default::default() + } + } +} + +// ---------------------------------------------------------------------------- + +/// How to wrap and elide text. +/// +/// This enum is used in high-level APIs where providing a [`TextWrapping`] is too verbose. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum TextWrapMode { + /// The text should expand the `Ui` size when reaching its boundary. + Extend, + + /// The text should wrap to the next line when reaching the `Ui` boundary. + Wrap, + + /// The text should be elided using "…" when reaching the `Ui` boundary. + /// + /// Note that using [`TextWrapping`] and [`LayoutJob`] offers more control over the elision. + Truncate, +} + +/// Controls the text wrapping and elision of a [`LayoutJob`]. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct TextWrapping { + /// Wrap text so that no row is wider than this. + /// + /// If you would rather truncate text that doesn't fit, set [`Self::max_rows`] to `1`. + /// + /// Set `max_width` to [`f32::INFINITY`] to turn off wrapping and elision. + /// + /// Note that `\n` always produces a new row + /// if [`LayoutJob::break_on_newline`] is `true`. + pub max_width: f32, + + /// Maximum amount of rows the text galley should have. + /// + /// If this limit is reached, text will be truncated + /// and [`Self::overflow_character`] appended to the final row. + /// You can detect this by checking [`Galley::elided`]. + /// + /// If set to `0`, no text will be outputted. + /// + /// If set to `1`, a single row will be outputted, + /// eliding the text after [`Self::max_width`] is reached. + /// When you set `max_rows = 1`, it is recommended you also set [`Self::break_anywhere`] to `true`. + /// + /// Default value: `usize::MAX`. + pub max_rows: usize, + + /// If `true`: Allow breaking between any characters. + /// If `false` (default): prefer breaking between words, etc. + /// + /// NOTE: Due to limitations in the current implementation, + /// when truncating text using [`Self::max_rows`] the text may be truncated + /// in the middle of a word even if [`Self::break_anywhere`] is `false`. + /// Therefore it is recommended to set [`Self::break_anywhere`] to `true` + /// whenever [`Self::max_rows`] is set to `1`. + pub break_anywhere: bool, + + /// Character to use to represent elided text. + /// + /// The default is `…`. + /// + /// If not set, no character will be used (but the text will still be elided). + pub overflow_character: Option, +} + +impl core::hash::Hash for TextWrapping { + #[inline] + fn hash(&self, state: &mut H) { + let Self { + max_width, + max_rows, + break_anywhere, + overflow_character, + } = self; + emath::OrderedFloat(*max_width).hash(state); + max_rows.hash(state); + break_anywhere.hash(state); + overflow_character.hash(state); + } +} + +impl Default for TextWrapping { + fn default() -> Self { + Self { + max_width: f32::INFINITY, + max_rows: usize::MAX, + break_anywhere: false, + overflow_character: Some('…'), + } + } +} + +impl TextWrapping { + /// Create a [`TextWrapping`] from a [`TextWrapMode`] and an available width. + pub fn from_wrap_mode_and_width(mode: TextWrapMode, max_width: f32) -> Self { + match mode { + TextWrapMode::Extend => Self::no_max_width(), + TextWrapMode::Wrap => Self::wrap_at_width(max_width), + TextWrapMode::Truncate => Self::truncate_at_width(max_width), + } + } + + /// A row can be as long as it need to be. + pub fn no_max_width() -> Self { + Self { + max_width: f32::INFINITY, + ..Default::default() + } + } + + /// A row can be at most `max_width` wide but can wrap in any number of lines. + pub fn wrap_at_width(max_width: f32) -> Self { + Self { + max_width, + ..Default::default() + } + } + + /// Elide text that doesn't fit within the given width, replaced with `…`. + pub fn truncate_at_width(max_width: f32) -> Self { + Self { + max_width, + max_rows: 1, + break_anywhere: true, + ..Default::default() + } + } +} + +// ---------------------------------------------------------------------------- + +/// Text that has been laid out, ready for painting. +/// +/// You can create a [`Galley`] using [`crate::FontsView::layout_job`]; +/// +/// Needs to be recreated if the underlying font atlas texture changes, which +/// happens under the following conditions: +/// - `pixels_per_point` or `max_texture_size` change. These parameters are set +/// in [`crate::text::Fonts::begin_pass`]. When using `egui` they are set +/// from `egui::InputState` and can change at any time. +/// - The atlas has become full. This can happen any time a new glyph is added +/// to the atlas, which in turn can happen any time new text is laid out. +/// +/// The name comes from typography, where a "galley" is a metal tray +/// containing a column of set type, usually the size of a page of text. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct Galley { + /// The job that this galley is the result of. + /// Contains the original string and style sections. + pub job: Arc, + + /// Rows of text, from top to bottom, and their offsets. + /// + /// The number of characters in all rows sum up to `job.text.chars().count()` + /// unless [`Self::elided`] is `true`. + /// + /// Note that a paragraph (a piece of text separated with `\n`) + /// can be split up into multiple rows. + pub rows: Vec, + + /// Set to true the text was truncated due to [`TextWrapping::max_rows`]. + pub elided: bool, + + /// Bounding rect. + /// + /// `rect.top()` is always 0.0. + /// + /// With [`LayoutJob::halign`]: + /// * [`Align::LEFT`]: `rect.left() == 0.0` + /// * [`Align::Center`]: `rect.center() == 0.0` + /// * [`Align::RIGHT`]: `rect.right() == 0.0` + pub rect: Rect, + + /// Tight bounding box around all the meshes in all the rows. + /// Can be used for culling. + pub mesh_bounds: Rect, + + /// Total number of vertices in all the row meshes. + pub num_vertices: usize, + + /// Total number of indices in all the row meshes. + pub num_indices: usize, + + /// The number of physical pixels for each logical point. + /// Since this affects the layout, we keep track of it + /// so that we can warn if this has changed once we get to + /// tessellation. + pub pixels_per_point: f32, + + pub(crate) intrinsic_size: Vec2, +} + +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct PlacedRow { + /// The position of this [`Row`] relative to the galley. + /// + /// This is rounded to the closest _pixel_ in order to produce crisp, pixel-perfect text. + pub pos: Pos2, + + /// The underlying unpositioned [`Row`]. + pub row: Arc, + + /// If true, this [`PlacedRow`] came from a paragraph ending with a `\n`. + /// The `\n` itself is omitted from row's [`Row::glyphs`]. + /// A `\n` in the input text always creates a new [`PlacedRow`] below it, + /// so that text that ends with `\n` has an empty [`PlacedRow`] last. + /// This also implies that the last [`PlacedRow`] in a [`Galley`] always has `ends_with_newline == false`. + pub ends_with_newline: bool, +} + +impl PlacedRow { + /// Logical bounding rectangle on font heights etc. + /// + /// This ignores / includes the `LayoutSection::leading_space`. + pub fn rect(&self) -> Rect { + Rect::from_min_size(self.pos, self.row.size) + } + + /// Same as [`Self::rect`] but excluding the `LayoutSection::leading_space`. + pub fn rect_without_leading_space(&self) -> Rect { + let x = self.pos.x + self.glyphs.first().map_or(0.0, |g| g.pos.x); + let right = self.pos.x + self.size.x; + Rect::from_min_max( + Pos2::new(x, self.pos.y), + Pos2::new(right, self.pos.y + self.size.y), + ) + } +} + +impl core::ops::Deref for PlacedRow { + type Target = Row; + + fn deref(&self) -> &Self::Target { + &self.row + } +} + +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct Row { + /// This is included in case there are no glyphs. + /// + /// Only used during layout, then set to an invalid value in order to + /// enable the paragraph-concat optimization path without having to + /// adjust `section_index` when concatting. + pub(crate) section_index_at_start: u32, + + /// One for each `char`. + pub glyphs: Vec, + + /// Logical size based on font heights etc. + /// Includes leading and trailing whitespace. + pub size: Vec2, + + /// The mesh, ready to be rendered. + pub visuals: RowVisuals, +} + +/// The tessellated output of a row. +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct RowVisuals { + /// The tessellated text, using non-normalized (texel) UV coordinates. + /// That is, you need to divide the uv coordinates by the texture size. + pub mesh: Mesh, + + /// Bounds of the mesh, and can be used for culling. + /// Does NOT include leading or trailing whitespace glyphs!! + pub mesh_bounds: Rect, + + /// The number of triangle indices added before the first glyph triangle. + /// + /// This can be used to insert more triangles after the background but before the glyphs, + /// i.e. for text selection visualization. + pub glyph_index_start: usize, + + /// The range of vertices in the mesh that contain glyphs (as opposed to background, underlines, strikethorugh, etc). + /// + /// The glyph vertices comes after backgrounds (if any), but before any underlines and strikethrough. + pub glyph_vertex_range: Range, +} + +impl Default for RowVisuals { + fn default() -> Self { + Self { + mesh: Default::default(), + mesh_bounds: Rect::NOTHING, + glyph_index_start: 0, + glyph_vertex_range: 0..0, + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct Glyph { + /// The character this glyph represents. + pub chr: char, + + /// Baseline position, relative to the row. + /// Logical position: pos.y is the same for all chars of the same [`TextFormat`]. + pub pos: Pos2, + + /// Logical width of the glyph. + pub advance_width: f32, + + /// Height of this row of text. + /// + /// Usually same as [`Self::font_height`], + /// unless explicitly overridden by [`TextFormat::line_height`]. + pub line_height: f32, + + /// The ascent of this font. + pub font_ascent: f32, + + /// The row/line height of this font. + pub font_height: f32, + + /// The ascent of the sub-font within the font (`FontFace`). + pub font_face_ascent: f32, + + /// The row/line height of the sub-font within the font (`FontFace`). + pub font_face_height: f32, + + /// Position and size of the glyph in the font texture, in texels. + pub uv_rect: UvRect, + + /// Index into [`LayoutJob::sections`]. Decides color etc. + /// + /// Only used during layout, then set to an invalid value in order to + /// enable the paragraph-concat optimization path without having to + /// adjust `section_index` when concatting. + pub(crate) section_index: u32, + + /// Which is our first vertex in [`RowVisuals::mesh`]. + pub first_vertex: u32, +} + +impl Glyph { + #[inline] + pub fn size(&self) -> Vec2 { + Vec2::new(self.advance_width, self.line_height) + } + + #[inline] + pub fn max_x(&self) -> f32 { + self.pos.x + self.advance_width + } + + /// Same y range for all characters with the same [`TextFormat`]. + #[inline] + pub fn logical_rect(&self) -> Rect { + Rect::from_min_size(self.pos - vec2(0.0, self.font_ascent), self.size()) + } +} + +// ---------------------------------------------------------------------------- + +impl Row { + /// The text on this row, excluding the implicit `\n` if any. + pub fn text(&self) -> String { + self.glyphs.iter().map(|g| g.chr).collect() + } + + /// Excludes the implicit `\n` after the [`Row`], if any. + #[inline] + pub fn char_count_excluding_newline(&self) -> CharIndex { + CharIndex(self.glyphs.len()) + } + + /// Closest char at the desired x coordinate in row-relative coordinates. + /// Returns something in the range `[0, char_count_excluding_newline()]`. + pub fn char_at(&self, desired_x: f32) -> CharIndex { + for (i, glyph) in self.glyphs.iter().enumerate() { + if desired_x < glyph.logical_rect().center().x { + return CharIndex(i); + } + } + self.char_count_excluding_newline() + } + + pub fn x_offset(&self, column: CharIndex) -> f32 { + if let Some(glyph) = self.glyphs.get(column.0) { + glyph.pos.x + } else { + self.size.x + } + } + + #[inline] + pub fn height(&self) -> f32 { + self.size.y + } +} + +impl PlacedRow { + #[inline] + pub fn min_y(&self) -> f32 { + self.rect().top() + } + + #[inline] + pub fn max_y(&self) -> f32 { + self.rect().bottom() + } + + /// Includes the implicit `\n` after the [`PlacedRow`], if any. + #[inline] + pub fn char_count_including_newline(&self) -> CharIndex { + CharIndex(self.row.glyphs.len() + (self.ends_with_newline as usize)) + } +} + +impl Galley { + #[inline] + pub fn is_empty(&self) -> bool { + self.job.is_empty() + } + + /// The full, non-elided text of the input job. + #[inline] + pub fn text(&self) -> &str { + &self.job.text + } + + #[inline] + pub fn size(&self) -> Vec2 { + self.rect.size() + } + + /// This is the size that a non-wrapped, non-truncated, non-justified version of the text + /// would have. + /// + /// Useful for advanced layouting. + #[inline] + pub fn intrinsic_size(&self) -> Vec2 { + // We do the rounding here instead of in `round_output_to_gui` so that rounding + // errors don't accumulate when concatenating multiple galleys. + if self.job.round_output_to_gui { + self.intrinsic_size.round_ui() + } else { + self.intrinsic_size + } + } + + pub(crate) fn round_output_to_gui(&mut self) { + for placed_row in &mut self.rows { + // Optimization: only call `make_mut` if necessary (can cause a deep clone) + let rounded_size = placed_row.row.size.round_ui(); + if placed_row.row.size != rounded_size { + Arc::make_mut(&mut placed_row.row).size = rounded_size; + } + } + + let rect = &mut self.rect; + + let did_exceed_wrap_width_by_a_lot = rect.width() > self.job.wrap.max_width + 1.0; + + *rect = rect.round_ui(); + + if did_exceed_wrap_width_by_a_lot { + // If the user picked a too aggressive wrap width (e.g. more narrow than any individual glyph), + // we should let the user know by reporting that our width is wider than the wrap width. + } else { + // Make sure we don't report being wider than the wrap width the user picked: + rect.max.x = rect + .max + .x + .at_most(rect.min.x + self.job.wrap.max_width) + .floor_ui(); + } + } + + /// Append each galley under the previous one. + pub fn concat(job: Arc, galleys: &[Arc], pixels_per_point: f32) -> Self { + profiling::function_scope!(); + + let mut merged_galley = Self { + job, + rows: Vec::new(), + elided: false, + rect: Rect::ZERO, + mesh_bounds: Rect::NOTHING, + num_vertices: 0, + num_indices: 0, + pixels_per_point, + intrinsic_size: Vec2::ZERO, + }; + + for (i, galley) in galleys.iter().enumerate() { + let current_y_offset = merged_galley.rect.height(); + let is_last_galley = i + 1 == galleys.len(); + + merged_galley + .rows + .extend(galley.rows.iter().enumerate().map(|(row_idx, placed_row)| { + let new_pos = placed_row.pos + current_y_offset * Vec2::Y; + let new_pos = new_pos.round_to_pixels(pixels_per_point); + merged_galley.mesh_bounds |= + placed_row.visuals.mesh_bounds.translate(new_pos.to_vec2()); + merged_galley.rect |= Rect::from_min_size(new_pos, placed_row.size); + + let mut ends_with_newline = placed_row.ends_with_newline; + let is_last_row_in_galley = row_idx + 1 == galley.rows.len(); + // Since we remove the `\n` when splitting rows, we need to add it back here + ends_with_newline |= !is_last_galley && is_last_row_in_galley; + super::PlacedRow { + pos: new_pos, + row: Arc::clone(&placed_row.row), + ends_with_newline, + } + })); + + merged_galley.num_vertices += galley.num_vertices; + merged_galley.num_indices += galley.num_indices; + // Note that if `galley.elided` is true this will be the last `Galley` in + // the vector and the loop will end. + merged_galley.elided |= galley.elided; + merged_galley.intrinsic_size.x = + f32::max(merged_galley.intrinsic_size.x, galley.intrinsic_size.x); + merged_galley.intrinsic_size.y += galley.intrinsic_size.y; + } + + if merged_galley.job.round_output_to_gui { + merged_galley.round_output_to_gui(); + } + + merged_galley + } +} + +impl AsRef for Galley { + #[inline] + fn as_ref(&self) -> &str { + self.text() + } +} + +impl core::borrow::Borrow for Galley { + #[inline] + fn borrow(&self) -> &str { + self.text() + } +} + +impl core::ops::Deref for Galley { + type Target = str; + #[inline] + fn deref(&self) -> &str { + self.text() + } +} + +// ---------------------------------------------------------------------------- + +/// ## Physical positions +impl Galley { + /// Zero-width rect past the last character. + fn end_pos(&self) -> Rect { + if let Some(row) = self.rows.last() { + let x = row.rect().right(); + Rect::from_min_max(pos2(x, row.min_y()), pos2(x, row.max_y())) + } else { + // Empty galley + Rect::from_min_max(pos2(0.0, 0.0), pos2(0.0, 0.0)) + } + } + + /// Returns a 0-width Rect. + pub fn pos_from_layout_cursor(&self, layout_cursor: &LayoutCursor) -> Rect { + let Some(row) = self.rows.get(layout_cursor.row) else { + return self.end_pos(); + }; + + let x = row.x_offset(layout_cursor.column) + row.pos.x; + Rect::from_min_max(pos2(x, row.min_y()), pos2(x, row.max_y())) + } + + /// Returns a 0-width Rect. + pub fn pos_from_cursor(&self, cursor: CCursor) -> Rect { + self.pos_from_layout_cursor(&self.layout_from_cursor(cursor)) + } + + /// Cursor at the given position within the galley. + /// + /// A cursor above the galley is considered + /// same as a cursor at the start, + /// and a cursor below the galley is considered + /// same as a cursor at the end. + /// This allows implementing text-selection by dragging above/below the galley. + pub fn cursor_from_pos(&self, pos: Vec2) -> CCursor { + // Vertical margin around galley improves text selection UX + const VMARGIN: f32 = 5.0; + + if let Some(first_row) = self.rows.first() + && pos.y < first_row.min_y() - VMARGIN + { + return self.begin(); + } + if let Some(last_row) = self.rows.last() + && last_row.max_y() + VMARGIN < pos.y + { + return self.end(); + } + + let mut best_y_dist = f32::INFINITY; + let mut cursor = CCursor::default(); + + let mut ccursor_index = CharIndex::ZERO; + + for row in &self.rows { + let min_y = row.min_y(); + let max_y = row.max_y(); + + let is_pos_within_row = min_y <= pos.y && pos.y <= max_y; + let y_dist = (min_y - pos.y).abs().min((max_y - pos.y).abs()); + if is_pos_within_row || y_dist < best_y_dist { + best_y_dist = y_dist; + // char_at is `Row` not `PlacedRow` relative which means we have to subtract the pos. + let column = row.char_at(pos.x - row.pos.x); + let prefer_next_row = column < row.char_count_excluding_newline(); + cursor = CCursor { + index: ccursor_index + column, + prefer_next_row, + }; + + if is_pos_within_row { + return cursor; + } + } + ccursor_index += row.char_count_including_newline(); + } + + cursor + } +} + +/// ## Cursor positions +impl Galley { + /// Cursor to the first character. + /// + /// This is the same as [`CCursor::default`]. + #[inline] + #[expect(clippy::unused_self)] + pub fn begin(&self) -> CCursor { + CCursor::default() + } + + /// Cursor to one-past last character. + pub fn end(&self) -> CCursor { + if self.rows.is_empty() { + return Default::default(); + } + let mut ccursor = CCursor { + index: CharIndex::ZERO, + prefer_next_row: true, + }; + for row in &self.rows { + let row_char_count = row.char_count_including_newline(); + ccursor.index += row_char_count; + } + ccursor + } +} + +/// ## Cursor conversions +impl Galley { + // The returned cursor is clamped. + pub fn layout_from_cursor(&self, cursor: CCursor) -> LayoutCursor { + let prefer_next_row = cursor.prefer_next_row; + let mut ccursor_it = CCursor { + index: CharIndex::ZERO, + prefer_next_row, + }; + + for (row_nr, row) in self.rows.iter().enumerate() { + let row_char_count = row.char_count_excluding_newline(); + + if ccursor_it.index <= cursor.index && cursor.index <= ccursor_it.index + row_char_count + { + let column = cursor.index - ccursor_it.index; + + let select_next_row_instead = prefer_next_row + && !row.ends_with_newline + && column >= row.char_count_excluding_newline(); + if !select_next_row_instead { + return LayoutCursor { + row: row_nr, + column, + }; + } + } + ccursor_it.index += row.char_count_including_newline(); + } + debug_assert!(ccursor_it == self.end(), "Cursor out of bounds"); + + if let Some(last_row) = self.rows.last() { + LayoutCursor { + row: self.rows.len() - 1, + column: last_row.char_count_including_newline(), + } + } else { + Default::default() + } + } + + fn cursor_from_layout(&self, layout_cursor: LayoutCursor) -> CCursor { + if layout_cursor.row >= self.rows.len() { + return self.end(); + } + + let prefer_next_row = + layout_cursor.column < self.rows[layout_cursor.row].char_count_excluding_newline(); + let mut cursor_it = CCursor { + index: CharIndex::ZERO, + prefer_next_row, + }; + + for (row_nr, row) in self.rows.iter().enumerate() { + if row_nr == layout_cursor.row { + cursor_it.index += layout_cursor.column.min(row.char_count_excluding_newline()); + + return cursor_it; + } + cursor_it.index += row.char_count_including_newline(); + } + cursor_it + } +} + +/// ## Cursor positions +impl Galley { + #[expect(clippy::unused_self)] + pub fn cursor_left_one_character(&self, cursor: &CCursor) -> CCursor { + if cursor.index == CharIndex::ZERO { + Default::default() + } else { + CCursor { + index: cursor.index - 1, + prefer_next_row: true, // default to this when navigating. It is more often useful to put cursor at the beginning of a row than at the end. + } + } + } + + pub fn cursor_right_one_character(&self, cursor: &CCursor) -> CCursor { + CCursor { + index: (cursor.index + 1).min(self.end().index), + prefer_next_row: true, // default to this when navigating. It is more often useful to put cursor at the beginning of a row than at the end. + } + } + + pub fn clamp_cursor(&self, cursor: &CCursor) -> CCursor { + self.cursor_from_layout(self.layout_from_cursor(*cursor)) + } + + pub fn cursor_up_one_row( + &self, + cursor: &CCursor, + h_pos: Option, + ) -> (CCursor, Option) { + let layout_cursor = self.layout_from_cursor(*cursor); + let h_pos = h_pos.unwrap_or_else(|| self.pos_from_layout_cursor(&layout_cursor).center().x); + if layout_cursor.row == 0 { + (CCursor::default(), None) + } else { + let new_row = layout_cursor.row - 1; + + let new_layout_cursor = { + // keep same X coord + // char_at is Row-relative, so subtract the row's position + let column = self.rows[new_row].char_at(h_pos - self.rows[new_row].pos.x); + LayoutCursor { + row: new_row, + column, + } + }; + (self.cursor_from_layout(new_layout_cursor), Some(h_pos)) + } + } + + pub fn cursor_down_one_row( + &self, + cursor: &CCursor, + h_pos: Option, + ) -> (CCursor, Option) { + let layout_cursor = self.layout_from_cursor(*cursor); + let h_pos = h_pos.unwrap_or_else(|| self.pos_from_layout_cursor(&layout_cursor).center().x); + if layout_cursor.row + 1 < self.rows.len() { + let new_row = layout_cursor.row + 1; + + let new_layout_cursor = { + // keep same X coord + // char_at is Row-relative, so subtract the row's position + let column = self.rows[new_row].char_at(h_pos - self.rows[new_row].pos.x); + LayoutCursor { + row: new_row, + column, + } + }; + + (self.cursor_from_layout(new_layout_cursor), Some(h_pos)) + } else { + (self.end(), None) + } + } + + pub fn cursor_begin_of_row(&self, cursor: &CCursor) -> CCursor { + let layout_cursor = self.layout_from_cursor(*cursor); + self.cursor_from_layout(LayoutCursor { + row: layout_cursor.row, + column: CharIndex::ZERO, + }) + } + + pub fn cursor_end_of_row(&self, cursor: &CCursor) -> CCursor { + let layout_cursor = self.layout_from_cursor(*cursor); + self.cursor_from_layout(LayoutCursor { + row: layout_cursor.row, + column: self.rows[layout_cursor.row].char_count_excluding_newline(), + }) + } + + pub fn cursor_begin_of_paragraph(&self, cursor: &CCursor) -> CCursor { + let mut layout_cursor = self.layout_from_cursor(*cursor); + layout_cursor.column = CharIndex::ZERO; + + loop { + let prev_row = layout_cursor + .row + .checked_sub(1) + .and_then(|row| self.rows.get(row)); + + let Some(prev_row) = prev_row else { + // This is the first row + break; + }; + + if prev_row.ends_with_newline { + break; + } + + layout_cursor.row -= 1; + } + + self.cursor_from_layout(layout_cursor) + } + + pub fn cursor_end_of_paragraph(&self, cursor: &CCursor) -> CCursor { + let mut layout_cursor = self.layout_from_cursor(*cursor); + loop { + let row = &self.rows[layout_cursor.row]; + if row.ends_with_newline || layout_cursor.row == self.rows.len() - 1 { + layout_cursor.column = row.char_count_excluding_newline(); + break; + } + + layout_cursor.row += 1; + } + + self.cursor_from_layout(layout_cursor) + } +} diff --git a/crates/accshift-egui/vendor/epaint/src/texture_atlas.rs b/crates/accshift-egui/vendor/epaint/src/texture_atlas.rs new file mode 100644 index 0000000..0bb235b --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/texture_atlas.rs @@ -0,0 +1,279 @@ +use ecolor::Color32; +use emath::{Rect, remap_clamp}; + +use crate::{ColorImage, ImageDelta, TextOptions}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct Rectu { + /// inclusive + min_x: usize, + + /// inclusive + min_y: usize, + + /// exclusive + max_x: usize, + + /// exclusive + max_y: usize, +} + +impl Rectu { + const NOTHING: Self = Self { + min_x: usize::MAX, + min_y: usize::MAX, + max_x: 0, + max_y: 0, + }; + const EVERYTHING: Self = Self { + min_x: 0, + min_y: 0, + max_x: usize::MAX, + max_y: usize::MAX, + }; +} + +#[derive(Copy, Clone, Debug)] +struct PrerasterizedDisc { + r: f32, + uv: Rectu, +} + +/// A pre-rasterized disc (filled circle), somewhere in the texture atlas. +#[derive(Copy, Clone, Debug)] +pub struct PreparedDisc { + /// The radius of this disc in texels. + pub r: f32, + + /// Width in texels. + pub w: f32, + + /// Where in the texture atlas the disc is. + /// Normalized in 0-1 range. + pub uv: Rect, +} + +/// Contains font data in an atlas, where each character occupied a small rectangle. +/// +/// More characters can be added, possibly expanding the texture. +#[derive(Clone)] +pub struct TextureAtlas { + image: ColorImage, + + /// What part of the image that is dirty + dirty: Rectu, + + /// Used for when allocating new rectangles. + cursor: (usize, usize), + + row_height: usize, + + /// Set when someone requested more space than was available. + overflowed: bool, + + /// pre-rasterized discs of radii `2^i`, where `i` is the index. + discs: Vec, + + /// Controls how to convert glyph coverage to alpha. + options: TextOptions, +} + +impl TextureAtlas { + pub fn new(size: [usize; 2], options: TextOptions) -> Self { + assert!(size[0] >= 1024, "Tiny texture atlas"); + let mut atlas = Self { + image: ColorImage::filled(size, Color32::TRANSPARENT), + dirty: Rectu::EVERYTHING, + cursor: (0, 0), + row_height: 0, + overflowed: false, + discs: vec![], // will be filled in below + options, + }; + + // Make the top left pixel fully white for `WHITE_UV`, i.e. painting something with solid color: + let (pos, image) = atlas.allocate((1, 1)); + assert_eq!( + pos, + (0, 0), + "Expected the first allocation to be at (0, 0), but was at {pos:?}" + ); + image[pos] = Color32::WHITE; + + // Allocate a series of anti-aliased discs used to render small filled circles: + // TODO(emilk): these circles can be packed A LOT better. + // In fact, the whole texture atlas could be packed a lot better. + // for r in [1, 2, 4, 8, 16, 32, 64] { + // let w = 2 * r + 3; + // let hw = w as i32 / 2; + const LARGEST_CIRCLE_RADIUS: f32 = 8.0; // keep small so that the initial texture atlas is small + for i in 0.. { + let r = 2.0_f32.powf(i as f32 / 2.0 - 1.0); + if r > LARGEST_CIRCLE_RADIUS { + break; + } + let hw = (r + 0.5).ceil() as i32; + let w = (2 * hw + 1) as usize; + let ((x, y), image) = atlas.allocate((w, w)); + for dx in -hw..=hw { + for dy in -hw..=hw { + let distance_to_center = ((dx * dx + dy * dy) as f32).sqrt(); + let coverage = + remap_clamp(distance_to_center, (r - 0.5)..=(r + 0.5), 1.0..=0.0); + image[((x as i32 + hw + dx) as usize, (y as i32 + hw + dy) as usize)] = options + .color_transfer_function + .color_from_coverage(coverage); + } + } + atlas.discs.push(PrerasterizedDisc { + r, + uv: Rectu { + min_x: x, + min_y: y, + max_x: x + w, + max_y: y + w, + }, + }); + } + + atlas + } + + pub fn options(&self) -> &TextOptions { + &self.options + } + + pub fn size(&self) -> [usize; 2] { + self.image.size + } + + /// Returns the locations and sizes of pre-rasterized discs (filled circles) in this atlas. + pub fn prepared_discs(&self) -> Vec { + let size = self.size(); + let inv_w = 1.0 / size[0] as f32; + let inv_h = 1.0 / size[1] as f32; + self.discs + .iter() + .map(|disc| { + let r = disc.r; + let Rectu { + min_x, + min_y, + max_x, + max_y, + } = disc.uv; + let w = max_x - min_x; + let uv = Rect::from_min_max( + emath::pos2(min_x as f32 * inv_w, min_y as f32 * inv_h), + emath::pos2(max_x as f32 * inv_w, max_y as f32 * inv_h), + ); + PreparedDisc { r, w: w as f32, uv } + }) + .collect() + } + + fn max_height(&self) -> usize { + // the initial width is set to the max size + self.image.height().max(self.image.width()) + } + + /// When this get high, it might be time to clear and start over! + pub fn fill_ratio(&self) -> f32 { + if self.overflowed { + 1.0 + } else { + (self.cursor.1 + self.row_height) as f32 / self.max_height() as f32 + } + } + + /// The texture options suitable for a font texture + #[inline] + pub fn texture_options() -> crate::textures::TextureOptions { + crate::textures::TextureOptions::LINEAR + } + + /// The full font atlas image. + #[inline] + pub fn image(&self) -> &ColorImage { + &self.image + } + + /// Call to get the change to the image since last call. + pub fn take_delta(&mut self) -> Option { + let texture_options = Self::texture_options(); + + let dirty = core::mem::replace(&mut self.dirty, Rectu::NOTHING); + if dirty == Rectu::NOTHING { + None + } else if dirty == Rectu::EVERYTHING { + Some(ImageDelta::full(self.image.clone(), texture_options)) + } else { + let pos = [dirty.min_x, dirty.min_y]; + let size = [dirty.max_x - dirty.min_x, dirty.max_y - dirty.min_y]; + let region = self.image.region_by_pixels(pos, size); + Some(ImageDelta::partial(pos, region, texture_options)) + } + } + + /// Returns the coordinates of where the rect ended up, + /// and invalidates the region. + pub fn allocate(&mut self, (w, h): (usize, usize)) -> ((usize, usize), &mut ColorImage) { + /// On some low-precision GPUs (my old iPad) characters get muddled up + /// if we don't add some empty pixels between the characters. + /// On modern high-precision GPUs this is not needed. + const PADDING: usize = 1; + + assert!( + w <= self.image.width(), + "Tried to allocate a {} wide glyph in a {} wide texture atlas", + w, + self.image.width() + ); + if self.cursor.0 + w > self.image.width() { + // New row: + self.cursor.0 = 0; + self.cursor.1 += self.row_height + PADDING; + self.row_height = 0; + } + + self.row_height = self.row_height.max(h); + + let required_height = self.cursor.1 + self.row_height; + + if required_height > self.max_height() { + // This is a bad place to be - we need to start reusing space :/ + + log::warn!("epaint texture atlas overflowed!"); + + self.cursor = (0, self.image.height() / 3); // Restart a bit down - the top of the atlas has too many important things in it + self.overflowed = true; // this will signal the user that we need to recreate the texture atlas next frame. + } else if resize_to_min_height(&mut self.image, required_height) { + self.dirty = Rectu::EVERYTHING; + } + + let pos = self.cursor; + self.cursor.0 += w + PADDING; + + self.dirty.min_x = self.dirty.min_x.min(pos.0); + self.dirty.min_y = self.dirty.min_y.min(pos.1); + self.dirty.max_x = self.dirty.max_x.max(pos.0 + w); + self.dirty.max_y = self.dirty.max_y.max(pos.1 + h); + + (pos, &mut self.image) + } +} + +fn resize_to_min_height(image: &mut ColorImage, required_height: usize) -> bool { + while required_height >= image.height() { + image.size[1] *= 2; // double the height + } + + if image.width() * image.height() > image.pixels.len() { + image + .pixels + .resize(image.width() * image.height(), Color32::TRANSPARENT); + true + } else { + false + } +} diff --git a/crates/accshift-egui/vendor/epaint/src/texture_handle.rs b/crates/accshift-egui/vendor/epaint/src/texture_handle.rs new file mode 100644 index 0000000..b8b4310 --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/texture_handle.rs @@ -0,0 +1,138 @@ +use std::sync::Arc; + +use crate::{ + ImageData, ImageDelta, TextureId, TextureManager, emath::NumExt as _, mutex::RwLock, + textures::TextureOptions, +}; + +/// Used to paint images. +/// +/// An _image_ is pixels stored in RAM, and represented using [`ImageData`]. +/// Before you can paint it however, you need to convert it to a _texture_. +/// +/// If you are using egui, use `egui::Context::load_texture`. +/// +/// The [`TextureHandle`] can be cloned cheaply. +/// When the last [`TextureHandle`] for specific texture is dropped, the texture is freed. +/// +/// See also [`TextureManager`]. +#[must_use] +pub struct TextureHandle { + tex_mngr: Arc>, + id: TextureId, +} + +impl Drop for TextureHandle { + fn drop(&mut self) { + self.tex_mngr.write().free(self.id); + } +} + +impl Clone for TextureHandle { + fn clone(&self) -> Self { + self.tex_mngr.write().retain(self.id); + Self { + tex_mngr: Arc::clone(&self.tex_mngr), + id: self.id, + } + } +} + +impl PartialEq for TextureHandle { + #[inline] + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } +} + +impl Eq for TextureHandle {} + +impl core::hash::Hash for TextureHandle { + #[inline] + fn hash(&self, state: &mut H) { + self.id.hash(state); + } +} + +impl TextureHandle { + /// If you are using egui, use `egui::Context::load_texture` instead. + pub fn new(tex_mngr: Arc>, id: TextureId) -> Self { + Self { tex_mngr, id } + } + + #[inline] + pub fn id(&self) -> TextureId { + self.id + } + + /// Assign a new image to an existing texture. + #[expect(clippy::needless_pass_by_ref_mut)] // Intentionally hide interiority of mutability + pub fn set(&mut self, image: impl Into, options: TextureOptions) { + self.tex_mngr + .write() + .set(self.id, ImageDelta::full(image.into(), options)); + } + + /// Assign a new image to a subregion of the whole texture. + #[expect(clippy::needless_pass_by_ref_mut)] // Intentionally hide interiority of mutability + pub fn set_partial( + &mut self, + pos: [usize; 2], + image: impl Into, + options: TextureOptions, + ) { + self.tex_mngr + .write() + .set(self.id, ImageDelta::partial(pos, image.into(), options)); + } + + /// width x height + pub fn size(&self) -> [usize; 2] { + self.tex_mngr + .read() + .meta(self.id) + .map_or([0, 0], |tex| tex.size) + } + + /// width x height + pub fn size_vec2(&self) -> crate::Vec2 { + let [w, h] = self.size(); + crate::Vec2::new(w as f32, h as f32) + } + + /// `width x height x bytes_per_pixel` + pub fn byte_size(&self) -> usize { + self.tex_mngr + .read() + .meta(self.id) + .map_or(0, |tex| tex.bytes_used()) + } + + /// width / height + pub fn aspect_ratio(&self) -> f32 { + let [w, h] = self.size(); + w as f32 / h.at_least(1) as f32 + } + + /// Debug-name. + pub fn name(&self) -> String { + self.tex_mngr + .read() + .meta(self.id) + .map_or_else(|| "".to_owned(), |tex| tex.name.clone()) + } +} + +impl From<&TextureHandle> for TextureId { + #[inline(always)] + fn from(handle: &TextureHandle) -> Self { + handle.id() + } +} + +impl From<&mut TextureHandle> for TextureId { + #[inline(always)] + fn from(handle: &mut TextureHandle) -> Self { + handle.id() + } +} diff --git a/crates/accshift-egui/vendor/epaint/src/textures.rs b/crates/accshift-egui/vendor/epaint/src/textures.rs new file mode 100644 index 0000000..594cada --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/textures.rs @@ -0,0 +1,380 @@ +use crate::{ImageData, ImageDelta, TextureId}; +use ahash::{HashMap, HashSet}; +use core::mem; +use smallvec::{SmallVec, smallvec}; + +// ---------------------------------------------------------------------------- + +/// Low-level manager for allocating textures. +/// +/// Communicates with the painting subsystem using [`Self::take_delta`]. +#[derive(Default)] +pub struct TextureManager { + /// We allocate texture id:s linearly. + next_id: u64, + + /// Information about currently allocated textures. + metas: ahash::HashMap, + + delta: TexturesDelta, +} + +impl TextureManager { + /// Allocate a new texture. + /// + /// The given name can be useful for later debugging. + /// + /// The returned [`TextureId`] will be [`TextureId::Managed`], with an index + /// starting from zero and increasing with each call to [`Self::alloc`]. + /// + /// The first texture you allocate will be `TextureId::Managed(0) == TextureId::default()` and + /// MUST have a white pixel at (0,0) ([`crate::WHITE_UV`]). + /// + /// The texture is given a retain-count of `1`, requiring one call to [`Self::free`] to free it. + pub fn alloc(&mut self, name: String, image: ImageData, options: TextureOptions) -> TextureId { + let id = TextureId::Managed(self.next_id); + self.next_id += 1; + + self.metas.entry(id).or_insert_with(|| TextureMeta { + name, + size: image.size(), + bytes_per_pixel: image.bytes_per_pixel(), + retain_count: 1, + options, + }); + + self.delta.push(id, ImageDelta::full(image, options)); + id + } + + /// Assign a new image to an existing texture, + /// or update a region of it. + pub fn set(&mut self, id: TextureId, delta: ImageDelta) { + if let Some(meta) = self.metas.get_mut(&id) { + if let Some(pos) = delta.pos { + debug_assert!( + pos[0] + delta.image.width() <= meta.size[0] + && pos[1] + delta.image.height() <= meta.size[1], + "Partial texture update is outside the bounds of texture {id:?}", + ); + } else { + // whole update + meta.size = delta.image.size(); + meta.bytes_per_pixel = delta.image.bytes_per_pixel(); + } + self.delta.push(id, delta); + } else { + debug_assert!(false, "Tried setting texture {id:?} which is not allocated"); + } + } + + /// Free an existing texture. + pub fn free(&mut self, id: TextureId) { + if let std::collections::hash_map::Entry::Occupied(mut entry) = self.metas.entry(id) { + let meta = entry.get_mut(); + meta.retain_count -= 1; + if meta.retain_count == 0 { + entry.remove(); + self.delta.free(id); + } + } else { + debug_assert!(false, "Tried freeing texture {id:?} which is not allocated"); + } + } + + /// Increase the retain-count of the given texture. + /// + /// For each time you call [`Self::retain`] you must call [`Self::free`] on additional time. + pub fn retain(&mut self, id: TextureId) { + if let Some(meta) = self.metas.get_mut(&id) { + meta.retain_count += 1; + } else { + debug_assert!( + false, + "Tried retaining texture {id:?} which is not allocated", + ); + } + } + + /// Take and reset changes since last frame. + /// + /// These should be applied to the painting subsystem each frame. + pub fn take_delta(&mut self) -> TexturesDelta { + core::mem::take(&mut self.delta) + } + + /// Get meta-data about a specific texture. + pub fn meta(&self, id: TextureId) -> Option<&TextureMeta> { + self.metas.get(&id) + } + + /// Get meta-data about all allocated textures in some arbitrary order. + pub fn allocated(&self) -> impl ExactSizeIterator { + self.metas.iter() + } + + /// Total number of allocated textures. + pub fn num_allocated(&self) -> usize { + self.metas.len() + } +} + +impl Drop for TextureManager { + fn drop(&mut self) { + self.delta.clear(); // Prevent a debug panic on application shutdown + } +} + +/// Meta-data about an allocated texture. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TextureMeta { + /// A human-readable name useful for debugging. + pub name: String, + + /// width x height + pub size: [usize; 2], + + /// 4 or 1 + pub bytes_per_pixel: usize, + + /// Free when this reaches zero. + pub retain_count: usize, + + /// The texture filtering mode to use when rendering. + pub options: TextureOptions, +} + +impl TextureMeta { + /// Size in bytes. + /// width x height x [`Self::bytes_per_pixel`]. + pub fn bytes_used(&self) -> usize { + self.size[0] * self.size[1] * self.bytes_per_pixel + } +} + +// ---------------------------------------------------------------------------- + +/// How the texture texels are filtered. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct TextureOptions { + /// How to filter when magnifying (when texels are larger than pixels). + pub magnification: TextureFilter, + + /// How to filter when minifying (when texels are smaller than pixels). + pub minification: TextureFilter, + + /// How to wrap the texture when the texture coordinates are outside the [0, 1] range. + pub wrap_mode: TextureWrapMode, + + /// How to filter between texture mipmaps. + /// + /// Mipmaps ensures textures look smooth even when the texture is very small and pixels are much + /// larger than individual texels. + /// + /// # Notes + /// + /// - This may not be available on all backends (currently only `egui_glow`). + pub mipmap_mode: Option, +} + +impl TextureOptions { + /// Linear magnification and minification. + pub const LINEAR: Self = Self { + magnification: TextureFilter::Linear, + minification: TextureFilter::Linear, + wrap_mode: TextureWrapMode::ClampToEdge, + mipmap_mode: None, + }; + + /// Nearest magnification and minification. + pub const NEAREST: Self = Self { + magnification: TextureFilter::Nearest, + minification: TextureFilter::Nearest, + wrap_mode: TextureWrapMode::ClampToEdge, + mipmap_mode: None, + }; + + /// Linear magnification and minification, but with the texture repeated. + pub const LINEAR_REPEAT: Self = Self { + magnification: TextureFilter::Linear, + minification: TextureFilter::Linear, + wrap_mode: TextureWrapMode::Repeat, + mipmap_mode: None, + }; + + /// Linear magnification and minification, but with the texture mirrored and repeated. + pub const LINEAR_MIRRORED_REPEAT: Self = Self { + magnification: TextureFilter::Linear, + minification: TextureFilter::Linear, + wrap_mode: TextureWrapMode::MirroredRepeat, + mipmap_mode: None, + }; + + /// Nearest magnification and minification, but with the texture repeated. + pub const NEAREST_REPEAT: Self = Self { + magnification: TextureFilter::Nearest, + minification: TextureFilter::Nearest, + wrap_mode: TextureWrapMode::Repeat, + mipmap_mode: None, + }; + + /// Nearest magnification and minification, but with the texture mirrored and repeated. + pub const NEAREST_MIRRORED_REPEAT: Self = Self { + magnification: TextureFilter::Nearest, + minification: TextureFilter::Nearest, + wrap_mode: TextureWrapMode::MirroredRepeat, + mipmap_mode: None, + }; + + pub const fn with_mipmap_mode(self, mipmap_mode: Option) -> Self { + Self { + mipmap_mode, + ..self + } + } +} + +impl Default for TextureOptions { + /// The default is linear for both magnification and minification. + fn default() -> Self { + Self::LINEAR + } +} + +/// How the texture texels are filtered. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum TextureFilter { + /// Show the nearest pixel value. + /// + /// When zooming in you will get sharp, square pixels/texels. + /// When zooming out you will get a very crisp (and aliased) look. + Nearest, + + /// Linearly interpolate the nearest neighbors, creating a smoother look when zooming in and out. + Linear, +} + +/// Defines how textures are wrapped around objects when texture coordinates fall outside the [0, 1] range. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub enum TextureWrapMode { + /// Stretches the edge pixels to fill beyond the texture's bounds. + /// + /// This is what you want to use for a normal image in a GUI. + #[default] + ClampToEdge, + + /// Tiles the texture across the surface, repeating it horizontally and vertically. + Repeat, + + /// Mirrors the texture with each repetition, creating symmetrical tiling. + MirroredRepeat, +} + +// ---------------------------------------------------------------------------- + +/// What has been allocated and freed during the last period. +/// +/// These are commands given to the integration painter. +#[derive(Clone, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[must_use = "The painter must take care of this"] +pub struct TexturesDelta { + /// New or changed textures. Apply before painting. + pub set: HashMap>, + + /// Textures to free after painting. + pub free: HashSet, +} + +impl TexturesDelta { + pub fn is_empty(&self) -> bool { + self.set.is_empty() && self.free.is_empty() + } + + /// Inserts a [`ImageDelta`]. + /// + /// If this [`TexturesDelta`] already contains this [`TextureId`], and this is a `whole` delta, + /// the previous deltas for this id are discarded. + pub fn push(&mut self, id: TextureId, delta: ImageDelta) { + if delta.is_whole() { + // It replaces the whole texture, fine to overwrite any previous deltas + self.set.insert(id, smallvec![delta]); + } else { + self.set.entry(id).or_default().push(delta); + } + } + + pub fn free(&mut self, id: TextureId) { + self.free.insert(id); + } + + #[expect(clippy::iter_over_hash_type)] + pub fn append(&mut self, mut newer: Self) { + // Only clear previous entries on append, not on set, since within a frame a texture might + // be created and immediately removed again. + for id in &newer.free { + self.set.remove(id); + } + for (id, deltas) in newer.set.drain() { + for delta in deltas { + self.push(id, delta); + } + } + self.free.extend(mem::take(&mut newer.free)); + } + + pub fn clear(&mut self) { + self.set.clear(); + self.free.clear(); + } +} + +impl Drop for TexturesDelta { + fn drop(&mut self) { + debug_assert!( + self.is_empty(), + "Dropped TexturesDelta with {} unapplied deltas. Deltas need to be handled. \ + If you want to drop this intentionally call `clear` before dropping.", + self.free.len() + self.set.len() + ); + } +} + +impl core::fmt::Debug for TexturesDelta { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + use core::fmt::Write as _; + + let mut debug_struct = f.debug_struct("TexturesDelta"); + if !self.set.is_empty() { + let mut string = String::new(); + #[expect(clippy::iter_over_hash_type)] + for (tex_id, deltas) in &self.set { + for delta in deltas { + let size = delta.image.size(); + if let Some(pos) = delta.pos { + write!( + string, + "{:?} partial ([{} {}] - [{} {}]), ", + tex_id, + pos[0], + pos[1], + pos[0] + size[0], + pos[1] + size[1] + ) + .ok(); + } else { + write!(string, "{:?} full {}x{}, ", tex_id, size[0], size[1]).ok(); + } + } + } + debug_struct.field("set", &string); + } + if !self.free.is_empty() { + debug_struct.field("free", &self.free); + } + debug_struct.finish() + } +} diff --git a/crates/accshift-egui/vendor/epaint/src/util/mod.rs b/crates/accshift-egui/vendor/epaint/src/util/mod.rs new file mode 100644 index 0000000..ad324ad --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/util/mod.rs @@ -0,0 +1,12 @@ +/// Hash the given value with a predictable hasher. +#[inline] +pub fn hash(value: impl core::hash::Hash) -> u64 { + ahash::RandomState::with_seeds(1, 2, 3, 4).hash_one(value) +} + +/// Hash the given value with the given hasher. +#[inline] +pub fn hash_with(value: impl core::hash::Hash, mut hasher: impl core::hash::Hasher) -> u64 { + value.hash(&mut hasher); + hasher.finish() +} diff --git a/crates/accshift-egui/vendor/epaint/src/viewport.rs b/crates/accshift-egui/vendor/epaint/src/viewport.rs new file mode 100644 index 0000000..01011b5 --- /dev/null +++ b/crates/accshift-egui/vendor/epaint/src/viewport.rs @@ -0,0 +1,54 @@ +use crate::Rect; + +/// Size of the viewport in whole, physical pixels. +pub struct ViewportInPixels { + /// Physical pixel offset for left side of the viewport. + pub left_px: i32, + + /// Physical pixel offset for top side of the viewport. + pub top_px: i32, + + /// Physical pixel offset for bottom side of the viewport. + /// + /// This is what `glViewport`, `glScissor` etc expects for the y axis. + pub from_bottom_px: i32, + + /// Viewport width in physical pixels. + pub width_px: i32, + + /// Viewport height in physical pixels. + pub height_px: i32, +} + +impl ViewportInPixels { + /// Convert from ui points. + pub fn from_points(rect: &Rect, pixels_per_point: f32, screen_size_px: [u32; 2]) -> Self { + // Fractional pixel values for viewports are generally valid, but may cause sampling issues + // and rounding errors might cause us to get out of bounds. + + // Round: + let left_px = (pixels_per_point * rect.min.x).round() as i32; // inclusive + let top_px = (pixels_per_point * rect.min.y).round() as i32; // inclusive + let right_px = (pixels_per_point * rect.max.x).round() as i32; // exclusive + let bottom_px = (pixels_per_point * rect.max.y).round() as i32; // exclusive + + // Clamp to screen: + let screen_width = screen_size_px[0] as i32; + let screen_height = screen_size_px[1] as i32; + let left_px = left_px.clamp(0, screen_width); + let right_px = right_px.clamp(left_px, screen_width); + let top_px = top_px.clamp(0, screen_height); + let bottom_px = bottom_px.clamp(top_px, screen_height); + + let width_px = right_px - left_px; + let height_px = bottom_px - top_px; + + Self { + left_px, + top_px, + from_bottom_px: screen_height - height_px - top_px, + width_px, + height_px, + } + } +} From e33a186a391d287fc4df6a9a7ed6b05f206634ea Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:25:34 +0200 Subject: [PATCH 032/106] feat(egui): port the workspace grid, folders, avatar pipeline and settings controls --- crates/accshift-egui/Cargo.lock | 350 +++- .../scenes/workspace-deep-glass-dark-100.json | 162 ++ .../scenes/workspace-deep-glass-dark-150.json | 162 ++ .../scenes/workspace-deep-light-100.json | 162 ++ .../scenes/workspace-deep-light-150.json | 162 ++ .../scenes/workspace-glass-dark-100.json | 98 + .../scenes/workspace-glass-dark-150.json | 98 + .../parity/scenes/workspace-huge-100.json | 38 + .../parity/scenes/workspace-huge-150.json | 38 + .../parity/scenes/workspace-light-100.json | 98 + .../parity/scenes/workspace-light-150.json | 98 + .../workspace-sections-glass-dark-100.json | 146 ++ .../workspace-sections-glass-dark-150.json | 146 ++ .../scenes/workspace-sections-light-100.json | 146 ++ .../scenes/workspace-sections-light-150.json | 146 ++ crates/accshift-egui/src/app/accounts.rs | 280 +++ crates/accshift-egui/src/app/avatars.rs | 254 +++ crates/accshift-egui/src/app/mod.rs | 169 +- .../accshift-egui/src/app/settings_panel.rs | 1048 +++++++++++ crates/accshift-egui/src/app/shell.rs | 56 +- crates/accshift-egui/src/app/vars.rs | 96 +- .../accshift-egui/src/screens/main_spike.rs | 1125 +----------- crates/accshift-egui/src/screens/mod.rs | 1 + crates/accshift-egui/src/screens/workspace.rs | 1614 +++++++++++++++++ .../src/ui/components/account_card.rs | 375 ++++ .../src/ui/components/avatar_fade_in.rs | 66 + .../src/ui/components/back_card.rs | 77 + .../src/ui/components/breadcrumb.rs | 202 +++ .../src/ui/components/controls/button.rs | 208 +++ .../src/ui/components/controls/card.rs | 111 ++ .../src/ui/components/controls/mod.rs | 69 + .../src/ui/components/controls/scroll.rs | 85 + .../src/ui/components/controls/select.rs | 274 +++ .../src/ui/components/controls/slider.rs | 166 ++ .../src/ui/components/controls/text_input.rs | 228 +++ .../src/ui/components/controls/toggle.rs | 230 +++ .../accshift-egui/src/ui/components/focus.rs | 29 + .../src/ui/components/folder_card.rs | 386 ++++ crates/accshift-egui/src/ui/components/mod.rs | 10 + .../src/ui/components/section_header.rs | 313 ++++ .../src/ui/components/use_grid_layout.rs | 77 + .../src/ui/components/view_toggle.rs | 122 ++ 42 files changed, 8529 insertions(+), 1192 deletions(-) create mode 100644 crates/accshift-egui/parity/scenes/workspace-deep-glass-dark-100.json create mode 100644 crates/accshift-egui/parity/scenes/workspace-deep-glass-dark-150.json create mode 100644 crates/accshift-egui/parity/scenes/workspace-deep-light-100.json create mode 100644 crates/accshift-egui/parity/scenes/workspace-deep-light-150.json create mode 100644 crates/accshift-egui/parity/scenes/workspace-glass-dark-100.json create mode 100644 crates/accshift-egui/parity/scenes/workspace-glass-dark-150.json create mode 100644 crates/accshift-egui/parity/scenes/workspace-huge-100.json create mode 100644 crates/accshift-egui/parity/scenes/workspace-huge-150.json create mode 100644 crates/accshift-egui/parity/scenes/workspace-light-100.json create mode 100644 crates/accshift-egui/parity/scenes/workspace-light-150.json create mode 100644 crates/accshift-egui/parity/scenes/workspace-sections-glass-dark-100.json create mode 100644 crates/accshift-egui/parity/scenes/workspace-sections-glass-dark-150.json create mode 100644 crates/accshift-egui/parity/scenes/workspace-sections-light-100.json create mode 100644 crates/accshift-egui/parity/scenes/workspace-sections-light-150.json create mode 100644 crates/accshift-egui/src/app/accounts.rs create mode 100644 crates/accshift-egui/src/app/avatars.rs create mode 100644 crates/accshift-egui/src/app/settings_panel.rs create mode 100644 crates/accshift-egui/src/screens/workspace.rs create mode 100644 crates/accshift-egui/src/ui/components/account_card.rs create mode 100644 crates/accshift-egui/src/ui/components/avatar_fade_in.rs create mode 100644 crates/accshift-egui/src/ui/components/back_card.rs create mode 100644 crates/accshift-egui/src/ui/components/breadcrumb.rs create mode 100644 crates/accshift-egui/src/ui/components/controls/button.rs create mode 100644 crates/accshift-egui/src/ui/components/controls/card.rs create mode 100644 crates/accshift-egui/src/ui/components/controls/mod.rs create mode 100644 crates/accshift-egui/src/ui/components/controls/scroll.rs create mode 100644 crates/accshift-egui/src/ui/components/controls/select.rs create mode 100644 crates/accshift-egui/src/ui/components/controls/slider.rs create mode 100644 crates/accshift-egui/src/ui/components/controls/text_input.rs create mode 100644 crates/accshift-egui/src/ui/components/controls/toggle.rs create mode 100644 crates/accshift-egui/src/ui/components/focus.rs create mode 100644 crates/accshift-egui/src/ui/components/folder_card.rs create mode 100644 crates/accshift-egui/src/ui/components/section_header.rs create mode 100644 crates/accshift-egui/src/ui/components/use_grid_layout.rs create mode 100644 crates/accshift-egui/src/ui/components/view_toggle.rs diff --git a/crates/accshift-egui/Cargo.lock b/crates/accshift-egui/Cargo.lock index 15c2f9d..d0d2f7b 100644 --- a/crates/accshift-egui/Cargo.lock +++ b/crates/accshift-egui/Cargo.lock @@ -122,17 +122,33 @@ dependencies = [ "accshift-core", "base64 0.23.1", "dirs", + "dunce", + "flate2", "image", + "infer", "jiff", + "minisign-verify", + "osakit", + "percent-encoding", + "plist", "reqwest", "rfd", "rusqlite", + "rust-ini", "rusty-leveldb", + "semver", "serde", "serde_json", "sha2 0.11.0", + "tar", + "tempfile", + "time", "tokio", + "url", + "windows-registry", "windows-sys 0.61.2", + "zbus 5.19.0", + "zip", ] [[package]] @@ -252,6 +268,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arboard" version = "3.6.1" @@ -482,7 +507,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -593,6 +618,12 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -676,6 +707,17 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -767,6 +809,26 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "constify" version = "0.0.1" @@ -995,6 +1057,23 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "digest" version = "0.10.7" @@ -1077,6 +1156,15 @@ dependencies = [ "libloading", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "document-features" version = "0.2.12" @@ -1409,6 +1497,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.10" @@ -1431,6 +1529,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.2.0" @@ -1583,7 +1687,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ "rustix 1.1.4", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1755,6 +1859,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.16.1" @@ -2063,6 +2173,15 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + [[package]] name = "inout" version = "0.1.4" @@ -2144,7 +2263,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2198,7 +2317,7 @@ dependencies = [ "simd_cesu8", "thiserror 2.0.20", "walkdir", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2328,7 +2447,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2429,6 +2548,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2551,6 +2676,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.47" @@ -2779,6 +2910,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.1", "block2 0.6.2", + "libc", "objc2 0.6.4", "objc2-core-foundation", ] @@ -2828,6 +2960,18 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-quartz-core" version = "0.2.2" @@ -2962,6 +3106,16 @@ dependencies = [ "libredox", ] +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -2972,6 +3126,20 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.20", +] + [[package]] name = "parking" version = "2.2.1" @@ -2998,7 +3166,7 @@ dependencies = [ "libc", "redox_syscall 0.5.18", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3128,6 +3296,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" +dependencies = [ + "base64 0.23.1", + "indexmap", + "quick-xml 0.42.0", + "serde", + "time", +] + [[package]] name = "png" version = "0.17.16" @@ -3207,6 +3388,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -3261,6 +3448,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.11" @@ -3603,6 +3799,16 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rustc-demangle" version = "0.1.28" @@ -4203,6 +4409,17 @@ dependencies = [ "windows", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -4270,6 +4487,45 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -4524,6 +4780,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -4813,7 +5070,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -4956,9 +5213,9 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -4968,7 +5225,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ "windows-core", - "windows-link", + "windows-link 0.2.1", "windows-threading", ] @@ -4994,6 +5251,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" @@ -5007,7 +5270,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ "windows-core", - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -5016,7 +5299,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -5025,7 +5317,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -5061,7 +5353,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -5086,7 +5378,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link", + "windows-link 0.2.1", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -5103,7 +5395,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -5112,7 +5404,7 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -5325,6 +5617,16 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "xcursor" version = "0.3.11" @@ -5651,6 +5953,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap", + "memchr", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/crates/accshift-egui/parity/scenes/workspace-deep-glass-dark-100.json b/crates/accshift-egui/parity/scenes/workspace-deep-glass-dark-100.json new file mode 100644 index 0000000..125c208 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-deep-glass-dark-100.json @@ -0,0 +1,162 @@ +{ + "id": "workspace-deep-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "FOLDERS": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "w1a-ranked", + "name": "Ranked", + "parentId": "demo-folder-smurfs", + "platform": "steam" + }, + { + "id": "w1a-season", + "name": "Season 2026", + "parentId": "w1a-ranked", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "folder", + "id": "w1a-ranked" + } + ], + "w1a-ranked": [ + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "folder", + "id": "w1a-season" + } + ], + "w1a-season": [ + { + "type": "account", + "id": "76561198000000017" + } + ] + } + } + }, + "steps": [ + { + "wait": 600 + }, + { + "click": [95, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [205, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [205, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "deep" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/workspace-deep-glass-dark-150.json b/crates/accshift-egui/parity/scenes/workspace-deep-glass-dark-150.json new file mode 100644 index 0000000..1d4cb13 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-deep-glass-dark-150.json @@ -0,0 +1,162 @@ +{ + "id": "workspace-deep-glass-dark-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "FOLDERS": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "w1a-ranked", + "name": "Ranked", + "parentId": "demo-folder-smurfs", + "platform": "steam" + }, + { + "id": "w1a-season", + "name": "Season 2026", + "parentId": "w1a-ranked", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "folder", + "id": "w1a-ranked" + } + ], + "w1a-ranked": [ + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "folder", + "id": "w1a-season" + } + ], + "w1a-season": [ + { + "type": "account", + "id": "76561198000000017" + } + ] + } + } + }, + "steps": [ + { + "wait": 600 + }, + { + "click": [95, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [205, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [205, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "deep" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/workspace-deep-light-100.json b/crates/accshift-egui/parity/scenes/workspace-deep-light-100.json new file mode 100644 index 0000000..3884f4e --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-deep-light-100.json @@ -0,0 +1,162 @@ +{ + "id": "workspace-deep-light-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "light", + "locale": "en", + "seed": { + "FOLDERS": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "w1a-ranked", + "name": "Ranked", + "parentId": "demo-folder-smurfs", + "platform": "steam" + }, + { + "id": "w1a-season", + "name": "Season 2026", + "parentId": "w1a-ranked", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "folder", + "id": "w1a-ranked" + } + ], + "w1a-ranked": [ + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "folder", + "id": "w1a-season" + } + ], + "w1a-season": [ + { + "type": "account", + "id": "76561198000000017" + } + ] + } + } + }, + "steps": [ + { + "wait": 600 + }, + { + "click": [95, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [205, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [205, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "deep" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/workspace-deep-light-150.json b/crates/accshift-egui/parity/scenes/workspace-deep-light-150.json new file mode 100644 index 0000000..226cfcc --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-deep-light-150.json @@ -0,0 +1,162 @@ +{ + "id": "workspace-deep-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "seed": { + "FOLDERS": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "w1a-ranked", + "name": "Ranked", + "parentId": "demo-folder-smurfs", + "platform": "steam" + }, + { + "id": "w1a-season", + "name": "Season 2026", + "parentId": "w1a-ranked", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "folder", + "id": "w1a-ranked" + } + ], + "w1a-ranked": [ + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "folder", + "id": "w1a-season" + } + ], + "w1a-season": [ + { + "type": "account", + "id": "76561198000000017" + } + ] + } + } + }, + "steps": [ + { + "wait": 600 + }, + { + "click": [95, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [205, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [205, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "deep" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/workspace-glass-dark-100.json b/crates/accshift-egui/parity/scenes/workspace-glass-dark-100.json new file mode 100644 index 0000000..3d4c372 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-glass-dark-100.json @@ -0,0 +1,98 @@ +{ + "id": "workspace-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 400 + }, + { + "shot": "root" + }, + { + "move": [95, 152] + }, + { + "wait": 500 + }, + { + "shot": "hover-folder" + }, + { + "move": [783, 60] + }, + { + "wait": 300 + }, + { + "shot": "hover-grid" + }, + { + "move": [815, 60] + }, + { + "wait": 300 + }, + { + "shot": "hover-list" + }, + { + "click": [95, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "smurfs" + }, + { + "move": [95, 152] + }, + { + "wait": 500 + }, + { + "shot": "hover-back" + }, + { + "click": [95, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [639, 60] + }, + { + "text": "ra" + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "search" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/workspace-glass-dark-150.json b/crates/accshift-egui/parity/scenes/workspace-glass-dark-150.json new file mode 100644 index 0000000..b6ff06b --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-glass-dark-150.json @@ -0,0 +1,98 @@ +{ + "id": "workspace-glass-dark-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 400 + }, + { + "shot": "root" + }, + { + "move": [95, 152] + }, + { + "wait": 500 + }, + { + "shot": "hover-folder" + }, + { + "move": [783, 60] + }, + { + "wait": 300 + }, + { + "shot": "hover-grid" + }, + { + "move": [815, 60] + }, + { + "wait": 300 + }, + { + "shot": "hover-list" + }, + { + "click": [95, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "smurfs" + }, + { + "move": [95, 152] + }, + { + "wait": 500 + }, + { + "shot": "hover-back" + }, + { + "click": [95, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [639, 60] + }, + { + "text": "ra" + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "search" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/workspace-huge-100.json b/crates/accshift-egui/parity/scenes/workspace-huge-100.json new file mode 100644 index 0000000..e60d099 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-huge-100.json @@ -0,0 +1,38 @@ +{ + "id": "workspace-huge-100", + "scenario": "huge", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 400 + }, + { + "shot": "top" + }, + { + "scroll": [430, 260, 1942] + }, + { + "move": [250, 18] + }, + { + "wait": 900 + }, + { + "shot": "half" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/workspace-huge-150.json b/crates/accshift-egui/parity/scenes/workspace-huge-150.json new file mode 100644 index 0000000..3d4abdf --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-huge-150.json @@ -0,0 +1,38 @@ +{ + "id": "workspace-huge-150", + "scenario": "huge", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "steps": [ + { + "wait": 800 + }, + { + "move": [250, 18] + }, + { + "wait": 400 + }, + { + "shot": "top" + }, + { + "scroll": [430, 260, 1942] + }, + { + "move": [250, 18] + }, + { + "wait": 900 + }, + { + "shot": "half" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/workspace-light-100.json b/crates/accshift-egui/parity/scenes/workspace-light-100.json new file mode 100644 index 0000000..ea0cb08 --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-light-100.json @@ -0,0 +1,98 @@ +{ + "id": "workspace-light-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 400 + }, + { + "shot": "root" + }, + { + "move": [95, 152] + }, + { + "wait": 500 + }, + { + "shot": "hover-folder" + }, + { + "move": [783, 60] + }, + { + "wait": 300 + }, + { + "shot": "hover-grid" + }, + { + "move": [815, 60] + }, + { + "wait": 300 + }, + { + "shot": "hover-list" + }, + { + "click": [95, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "smurfs" + }, + { + "move": [95, 152] + }, + { + "wait": 500 + }, + { + "shot": "hover-back" + }, + { + "click": [95, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [639, 60] + }, + { + "text": "ra" + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "search" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/workspace-light-150.json b/crates/accshift-egui/parity/scenes/workspace-light-150.json new file mode 100644 index 0000000..9e1e6bf --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-light-150.json @@ -0,0 +1,98 @@ +{ + "id": "workspace-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 400 + }, + { + "shot": "root" + }, + { + "move": [95, 152] + }, + { + "wait": 500 + }, + { + "shot": "hover-folder" + }, + { + "move": [783, 60] + }, + { + "wait": 300 + }, + { + "shot": "hover-grid" + }, + { + "move": [815, 60] + }, + { + "wait": 300 + }, + { + "shot": "hover-list" + }, + { + "click": [95, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "smurfs" + }, + { + "move": [95, 152] + }, + { + "wait": 500 + }, + { + "shot": "hover-back" + }, + { + "click": [95, 152] + }, + { + "move": [250, 18] + }, + { + "wait": 500 + }, + { + "click": [639, 60] + }, + { + "text": "ra" + }, + { + "move": [250, 18] + }, + { + "wait": 700 + }, + { + "shot": "search" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/workspace-sections-glass-dark-100.json b/crates/accshift-egui/parity/scenes/workspace-sections-glass-dark-100.json new file mode 100644 index 0000000..506231d --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-sections-glass-dark-100.json @@ -0,0 +1,146 @@ +{ + "id": "workspace-sections-glass-dark-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "FOLDERS": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "w1a-ranked", + "name": "Ranked", + "parentId": "demo-folder-smurfs", + "platform": "steam" + }, + { + "id": "w1a-season", + "name": "Season 2026", + "parentId": "w1a-ranked", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "folder", + "id": "w1a-ranked" + } + ], + "w1a-ranked": [ + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "folder", + "id": "w1a-season" + } + ], + "w1a-season": [ + { + "type": "account", + "id": "76561198000000017" + } + ] + } + }, + "SETTINGS": { + "accountDisplay": { + "expandedFolders": true + } + } + }, + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 600 + }, + { + "shot": "sections" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/workspace-sections-glass-dark-150.json b/crates/accshift-egui/parity/scenes/workspace-sections-glass-dark-150.json new file mode 100644 index 0000000..caba0de --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-sections-glass-dark-150.json @@ -0,0 +1,146 @@ +{ + "id": "workspace-sections-glass-dark-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "glass-dark", + "locale": "en", + "seed": { + "FOLDERS": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "w1a-ranked", + "name": "Ranked", + "parentId": "demo-folder-smurfs", + "platform": "steam" + }, + { + "id": "w1a-season", + "name": "Season 2026", + "parentId": "w1a-ranked", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "folder", + "id": "w1a-ranked" + } + ], + "w1a-ranked": [ + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "folder", + "id": "w1a-season" + } + ], + "w1a-season": [ + { + "type": "account", + "id": "76561198000000017" + } + ] + } + }, + "SETTINGS": { + "accountDisplay": { + "expandedFolders": true + } + } + }, + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 600 + }, + { + "shot": "sections" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/workspace-sections-light-100.json b/crates/accshift-egui/parity/scenes/workspace-sections-light-100.json new file mode 100644 index 0000000..320534c --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-sections-light-100.json @@ -0,0 +1,146 @@ +{ + "id": "workspace-sections-light-100", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1 + }, + "theme": "light", + "locale": "en", + "seed": { + "FOLDERS": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "w1a-ranked", + "name": "Ranked", + "parentId": "demo-folder-smurfs", + "platform": "steam" + }, + { + "id": "w1a-season", + "name": "Season 2026", + "parentId": "w1a-ranked", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "folder", + "id": "w1a-ranked" + } + ], + "w1a-ranked": [ + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "folder", + "id": "w1a-season" + } + ], + "w1a-season": [ + { + "type": "account", + "id": "76561198000000017" + } + ] + } + }, + "SETTINGS": { + "accountDisplay": { + "expandedFolders": true + } + } + }, + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 600 + }, + { + "shot": "sections" + } + ] +} diff --git a/crates/accshift-egui/parity/scenes/workspace-sections-light-150.json b/crates/accshift-egui/parity/scenes/workspace-sections-light-150.json new file mode 100644 index 0000000..d6f476b --- /dev/null +++ b/crates/accshift-egui/parity/scenes/workspace-sections-light-150.json @@ -0,0 +1,146 @@ +{ + "id": "workspace-sections-light-150", + "scenario": "demo", + "os": "windows", + "viewport": { + "width": 860, + "height": 440, + "scale": 1.5 + }, + "theme": "light", + "locale": "en", + "seed": { + "FOLDERS": { + "version": 1, + "folders": [ + { + "id": "demo-folder-smurfs", + "name": "Smurfs", + "parentId": null, + "platform": "steam" + }, + { + "id": "w1a-ranked", + "name": "Ranked", + "parentId": "demo-folder-smurfs", + "platform": "steam" + }, + { + "id": "w1a-season", + "name": "Season 2026", + "parentId": "w1a-ranked", + "platform": "steam" + } + ], + "itemOrder": { + "root:steam": [ + { + "type": "account", + "id": "76561198000000001" + }, + { + "type": "account", + "id": "76561198000000002" + }, + { + "type": "account", + "id": "76561198000000003" + }, + { + "type": "account", + "id": "76561198000000004" + }, + { + "type": "account", + "id": "76561198000000005" + }, + { + "type": "account", + "id": "76561198000000006" + }, + { + "type": "account", + "id": "76561198000000007" + }, + { + "type": "account", + "id": "76561198000000008" + }, + { + "type": "account", + "id": "76561198000000009" + }, + { + "type": "account", + "id": "76561198000000010" + }, + { + "type": "account", + "id": "76561198000000011" + }, + { + "type": "account", + "id": "76561198000000012" + }, + { + "type": "account", + "id": "76561198000000013" + }, + { + "type": "folder", + "id": "demo-folder-smurfs" + } + ], + "demo-folder-smurfs": [ + { + "type": "account", + "id": "76561198000000014" + }, + { + "type": "account", + "id": "76561198000000015" + }, + { + "type": "folder", + "id": "w1a-ranked" + } + ], + "w1a-ranked": [ + { + "type": "account", + "id": "76561198000000016" + }, + { + "type": "folder", + "id": "w1a-season" + } + ], + "w1a-season": [ + { + "type": "account", + "id": "76561198000000017" + } + ] + } + }, + "SETTINGS": { + "accountDisplay": { + "expandedFolders": true + } + } + }, + "steps": [ + { + "wait": 600 + }, + { + "move": [250, 18] + }, + { + "wait": 600 + }, + { + "shot": "sections" + } + ] +} diff --git a/crates/accshift-egui/src/app/accounts.rs b/crates/accshift-egui/src/app/accounts.rs new file mode 100644 index 0000000..9bdc4be --- /dev/null +++ b/crates/accshift-egui/src/app/accounts.rs @@ -0,0 +1,280 @@ +//! The account side of the workspace: M2's loading and display pipeline +//! per platform tab, the folder navigation and search, and the avatar +//! textures. One [`Accounts`] lives in [`super::Booted`]. +//! +//! A frame, before drawing ([`Accounts::update`]): poll the loading jobs +//! with the previous frame's visible ids, tick the search debounce, compute +//! the display lists, feed `track_priming` the new visible ids, then build +//! the [`DisplayView`] the workspace draws. Repaint deadlines from the loader +//! and the debounce are requested from egui. + +use std::sync::Arc; +use std::time::Instant; + +use accshift_app::accounts::loader::LoadOptions; +use accshift_app::accounts::loading::{AccountLoading, LoadingContext}; +use accshift_app::accounts::profile_cache::{ProfileCaches, UrlPolicy}; +use accshift_app::accounts::LoadingEvent; +use accshift_app::display::{ + build_display_view, CardContext, DisplayBody, DisplayLists, DisplayPipeline, DisplayView, + DragPreview, FolderNavigation, PipelineInput, +}; +use accshift_app::jobs::Jobs; +use accshift_app::platforms::descriptors::PlatformDescriptor; +use accshift_app::platforms::{self as app_platforms, PlatformRegistry}; +use accshift_app::stores::ClientStores; +use accshift_app::time::Clock; +use egui::Context; +use serde_json::Value; + +use super::avatars::AvatarTextures; +use super::platforms::RuntimeOs; + +/// `RuntimeOs` of the egui shell in the app crate's type. +pub fn app_os(os: RuntimeOs) -> app_platforms::RuntimeOs { + match os { + RuntimeOs::Windows => app_platforms::RuntimeOs::Windows, + RuntimeOs::Linux => app_platforms::RuntimeOs::Linux, + RuntimeOs::Macos => app_platforms::RuntimeOs::Macos, + RuntimeOs::Unknown => app_platforms::RuntimeOs::Unknown, + } +} + +/// `loadAccounts(silent, showRefreshedToast, forceRefresh, checkBans, +/// deferBackground)`. +fn load_options(silent: bool, defer_background: bool) -> LoadOptions { + LoadOptions { + silent, + show_refreshed_toast: false, + force_refresh: false, + check_bans: false, + defer_background, + } +} + +/// Everything the workspace reads, owned by the model. +pub struct Accounts { + pub registry: PlatformRegistry, + pub loading: AccountLoading, + pub pipeline: DisplayPipeline, + pub navigation: FolderNavigation, + pub clock: Clock, + pub runtime_os: app_platforms::RuntimeOs, + /// The last computed lists (their `visible_account_ids` feed the next + /// poll, as the grid's `visibleRenderedAccountIds` does in TS). + pub lists: DisplayLists, + pub view: DisplayView, + /// The view drawn last frame: a collapsing section slides its cards out + /// from it. + pub prev_view: DisplayView, + pub avatars: AvatarTextures, +} + +impl Accounts { + /// The boot wiring: registry with the user platforms, the loader on the + /// active tab, and the first `loadAccounts(false, false, false, false, + /// true)` of `App.svelte`'s mount. + pub fn boot( + jobs: &Jobs, + stores: &mut ClientStores, + user_platforms: &[Value], + active_tab: &str, + runtime_os: RuntimeOs, + clock: Clock, + mock: bool, + ) -> Accounts { + let mut registry = PlatformRegistry::shipped(); + let descriptors: Vec = user_platforms + .iter() + .filter_map(PlatformDescriptor::from_value) + .collect(); + registry.register_user_platforms(&descriptors); + // The mock's avatars are local SVG files; the real backend only + // trusts http(s). + let policy = if mock { + UrlPolicy::HttpOrFile + } else { + UrlPolicy::HttpOnly + }; + let caches = Arc::new(ProfileCaches::new(policy, clock.now_ms())); + let loading = AccountLoading::new(Some(active_tab.to_string()), caches); + let mut accounts = Accounts { + registry, + loading, + pipeline: DisplayPipeline::new(), + navigation: FolderNavigation::new(), + clock, + runtime_os: app_os(runtime_os), + lists: DisplayLists::default(), + view: DisplayView { + error: None, + body: DisplayBody::Loading, + }, + prev_view: DisplayView { + error: None, + body: DisplayBody::Loading, + }, + avatars: AvatarTextures::new(policy), + }; + accounts + .navigation + .refresh_current_items(stores, active_tab); + accounts.load(jobs, stores, active_tab, load_options(false, true)); + accounts + } + + fn load(&mut self, jobs: &Jobs, stores: &mut ClientStores, tab: &str, options: LoadOptions) { + let visible = self.lists.visible_account_ids.clone(); + let mut ctx = LoadingContext { + jobs, + registry: &self.registry, + stores, + active_tab: tab, + runtime_os: self.runtime_os, + visible_ids: &visible, + is_searching: self.navigation.is_searching(), + clock: &self.clock, + now: Instant::now(), + }; + self.loading.load_accounts(&mut ctx, options); + } + + /// `handleTabChange` once the tab is known usable and different: + /// everything in flight dropped, the folder reset, the accounts loaded + /// silently, the search cleared. + pub fn change_tab(&mut self, jobs: &Jobs, stores: &mut ClientStores, tab: &str) { + self.loading.switch_tab(&self.registry, tab); + self.navigation.set_current_folder_id(None); + self.navigation.refresh_current_items(stores, tab); + self.load(jobs, stores, tab, load_options(true, false)); + self.set_search("", Instant::now()); + self.pipeline.clear_collapsed_sections(); + } + + /// `handleRefreshClick`'s load: the active tab again, not silent. + pub fn refresh(&mut self, jobs: &Jobs, stores: &mut ClientStores, tab: &str) { + self.load(jobs, stores, tab, load_options(false, false)); + } + + /// `navigateTo(folderId)` without history: the folder, its items. + pub fn navigate_to(&mut self, stores: &ClientStores, tab: &str, folder: Option) { + self.navigation.set_current_folder_id(folder); + self.navigation.refresh_current_items(stores, tab); + } + + /// `navigateToParentFolder`: the parent folder, and the search cleared. + pub fn navigate_back(&mut self, stores: &ClientStores, tab: &str) { + let Some(current) = self.navigation.current_folder_id().map(str::to_string) else { + return; + }; + let parent = stores + .folders() + .folder(¤t) + .and_then(|folder| folder.parent_id.clone()); + self.navigate_to(stores, tab, parent); + self.set_search("", Instant::now()); + } + + /// `navigation.searchQuery = value` and the pipeline's debounce. + pub fn set_search(&mut self, query: &str, now: Instant) { + if query == self.navigation.search_query() { + return; + } + self.navigation.set_search_query(query); + self.pipeline.set_query(query, now); + } + + /// One frame of the account side. `grid_hidden`: the workspace is not + /// drawn (settings or personas open), so nothing primes. + #[allow(clippy::too_many_arguments)] + pub fn update( + &mut self, + ctx: &Context, + jobs: &Jobs, + stores: &mut ClientStores, + tab: &str, + expanded_folders: bool, + grid_hidden: bool, + now: Instant, + ) { + let visible = std::mem::take(&mut self.lists.visible_account_ids); + let events = { + let mut lctx = LoadingContext { + jobs, + registry: &self.registry, + stores, + active_tab: tab, + runtime_os: self.runtime_os, + visible_ids: &visible, + is_searching: self.navigation.is_searching(), + clock: &self.clock, + now, + }; + self.loading.poll(&mut lctx) + }; + self.lists.visible_account_ids = visible; + for event in events { + if let LoadingEvent::AccountsLoaded { platform_id } = event { + if platform_id == tab { + self.navigation.refresh_current_items(stores, tab); + } + } + } + self.pipeline.tick(now); + self.lists = self.pipeline.compute(&PipelineInput { + navigation: &self.navigation, + drag: DragPreview::default(), + accounts: self.loading.loader.accounts(), + pending_setup_account: None, + expanded_folders, + active_tab: tab, + stores, + }); + { + let lctx = LoadingContext { + jobs, + registry: &self.registry, + stores, + active_tab: tab, + runtime_os: self.runtime_os, + visible_ids: &self.lists.visible_account_ids, + is_searching: self.navigation.is_searching(), + clock: &self.clock, + now, + }; + self.loading.track_priming(&lctx, grid_hidden); + } + let adapter = self.registry.adapter(tab); + let view = build_display_view( + &CardContext { + loader: &self.loading.loader, + stores, + navigation: &self.navigation, + platform: self.registry.definition(tab), + adapter: adapter.as_deref(), + active_tab: tab, + pending_setup_account: None, + bulk_selection: None, + drag_item: None, + collapsed_sections: self.pipeline.collapsed_sections(), + }, + &self.lists, + ); + self.prev_view = std::mem::replace(&mut self.view, view); + self.avatars.poll(ctx); + let deadlines = [ + self.loading.next_deadline(now), + self.pipeline.next_deadline(), + ]; + if let Some(at) = deadlines.into_iter().flatten().min() { + ctx.request_repaint_after(at.saturating_duration_since(now)); + } + } + + /// Nothing left to land: no job, no debounce, no avatar download. + pub fn settled(&self) -> bool { + !self.loading.loader.loading() + && !self.loading.has_pending_work() + && self.pipeline.next_deadline().is_none() + } +} diff --git a/crates/accshift-egui/src/app/avatars.rs b/crates/accshift-egui/src/app/avatars.rs new file mode 100644 index 0000000..e161d8c --- /dev/null +++ b/crates/accshift-egui/src/app/avatars.rs @@ -0,0 +1,254 @@ +//! The avatar texture cache of the workspace. +//! +//! M2's [`AvatarImages`] turns an avatar URL into an [`AvatarImage`] (a +//! local file at once, an http download through the job pool). Here each +//! image is decoded off the UI thread through [`Jobs`] at the device size +//! the card paints it, then uploaded on the UI thread, keyed by the image's +//! stable key and that size. The card fades the picture in on its own +//! (`avatar_fade_in`). +//! +//! Decoders: SVG (resvg, the mock's avatars) and PNG. JPEG, what Steam's CDN +//! serves, needs a decoder this crate does not link yet: such a picture +//! fails and the card shows its initials. + +use std::collections::HashMap; +use std::io::Cursor; +use std::sync::Arc; + +use accshift_app::accounts::avatars::{ + fetch_avatar_image, AvatarFetch, AvatarImage, AvatarImages, HttpAvatarClient, +}; +use accshift_app::accounts::profile_cache::UrlPolicy; +use accshift_app::jobs::{Jobs, Pending, Poll}; +use egui::{Color32, ColorImage, Context, TextureHandle, TextureId, TextureOptions}; + +/// What a card can draw for an avatar URL this frame. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum AvatarTexture { + Ready { + texture: TextureId, + size: [usize; 2], + }, + /// Downloading or decoding. + Pending, + /// Not drawable: the card shows its initials. + Failed, +} + +enum Entry { + Decoding(Pending>), + Ready(TextureHandle), + Failed, +} + +/// URL to image (M2) and image to texture (here). +pub struct AvatarTextures { + images: AvatarImages, + http: Option>, + downloads: Vec<(String, Pending>)>, + textures: HashMap<(String, u32), Entry>, +} + +impl AvatarTextures { + pub fn new(policy: UrlPolicy) -> Self { + Self { + images: AvatarImages::new(policy), + http: None, + downloads: Vec::new(), + textures: HashMap::new(), + } + } + + /// Lands finished downloads and decodes. Call once per frame. + pub fn poll(&mut self, ctx: &Context) { + let images = &mut self.images; + self.downloads + .retain_mut(|(url, pending)| match pending.poll() { + Poll::Waiting => true, + Poll::Ready(result) => { + images.finish(url, result); + false + } + Poll::Gone => { + images.finish(url, Err("download job died".into())); + false + } + }); + for ((key, px), entry) in self.textures.iter_mut() { + let Entry::Decoding(pending) = entry else { + continue; + }; + match pending.poll() { + Poll::Waiting => {} + Poll::Ready(Ok(image)) => { + *entry = Entry::Ready(ctx.load_texture( + format!("avatar-{key}-{px}"), + image, + TextureOptions::LINEAR, + )); + } + Poll::Ready(Err(_)) | Poll::Gone => *entry = Entry::Failed, + } + } + } + + /// The texture of `url` at `px` device pixels, starting its download or + /// decode if needed. + pub fn get(&mut self, jobs: &Jobs, url: &str, px: u32) -> AvatarTexture { + let image = match self.images.request(url) { + AvatarFetch::Ready(image) => image, + AvatarFetch::Waiting => return AvatarTexture::Pending, + AvatarFetch::Failed => return AvatarTexture::Failed, + AvatarFetch::Start(url) => { + let http = match &self.http { + Some(http) => Arc::clone(http), + None => match HttpAvatarClient::new() { + Ok(client) => { + let client = Arc::new(client); + self.http = Some(Arc::clone(&client)); + client + } + Err(error) => { + self.images.finish(&url, Err(error)); + return AvatarTexture::Failed; + } + }, + }; + let target = url.clone(); + let pending = jobs.run(move |_| fetch_avatar_image(&target, http.as_ref())); + self.downloads.push((url, pending)); + return AvatarTexture::Pending; + } + }; + let key = (image.key().to_string(), px); + let entry = self + .textures + .entry(key) + .or_insert_with(|| Entry::Decoding(jobs.run(move |_| decode(&image, px)))); + match entry { + Entry::Decoding(_) => AvatarTexture::Pending, + Entry::Ready(texture) => AvatarTexture::Ready { + texture: texture.id(), + size: texture.size(), + }, + Entry::Failed => AvatarTexture::Failed, + } + } + + /// Downloads or decodes still running. + pub fn busy(&self) -> bool { + !self.downloads.is_empty() + || self + .textures + .values() + .any(|entry| matches!(entry, Entry::Decoding(_))) + } +} + +/// Decodes an avatar for a `px` device pixel box. SVGs rasterize at that +/// size (covering it, like `object-fit: cover`); bitmaps keep theirs. +pub fn decode(image: &AvatarImage, px: u32) -> Result { + let bytes: Arc<[u8]> = match image { + AvatarImage::File { path, .. } => std::fs::read(path) + .map_err(|e| format!("{}: {e}", path.display()))? + .into(), + AvatarImage::Bytes { bytes, .. } => Arc::clone(bytes), + }; + if bytes.starts_with(b"\x89PNG") { + return decode_png(&bytes); + } + if is_svg(&bytes) { + return crate::avatars::raster(&bytes, px.max(1)); + } + Err("unsupported avatar format".into()) +} + +fn is_svg(bytes: &[u8]) -> bool { + let head = &bytes[..bytes.len().min(512)]; + let text = String::from_utf8_lossy(head); + let text = text.trim_start_matches('\u{feff}').trim_start(); + text.starts_with(" Result { + let mut decoder = png::Decoder::new(Cursor::new(bytes)); + decoder.set_transformations(png::Transformations::normalize_to_color8()); + let mut reader = decoder.read_info().map_err(|e| e.to_string())?; + let mut buf = vec![0; reader.output_buffer_size().ok_or("png too large")?]; + let info = reader.next_frame(&mut buf).map_err(|e| e.to_string())?; + let data = &buf[..info.buffer_size()]; + let (w, h) = (info.width as usize, info.height as usize); + let pixels: Vec = match info.color_type { + png::ColorType::Rgba => data + .as_chunks::<4>() + .0 + .iter() + .map(|&[r, g, b, a]| Color32::from_rgba_unmultiplied(r, g, b, a)) + .collect(), + png::ColorType::Rgb => data + .as_chunks::<3>() + .0 + .iter() + .map(|&[r, g, b]| Color32::from_rgb(r, g, b)) + .collect(), + png::ColorType::GrayscaleAlpha => data + .as_chunks::<2>() + .0 + .iter() + .map(|&[g, a]| Color32::from_rgba_unmultiplied(g, g, g, a)) + .collect(), + png::ColorType::Grayscale => data.iter().map(|&g| Color32::from_gray(g)).collect(), + png::ColorType::Indexed => return Err("indexed png not expanded".into()), + }; + if pixels.len() != w * h { + return Err("png size mismatch".into()); + } + Ok(ColorImage::new([w, h], pixels)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mock_svg_rasterizes_at_the_device_size() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../src/lib/mock/avatars/1.svg"); + let image = AvatarImage::File { + key: "1".into(), + path: root, + }; + let decoded = decode(&image, 102).unwrap(); + assert_eq!(decoded.size, [102, 102]); + } + + #[test] + fn png_round_trips() { + let mut out = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut out, 2, 1); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().unwrap(); + writer + .write_image_data(&[255, 0, 0, 255, 0, 0, 255, 128]) + .unwrap(); + } + let image = AvatarImage::Bytes { + key: "k".into(), + bytes: out.into(), + }; + let decoded = decode(&image, 68).unwrap(); + assert_eq!(decoded.size, [2, 1]); + assert_eq!(decoded.pixels[0], Color32::from_rgb(255, 0, 0)); + } + + #[test] + fn jpeg_is_reported_unsupported() { + let image = AvatarImage::Bytes { + key: "j".into(), + bytes: vec![0xff, 0xd8, 0xff, 0xe0].into(), + }; + assert!(decode(&image, 68).is_err()); + } +} diff --git a/crates/accshift-egui/src/app/mod.rs b/crates/accshift-egui/src/app/mod.rs index 4994e44..cde5927 100644 --- a/crates/accshift-egui/src/app/mod.rs +++ b/crates/accshift-egui/src/app/mod.rs @@ -20,8 +20,11 @@ //! actions. Before the boot payload lands nothing is drawn, like //! `.app-frame { opacity: 0 }` before `boot-ready`. +pub mod accounts; +pub mod avatars; pub mod clock; pub mod platforms; +pub mod settings_panel; mod shell; pub mod vars; @@ -37,7 +40,7 @@ use accshift_app::mock::MockBackend; use accshift_app::real::RealBackend; use accshift_app::settings::AnimationsMode; use accshift_app::storage::{self, StoreId}; -use accshift_app::stores::{ClientStores, StoreContext}; +use accshift_app::stores::{ClientStores, StoreContext, ViewMode}; use accshift_app::types::BootPayload; use accshift_theme::{ theme_application, window_backdrop, AppTheme, CustomThemePayload, ResolvedTheme, @@ -46,8 +49,9 @@ use accshift_theme::{ use egui::{Context, Rect, TextureHandle, Ui, ViewportCommand}; use serde_json::Value; -use crate::screens::main_spike; +use crate::screens::workspace::Workspace; use crate::ui::anim::{self, Motion}; +use accounts::Accounts; use clock::Clock; use platforms::{PlatformDef, RuntimeOs}; use vars::Vars; @@ -77,6 +81,22 @@ pub enum Action { /// Where the maximize button is, for Windows 11 Snap Layouts /// (`set_maximize_button_rect`), `None` when there is none (macOS). SetMaximizeButtonRect(Option), + /// The settings panel (`settings_panel`). + Settings(settings_panel::SettingsAction), + /// Breadcrumb, folder card or section header: `navigateTo(folderId)`, + /// `None` being the root. + NavigateToFolder(Option), + /// The back card: `navigateToParentFolder`. + NavigateBack, + /// The search input's new value. + SearchChanged(String), + SetViewMode(ViewMode), + /// A section header's chevron: its collapse key. + ToggleSection(String), + /// A card click (`onAccountActivate`): selection and focus, a later port. + AccountActivate(String), + /// `onAccountSwitch`: the switch flow is a later port. + AccountSwitch(String), } /// How a scene sets up the mock, the same fields `parity/ref.mjs` writes into @@ -201,6 +221,10 @@ pub struct Booted { /// `panelKey` and when it last changed (`{#key panelKey}` remount). pub panel: (String, f64), pub init_error: Option, + /// Accounts, display pipeline, folder navigation, avatar textures. + pub accounts: Accounts, + /// The workspace's own state (scroll, mounts, slides). + pub workspace: Workspace, } type BootResult = BackendResult; @@ -216,14 +240,19 @@ pub struct AppModel { boot_error: Option, booted: Option, saves: Vec<(StoreId, Pending>>)>, - /// The panel slot's placeholder until the workspace port replaces it. - spike: main_spike::AccshiftApp, /// The recorder's stand-in desktop under glass themes, scene runs only. desktop: Option, maximize_button: Option, + /// The open settings panel. + settings_panel: Option, + /// `addToast`'s queue (drawn by the toast port). + toasts: accshift_app::actions::toasts::ToastQueue, focus_visible: bool, last_time: f64, os_reduced_motion: bool, + /// Frames drawn, for the boot marks. + frames: u64, + ready_marked: bool, } impl AppModel { @@ -274,13 +303,7 @@ impl AppModel { let wake = ctx.clone(); let jobs = Jobs::new(Arc::clone(&backend), 2, move || wake.request_repaint()); let boot_job = Some(jobs.run(|backend| backend.get_boot_payload())); - let spike = main_spike::AccshiftApp::new( - ctx, - main_spike::Options { - desktop_backdrop: false, - embedded: true, - }, - ); + crate::ui::text::install(ctx, false); AppModel { backend, mock, @@ -291,12 +314,15 @@ impl AppModel { boot_error: None, booted: None, saves: Vec::new(), - spike, desktop: None, maximize_button: None, + settings_panel: None, + toasts: accshift_app::actions::toasts::ToastQueue::new(), focus_visible: false, last_time: 0.0, os_reduced_motion: false, + frames: 0, + ready_marked: false, } } @@ -317,6 +343,16 @@ impl AppModel { self.clock.now_ms() } + /// The open settings panel. + pub fn settings_panel(&self) -> Option<&settings_panel::SettingsPanel> { + self.settings_panel.as_ref() + } + + /// The toasts `addToast` queued. + pub fn toasts(&self) -> &accshift_app::actions::toasts::ToastQueue { + &self.toasts + } + /// Where the maximize button was last drawn (Snap Layouts hook). pub fn maximize_button_rect(&self) -> Option { self.maximize_button @@ -338,13 +374,17 @@ impl AppModel { } } - /// Ready to be photographed: booted, the boot cascade over, the panel's - /// assets uploaded. + /// Ready to be photographed: booted, the boot cascade over, the + /// accounts loaded, the panel's cards and avatars settled. pub fn ready(&self) -> bool { - self.booted - .as_ref() - .is_some_and(|b| self.last_time - b.boot_at >= shell::BOOT_CASCADE) - && self.spike.avatars_ready() + self.booted.as_ref().is_some_and(|b| { + self.last_time - b.boot_at >= shell::BOOT_CASCADE + && self.last_time - b.panel.1 >= b.theme.vars.motion_page_entrance + && b.accounts.settled() + && !b.accounts.avatars.busy() + && b.workspace + .settled(&b.theme.vars, b.theme.motion, self.last_time) + }) } fn finish_boot(&mut self, ctx: &Context, result: BootResult, now: f64) { @@ -385,7 +425,7 @@ impl AppModel { store_ctx.platform_ids = platforms.iter().map(|p| p.id.clone()).collect(); store_ctx.theme_ids = registry.all_themes().into_iter().map(|t| t.id).collect(); store_ctx.preferred_languages = preferred_languages(); - let stores = ClientStores::new(storage, store_ctx); + let mut stores = ClientStores::new(storage, store_ctx); if let Some(mock) = &self.mock { // The TS mock reads the live settings for its PIN gate. mock.set_pin_settings(&stores.settings().to_value()); @@ -404,6 +444,19 @@ impl AppModel { let theme = compute_theme(®istry, &stores, runtime_os, self.os_reduced_motion); anim::set_motion(ctx, theme.motion); let panel = (nav.panel_key(), now); + let app_clock = match &self.mock { + Some(mock) => accshift_app::time::Clock::mock(mock.now()), + None => accshift_app::time::Clock::system(), + }; + let accounts = Accounts::boot( + &self.jobs, + &mut stores, + &loaded, + &nav.active_tab, + runtime_os, + app_clock, + self.mock.is_some(), + ); self.booted = Some(Booted { runtime_os, stores, @@ -415,6 +468,8 @@ impl AppModel { boot_at: now, panel, init_error: report.load_error, + accounts, + workspace: Workspace::new(), }); // `finish_boot` (marks, the startup event); the real shell shows the // window here. Fire and forget. @@ -478,14 +533,43 @@ impl AppModel { } } self.sync_settings(&ctx); + self.tick_settings_panel(&ctx, Instant::now()); self.pump_saves(); if ctx.input(|i| i.key_pressed(egui::Key::Tab)) { self.focus_visible = true; } else if ctx.input(|i| i.pointer.any_pressed()) { self.focus_visible = false; } + if let Some(b) = self.booted.as_mut() { + let grid_hidden = b.nav.show_settings || b.nav.show_personas; + let expanded = b.stores.settings().account_display.expanded_folders; + b.accounts.update( + &ctx, + &self.jobs, + &mut b.stores, + &b.nav.active_tab, + expanded, + grid_hidden, + Instant::now(), + ); + } let actions = shell::draw(self, ui, now); self.apply(&ctx, actions, now); + // Boot marks for the startup bench. + match self.frames { + 0 => { + crate::bench::mark("ui_done"); + ctx.request_repaint(); + } + // Frame 0 has been painted and swapped by now. + 1 => crate::bench::mark("first_frame"), + _ => {} + } + self.frames += 1; + if !self.ready_marked && self.ready() { + crate::bench::mark("ready"); + self.ready_marked = true; + } } /// Applies what the frame's components asked for. @@ -497,6 +581,8 @@ impl AppModel { let key = b.nav.panel_key(); if key != b.panel.0 { b.panel = (key, now); + // `{#key panelKey}` remounts the workspace and its sections. + b.accounts.pipeline.clear_collapsed_sections(); } } } @@ -513,22 +599,50 @@ impl AppModel { // this rect so Windows 11 opens the Snap Layouts flyout. self.maximize_button = rect; } - // The workspace and add-flow ports own these. - Action::Refresh | Action::AddAccount | Action::ApplyUpdate => {} - Action::OpenSettings => { + Action::Refresh => { + if let Some(b) = self.booted.as_mut() { + let tab = b.nav.active_tab.clone(); + b.accounts.refresh(&self.jobs, &mut b.stores, &tab); + } + } + // The add-flow, updater, selection and switch ports own these. + Action::AddAccount + | Action::ApplyUpdate + | Action::AccountActivate(_) + | Action::AccountSwitch(_) => {} + Action::NavigateToFolder(folder) => { + if let Some(b) = self.booted.as_mut() { + b.accounts.navigate_to(&b.stores, &b.nav.active_tab, folder); + } + } + Action::NavigateBack => { + if let Some(b) = self.booted.as_mut() { + b.accounts.navigate_back(&b.stores, &b.nav.active_tab); + } + } + Action::SearchChanged(query) => { + if let Some(b) = self.booted.as_mut() { + b.accounts.set_search(&query, Instant::now()); + } + } + Action::SetViewMode(mode) => { + if let Some(b) = self.booted.as_mut() { + b.stores.set_view_mode(mode); + } + } + Action::ToggleSection(key) => { if let Some(b) = self.booted.as_mut() { - b.nav.show_personas = false; - b.nav.show_settings = !b.nav.show_settings; - b.nav.bulk_edit = false; + b.accounts.pipeline.toggle_section_collapsed(&key); } } + Action::OpenSettings => self.toggle_settings_panel(), + Action::Settings(action) => self.apply_settings(ctx, action), Action::OpenPersonas => { if let Some(b) = self.booted.as_mut() { if b.stores.settings().personas_enabled { b.nav.show_settings = false; b.nav.bulk_edit = false; b.nav.show_personas = true; - self.spike.show_tab("personas"); } } } @@ -547,13 +661,12 @@ impl AppModel { } if tab == b.nav.active_tab { b.nav.show_settings = false; - self.spike.show_tab(&tab); return; } b.nav.bulk_edit = false; b.nav.show_settings = false; b.nav.active_tab = tab.clone(); - self.spike.show_tab(&tab); + b.accounts.change_tab(&self.jobs, &mut b.stores, &tab); } } } diff --git a/crates/accshift-egui/src/app/settings_panel.rs b/crates/accshift-egui/src/app/settings_panel.rs new file mode 100644 index 0000000..c1cebcc --- /dev/null +++ b/crates/accshift-egui/src/app/settings_panel.rs @@ -0,0 +1,1048 @@ +//! The settings panel's state and its save flow, `Settings.svelte` and the +//! state its General and Privacy tabs keep (`SettingsPrivacyTab.svelte`'s +//! PIN and telemetry). +//! +//! The panel edits a draft of [`AppSettings`]. Every change of a tracked +//! field queues a save 220 ms later (`queueSave`); the save normalises the +//! draft, commits a typed PIN, merges the draft over the stored settings +//! with [`merge_settings_draft`] (a field another writer changed meanwhile +//! is kept), writes it through [`ClientStores::save_settings`], then pushes +//! the API key and the platform paths and toasts `settings.saved` at most +//! every 1.5 s. A change of the stored settings that the panel did not +//! write is adopted into every field the user has not edited +//! (`adoptExternalSettings`). Escape and the close button flush, then close. +//! +//! The Platforms tab and the per-platform tabs read and edit the same state +//! ([`SettingsPanel::platform_paths`], the API key draft, the numeric inputs +//! of the data refresh); they are drawn by a later port. + +use std::collections::BTreeMap; +use std::time::{Duration, Instant}; + +use accshift_app::actions::navigation::SettingsTabBar; +use accshift_app::actions::toasts::{AddToastOptions, ToastQueue, ToastType}; +use accshift_app::error::BackendResult; +use accshift_app::i18n::{Locale, TranslationValue}; +use accshift_app::jobs::{Jobs, Pending, Poll}; +use accshift_app::secure::pin::{hash_pin_code, sanitize_pin_digits, unlock_pin_session}; +use accshift_app::settings::{ + api_key_draft_after_successful_save, merge_settings_draft, AnimationsMode, AppSettings, + StreamerMode, +}; +use accshift_app::stores::ClientStores; +use accshift_app::text::Text; +use accshift_app::types::TelemetryUiState; +use egui::Context; +use serde_json::Value; + +use super::platforms::{self, PlatformDef, RuntimeOs}; +use super::AppModel; + +/// `PIN_CODE_LENGTH`. +pub const PIN_CODE_LENGTH: usize = 4; +/// `queueSave`'s debounce. +const SAVE_DEBOUNCE: Duration = Duration::from_millis(220); +/// `SAVE_TOAST_COOLDOWN_MS`. +const SAVE_TOAST_COOLDOWN: Duration = Duration::from_millis(1500); +/// `WIKI_URL`. +pub const WIKI_URL: &str = "https://github.com/klNuno/accshift/wiki"; +/// `INTEGRATIONS_WIKI_URL`. +pub const INTEGRATIONS_WIKI_URL: &str = "https://github.com/klNuno/accshift/wiki/Settings"; +/// `TELEMETRY_DOC_URL`. +pub const TELEMETRY_DOC_URL: &str = + "https://github.com/klNuno/accshift/blob/main/docs/analytics.md"; + +/// `clampInt`: rounded and clamped, `fallback` for a non-finite value. +pub fn clamp_int(value: Option, min: i64, max: i64, fallback: i64) -> i64 { + match value { + Some(v) if v.is_finite() => (v.round() as i64).clamp(min, max), + _ => fallback, + } +} + +/// `Number(string)`: trimmed, empty is 0, anything unparsable is `NaN`. +pub fn js_number(s: &str) -> Option { + let t = s.trim(); + if t.is_empty() { + return Some(0.0); + } + if t.chars() + .any(|c| c.is_ascii_alphabetic() && c != 'e' && c != 'E') + { + return None; + } + t.parse::().ok() +} + +/// `createNumericInput`: the text of a number field, committed on demand. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NumericInput { + pub input: String, + pub min: i64, + pub max: i64, +} + +impl NumericInput { + pub fn new(value: i64, min: i64, max: i64) -> Self { + Self { + input: value.to_string(), + min, + max, + } + } + + /// `commit`: the clamped value to store; `current` when the text is not + /// a number. The text shows the stored value afterwards. + pub fn commit(&mut self, current: i64) -> i64 { + let clamped = clamp_int(js_number(&self.input), self.min, self.max, current); + self.input = clamped.to_string(); + clamped + } + + /// `refresh`. + pub fn refresh(&mut self, value: i64) { + self.input = value.to_string(); + } +} + +/// The five number fields of the panel. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NumericField { + UiScale, + BgOpacity, + AvatarCacheDays, + BanCheckDays, + InactivityBlur, +} + +/// A boolean setting a toggle flips. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SettingFlag { + ShowCardNotesInline, + ExpandedFolders, + SuspendGraphicsWhenMinimized, + MinimizeOnAccountSwitch, + DeepLinks, + Cli, + /// `streamerMode`: `auto` when on, `off` when off. + StreamerMode, +} + +/// What the panel's components ask for. +#[derive(Clone, Debug, PartialEq)] +pub enum SettingsAction { + /// The close button or Escape: flush, then close. + Close, + SelectTab(String), + OpenHelp, + OpenLogs, + OpenIntegrationsWiki, + OpenTelemetryDoc, + Toggle(SettingFlag), + Language(Locale), + Animations(AnimationsMode), + Theme(String), + /// A slider's or a field's text (`oninput`). + NumericInput(NumericField, String), + /// `commit`: blur, Enter, or a slider's `change`. + NumericCommit(NumericField), + /// The new PIN field (`pinCodeInput`). + PinInput(String), + /// The current PIN field of a guarded change. + CurrentPinInput(String), + TogglePin, + ReplayOnboarding, + ExportTheme, + ToggleTelemetryA, + ToggleTelemetryB, + RetryTelemetryDeletion, + ExportTelemetry, + RetryTelemetryLoad, +} + +/// What a finished telemetry or link call does next. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Followup { + /// A failure toasts this key as an error. + ErrorToast(&'static str), + /// `toggleModeA`: a failure reloads the state. + ModeA, + /// `toggleModeB`. + ModeB { + next: bool, + }, + RetryForget, + Export, +} + +/// The mount job's result: whether a Steam API key is stored, then each enabled platform's path. +type HydrateResult = (bool, Vec<(String, String)>); + +/// A backend call queued by an action. +type BackendCall = + Box BackendResult + Send>; + +/// `SettingsPanel` state while it is open. +pub struct SettingsPanel { + /// `settings`: the draft the controls edit. + pub draft: AppSettings, + /// `persistedSettings`: the draft as last written. + persisted: AppSettings, + /// The stored settings as the panel last saw them. + seen_store: AppSettings, + last_persisted_snapshot: String, + last_platform_snapshot: String, + last_tracked: String, + save_at: Option, + last_saved_toast: Option, + hydrated: bool, + hydrate_job: Option>, + pub tab_bar: SettingsTabBar, + pub ui_scale: NumericInput, + pub bg_opacity: NumericInput, + pub avatar_cache_days: NumericInput, + pub ban_check_days: NumericInput, + pub inactivity_blur: NumericInput, + pub pin_code_input: String, + pub pin_setup_pending: bool, + pub pin_change_authorized: bool, + pub current_pin_input: String, + pub current_pin_error: String, + current_pin_busy_until: Option, + pub pin_disable_pending: bool, + pub telemetry: Option, + pub telemetry_error: bool, + telemetry_job: Option>>, + pub mode_b_busy: bool, + pub export_busy: bool, + calls: Vec<(Pending>, Followup)>, + pub api_key: String, + pub api_key_touched: bool, + pub api_key_configured: bool, + pub api_key_error: bool, + pub platform_paths: BTreeMap, + pub platform_path_errors: BTreeMap, + /// `.settings-content`'s `scrollTop`. + pub scroll: f32, + /// Text for the clipboard once the frame has the context. + clipboard: Option, +} + +/// One tab of the sidebar. +#[derive(Clone, Debug, PartialEq)] +pub struct TabDef { + pub id: String, + pub label_key: String, + /// `None` for the core tabs (their icon is drawn instead of a dot). + pub accent: Option, +} + +/// `coreTabConfig` then `platformTabConfig` (shipped platforms with a +/// settings tab, enabled, in registry order). +pub fn visible_tabs(platforms: &[PlatformDef], enabled: &[String]) -> Vec { + let mut tabs: Vec = [ + ("general", "settings.general"), + ("platforms", "settings.platforms"), + ("privacy", "settings.privacy"), + ] + .iter() + .map(|(id, key)| TabDef { + id: (*id).to_string(), + label_key: (*key).to_string(), + accent: None, + }) + .collect(); + for p in platforms { + let Some(key) = settings_tab_key(&p.id) else { + continue; + }; + if p.user_provided || !enabled.contains(&p.id) { + continue; + } + tabs.push(TabDef { + id: format!("platform:{}", p.id), + label_key: key.to_string(), + accent: Some(p.accent), + }); + } + tabs +} + +/// `settingsTabKey` of the shipped registry. +fn settings_tab_key(id: &str) -> Option<&'static str> { + Some(match id { + "steam" => "settings.steam", + "riot" => "settings.riot", + "battle-net" => "settings.battleNet", + "ubisoft" => "settings.ubisoft", + "roblox" => "settings.roblox", + "epic" => "settings.epic", + "gog" => "settings.gog", + "jagex" => "settings.jagex", + "discord" => "settings.discord", + _ => return None, + }) +} + +fn platform_snapshot(s: &AppSettings) -> String { + let mut enabled = s.enabled_platforms.clone(); + enabled.sort(); + serde_json::json!({ "enabledPlatforms": enabled, "defaultPlatformId": s.default_platform_id }) + .to_string() +} + +impl SettingsPanel { + /// `onMount`: the draft from the store, normalised, and the API key and + /// platform paths requested. + pub fn open( + stores: &ClientStores, + jobs: &Jobs, + platforms: &[PlatformDef], + os: RuntimeOs, + ) -> Self { + let mut draft = stores.settings().clone(); + normalize(&mut draft, platforms, os); + let enabled = draft.enabled_platforms.clone(); + let hydrate_job = Some(jobs.run(move |backend| { + let has_key = backend.steam_has_api_key().unwrap_or(false); + let paths = enabled + .iter() + .map(|id| { + ( + id.clone(), + backend.platform_get_path(id).unwrap_or_default(), + ) + }) + .collect(); + (has_key, paths) + })); + let mut panel = SettingsPanel { + ui_scale: NumericInput::new(draft.ui_scale_percent, 75, 150), + bg_opacity: NumericInput::new(draft.background_opacity, 0, 100), + avatar_cache_days: NumericInput::new(draft.data_refresh.avatar_cache_days, 0, 90), + ban_check_days: NumericInput::new(draft.data_refresh.ban_check_days, 0, 90), + inactivity_blur: NumericInput::new(draft.inactivity_blur_seconds, 0, 3600), + persisted: draft.clone(), + seen_store: stores.settings().clone(), + last_persisted_snapshot: String::new(), + last_platform_snapshot: platform_snapshot(&draft), + last_tracked: String::new(), + save_at: None, + last_saved_toast: None, + hydrated: false, + hydrate_job, + tab_bar: SettingsTabBar::new(), + pin_code_input: String::new(), + pin_setup_pending: false, + pin_change_authorized: false, + current_pin_input: String::new(), + current_pin_error: String::new(), + current_pin_busy_until: None, + pin_disable_pending: false, + telemetry: None, + telemetry_error: false, + telemetry_job: None, + mode_b_busy: false, + export_busy: false, + calls: Vec::new(), + api_key: String::new(), + api_key_touched: false, + api_key_configured: false, + api_key_error: false, + platform_paths: BTreeMap::new(), + platform_path_errors: BTreeMap::new(), + scroll: 0.0, + clipboard: None, + draft, + }; + panel.last_tracked = panel.tracked(); + panel + } + + /// `pinGuarded`. + pub fn pin_guarded(&self) -> bool { + self.draft.pin_enabled && !self.pin_change_authorized + } + + /// The PIN toggle's state. + pub fn pin_toggle_on(&self) -> bool { + (self.draft.pin_enabled || self.pin_setup_pending) && !self.pin_disable_pending + } + + /// The current-PIN field is waiting out a failure. + pub fn current_pin_busy(&self) -> bool { + self.current_pin_busy_until.is_some() + } + + pub fn numeric(&self, field: NumericField) -> &NumericInput { + match field { + NumericField::UiScale => &self.ui_scale, + NumericField::BgOpacity => &self.bg_opacity, + NumericField::AvatarCacheDays => &self.avatar_cache_days, + NumericField::BanCheckDays => &self.ban_check_days, + NumericField::InactivityBlur => &self.inactivity_blur, + } + } + + fn numeric_mut(&mut self, field: NumericField) -> (&mut NumericInput, &mut i64) { + let d = &mut self.draft; + match field { + NumericField::UiScale => (&mut self.ui_scale, &mut d.ui_scale_percent), + NumericField::BgOpacity => (&mut self.bg_opacity, &mut d.background_opacity), + NumericField::AvatarCacheDays => ( + &mut self.avatar_cache_days, + &mut d.data_refresh.avatar_cache_days, + ), + NumericField::BanCheckDays => { + (&mut self.ban_check_days, &mut d.data_refresh.ban_check_days) + } + NumericField::InactivityBlur => { + (&mut self.inactivity_blur, &mut d.inactivity_blur_seconds) + } + } + } + + /// The `$effect` dependencies of `queueSave`, as one string. + fn tracked(&self) -> String { + serde_json::json!({ + "settings": self.draft.to_value(), + "pin": self.pin_code_input, + "apiKey": self.api_key, + "apiKeyConfigured": self.api_key_configured, + "apiKeyTouched": self.api_key_touched, + "paths": self.platform_paths, + }) + .to_string() + } + + /// `buildPersistSnapshot`. + fn persist_snapshot(&self, settings: &AppSettings) -> String { + let pending_api_key = if self.api_key_touched { + self.api_key.trim().to_string() + } else { + String::new() + }; + let paths: BTreeMap<&String, &str> = self + .platform_paths + .iter() + .map(|(k, v)| (k, v.trim())) + .collect(); + serde_json::json!({ + "settings": settings.to_value(), + "pendingApiKey": pending_api_key, + "apiKeyConfigured": self.api_key_configured, + "platformPaths": paths, + }) + .to_string() + } + + fn refresh_numeric(&mut self) { + let d = &self.draft; + self.ui_scale.refresh(d.ui_scale_percent); + self.bg_opacity.refresh(d.background_opacity); + self.avatar_cache_days + .refresh(d.data_refresh.avatar_cache_days); + self.ban_check_days.refresh(d.data_refresh.ban_check_days); + self.inactivity_blur.refresh(d.inactivity_blur_seconds); + } + + /// `commitPinCode`: true when a typed PIN was written into the draft. + fn commit_pin_code(&mut self) -> bool { + let sanitized = sanitize_pin_digits(&self.pin_code_input); + if !self.draft.pin_enabled && !self.pin_setup_pending { + return false; + } + if self.draft.pin_enabled && !self.pin_change_authorized { + return false; + } + if sanitized.len() != PIN_CODE_LENGTH { + return false; + } + let Some(hash) = hash_pin_code(&sanitized) else { + return false; + }; + self.draft.pin_hash = hash; + self.draft.pin_enabled = true; + self.pin_setup_pending = false; + self.pin_change_authorized = true; + self.pin_code_input.clear(); + true + } + + /// `disablePin`. + fn disable_pin(&mut self) { + self.draft.pin_enabled = false; + self.pin_setup_pending = false; + self.draft.pin_hash.clear(); + self.pin_code_input.clear(); + self.pin_disable_pending = false; + } + + /// The Privacy tab mounts: its local state starts over and the + /// telemetry state is fetched (`onMount(refreshTelemetry)`). + fn mount_privacy(&mut self, jobs: &Jobs) { + self.current_pin_input.clear(); + self.current_pin_error.clear(); + self.current_pin_busy_until = None; + self.pin_disable_pending = false; + self.telemetry = None; + self.telemetry_error = false; + self.mode_b_busy = false; + self.export_busy = false; + self.refresh_telemetry(jobs); + } + + fn refresh_telemetry(&mut self, jobs: &Jobs) { + self.telemetry_job = Some(jobs.run(|backend| backend.telemetry_get_state())); + } +} + +/// `normalizeSettings`. +pub fn normalize(s: &mut AppSettings, platforms: &[PlatformDef], os: RuntimeOs) { + let clamp = |v: i64, min, max, fallback| clamp_int(Some(v as f64), min, max, fallback); + s.background_opacity = clamp(s.background_opacity, 0, 100, 100); + s.ui_scale_percent = clamp(s.ui_scale_percent, 75, 150, 100); + s.data_refresh.avatar_cache_days = clamp(s.data_refresh.avatar_cache_days, 0, 90, 7); + s.data_refresh.ban_check_days = clamp(s.data_refresh.ban_check_days, 0, 90, 7); + s.inactivity_blur_seconds = clamp(s.inactivity_blur_seconds, 0, 3600, 60); + s.platform_settings.steam.launch_options = + s.platform_settings.steam.launch_options.trim().to_string(); + if !s.pin_enabled { + s.pin_hash.clear(); + } + let implemented = |id: &str| settings_tab_key(id).is_some(); + if !platforms.iter().any(|p| { + p.id == s.default_platform_id && (implemented(&p.id) || s.enabled_platforms.contains(&p.id)) + }) { + s.default_platform_id = "steam".to_string(); + } + if s.enabled_platforms.is_empty() { + s.enabled_platforms = vec!["steam".to_string()]; + } + if !s.enabled_platforms.contains(&s.default_platform_id) { + s.default_platform_id = s.enabled_platforms[0].clone(); + } + let selectable: Vec<&String> = s + .enabled_platforms + .iter() + .filter(|id| implemented(id) && platforms::usable(platforms, id, os)) + .collect(); + if let Some(first) = selectable.first() { + if !selectable.contains(&&s.default_platform_id) { + s.default_platform_id = (*first).clone(); + } + } +} + +fn toast(toasts: &mut ToastQueue, key: &str, kind: ToastType, now: Instant) { + toasts.add(Text::key(key), AddToastOptions::kind(kind), now); +} + +impl AppModel { + /// The settings toggle of the title bar: open (or flush and close). + pub(super) fn toggle_settings_panel(&mut self) { + if self.settings_panel.is_some() { + self.close_settings_panel(); + return; + } + let Some(b) = self.booted.as_mut() else { + return; + }; + b.nav.show_personas = false; + b.nav.bulk_edit = false; + b.nav.show_settings = true; + self.settings_panel = Some(SettingsPanel::open( + &b.stores, + &self.jobs, + &b.platforms, + b.runtime_os, + )); + } + + /// `closePanel`: flush the pending save, then `onClose`. + pub(super) fn close_settings_panel(&mut self) { + self.flush_settings(Instant::now()); + self.settings_panel = None; + if let Some(b) = self.booted.as_mut() { + b.nav.show_settings = false; + } + } + + /// Something else closed the panel (a platform tab, personas): the + /// pending save still lands, like `onDestroy`'s final persist. + pub(super) fn settings_panel_hidden(&mut self) { + if self.booted.as_ref().is_some_and(|b| !b.nav.show_settings) + && self.settings_panel.is_some() + { + self.flush_settings(Instant::now()); + self.settings_panel = None; + } + } + + /// `flushSettingsNow`. + fn flush_settings(&mut self, now: Instant) { + if let Some(p) = self.settings_panel.as_mut() { + p.save_at = None; + } + self.persist_settings(now); + } + + /// `persistCurrentState`. + fn persist_settings(&mut self, now: Instant) { + let (Some(panel), Some(b)) = (self.settings_panel.as_mut(), self.booted.as_mut()) else { + return; + }; + if !panel.hydrated { + return; + } + normalize(&mut panel.draft, &b.platforms, b.runtime_os); + let pin_committed = panel.commit_pin_code(); + let snapshot = panel.persist_snapshot(&panel.draft); + if snapshot == panel.last_persisted_snapshot { + return; + } + let next_platforms = platform_snapshot(&panel.draft); + let platforms_changed = next_platforms != panel.last_platform_snapshot; + let previous: Value = + serde_json::from_str(&panel.last_persisted_snapshot).unwrap_or(Value::Null); + let draft = panel.draft.clone(); + match merge_settings_draft(b.stores.settings(), &panel.persisted, &draft) { + Ok(merged) => b.stores.save_settings(&merged), + Err(_) => b.stores.save_settings(&draft), + } + panel.seen_store = b.stores.settings().clone(); + panel.persisted = draft; + if pin_committed { + toast( + &mut self.toasts, + "settings.pinSaved", + ToastType::Success, + now, + ); + } + // `saveApiKey`. + let mut api_key_saved = true; + if panel.api_key_touched { + let trimmed = panel.api_key.trim().to_string(); + if trimmed.is_empty() { + panel.api_key_touched = false; + } else { + match self.backend.steam_set_api_key(&trimmed) { + Ok(()) => { + let next = api_key_draft_after_successful_save(&panel.api_key, &trimmed); + panel.api_key_configured = true; + panel.api_key = next.api_key; + panel.api_key_touched = next.touched; + panel.api_key_error = false; + } + Err(_) => { + panel.api_key_error = true; + api_key_saved = false; + toast( + &mut self.toasts, + "settings.apiKeySaveFailed", + ToastType::Error, + now, + ); + } + } + } + } + // `savePlatformPaths`. + let mut paths_saved = true; + let ids: Vec = panel.platform_paths.keys().cloned().collect(); + for id in ids { + let next = panel.platform_paths[&id].trim().to_string(); + let prev = previous["platformPaths"][&id].as_str().unwrap_or(""); + if prev == next { + continue; + } + match self.backend.platform_set_path(&id, &next) { + Ok(()) => { + panel.platform_path_errors.insert(id, false); + } + Err(_) => { + panel.platform_path_errors.insert(id, true); + paths_saved = false; + toast( + &mut self.toasts, + "settings.pathSaveFailed", + ToastType::Error, + now, + ); + } + } + } + panel.last_platform_snapshot = next_platforms; + if platforms_changed { + // `onPlatformsChanged`: a tab that is no longer enabled falls + // back to the first one the settings allow. + let s = b.stores.settings(); + if !s.enabled_platforms.contains(&b.nav.active_tab) { + b.nav.active_tab = platforms::initial_tab( + &b.platforms, + &s.enabled_platforms, + &s.default_platform_id, + b.runtime_os, + ); + } + } + if !api_key_saved || !paths_saved { + return; + } + panel.last_persisted_snapshot = snapshot; + if panel + .last_saved_toast + .is_none_or(|at| now.duration_since(at) >= SAVE_TOAST_COOLDOWN) + { + toast(&mut self.toasts, "settings.saved", ToastType::Info, now); + panel.last_saved_toast = Some(now); + } + } + + /// Once per frame while the panel is open: jobs, timers, the external + /// settings watch and the save debounce. + pub(super) fn tick_settings_panel(&mut self, ctx: &Context, now: Instant) { + self.settings_panel_hidden(); + let jobs = &self.jobs; + let (Some(panel), Some(b)) = (self.settings_panel.as_mut(), self.booted.as_mut()) else { + return; + }; + if let Some(job) = &mut panel.hydrate_job { + let landed = match job.poll() { + Poll::Waiting => None, + Poll::Ready(result) => Some(result), + Poll::Gone => Some((false, Vec::new())), + }; + if let Some((has_key, paths)) = landed { + panel.hydrate_job = None; + panel.api_key_configured = has_key; + panel.platform_paths = paths.into_iter().collect(); + let at_start = panel.persisted.clone(); + panel.last_persisted_snapshot = panel.persist_snapshot(&at_start); + panel.last_platform_snapshot = platform_snapshot(&at_start); + panel.hydrated = true; + } + } + if let Some(job) = &mut panel.telemetry_job { + match job.poll() { + Poll::Waiting => {} + Poll::Ready(Ok(state)) => { + panel.telemetry = Some(state); + panel.telemetry_error = false; + panel.telemetry_job = None; + } + Poll::Ready(Err(_)) | Poll::Gone => { + panel.telemetry_error = true; + panel.telemetry_job = None; + } + } + } + let mut refresh = false; + let mut finished = Vec::new(); + panel.calls.retain_mut(|(job, followup)| match job.poll() { + Poll::Waiting => true, + Poll::Ready(result) => { + finished.push((*followup, result)); + false + } + Poll::Gone => false, + }); + for (followup, result) in finished { + match (followup, result) { + (Followup::ErrorToast(key), Err(_)) => { + toast(&mut self.toasts, key, ToastType::Error, now); + } + (Followup::ModeA, Err(_)) => refresh = true, + (Followup::ModeB { next }, result) => { + panel.mode_b_busy = false; + refresh = true; + if result.is_err() { + let key = if next { + "settings.telemetryEnableFailed" + } else if panel.telemetry.as_ref().is_some_and(|t| !t.mode_b_enabled) { + "settings.telemetryDeletePending" + } else { + "settings.telemetryDisableFailed" + }; + toast(&mut self.toasts, key, ToastType::Info, now); + } + } + (Followup::RetryForget, result) => { + panel.mode_b_busy = false; + refresh = true; + let key = if result.is_ok() { + "settings.telemetryDeleteCompleted" + } else { + "settings.telemetryDeleteRetryFailed" + }; + toast(&mut self.toasts, key, ToastType::Info, now); + } + (Followup::Export, result) => { + panel.export_busy = false; + let key = match result { + Ok(data) => { + let text = serde_json::to_string_pretty(&data).unwrap_or_default(); + panel.clipboard = Some(text); + "settings.telemetryExported" + } + Err(_) => "settings.telemetryExportFailed", + }; + toast(&mut self.toasts, key, ToastType::Info, now); + } + _ => {} + } + } + if refresh { + panel.refresh_telemetry(jobs); + } + if let Some(text) = panel.clipboard.take() { + ctx.copy_text(text); + } + if panel + .current_pin_busy_until + .is_some_and(|until| now >= until) + { + panel.current_pin_busy_until = None; + } + // `adoptExternalSettings`. + if panel.hydrated && b.stores.settings() != &panel.seen_store { + let stored = b.stores.settings().clone(); + panel.seen_store = stored.clone(); + let draft = panel.draft.clone(); + let was_saved = panel.persist_snapshot(&panel.draft) == panel.last_persisted_snapshot; + let next = merge_settings_draft(&stored, &panel.persisted, &draft).unwrap_or(draft); + panel.persisted = stored; + panel.draft = next; + panel.refresh_numeric(); + if was_saved { + panel.last_persisted_snapshot = panel.persist_snapshot(&panel.draft); + } + } + // `$effect(... queueSave())`. + let tracked = panel.tracked(); + if tracked != panel.last_tracked { + panel.last_tracked = tracked; + if panel.hydrated { + panel.save_at = Some(now + SAVE_DEBOUNCE); + } + } + // `ensureActiveVisible` on a tab list or language change. + let enabled = panel.draft.enabled_platforms.clone(); + let ids: Vec = visible_tabs(&b.platforms, &enabled) + .into_iter() + .map(|t| t.id) + .collect(); + if !ids.iter().any(|id| id == panel.tab_bar.active_tab()) { + panel.tab_bar.ensure_active_visible(&ids); + } + if panel.save_at.is_some_and(|at| now >= at) { + panel.save_at = None; + self.persist_settings(now); + } + } + + /// When the panel next needs a frame without input. + pub fn settings_panel_deadline(&self) -> Option { + let p = self.settings_panel.as_ref()?; + [p.save_at, p.current_pin_busy_until] + .into_iter() + .flatten() + .min() + } + + /// Applies one [`SettingsAction`]. + pub(super) fn apply_settings(&mut self, ctx: &Context, action: SettingsAction) { + let now = Instant::now(); + if matches!( + action, + SettingsAction::Close | SettingsAction::ReplayOnboarding + ) { + // `closePanel()`; the replay then hands over to the onboarding + // tour, which belongs to the overlays port. + self.close_settings_panel(); + return; + } + let jobs = &self.jobs; + let Some(panel) = self.settings_panel.as_mut() else { + return; + }; + let call = |panel: &mut SettingsPanel, followup: Followup, f: BackendCall| { + panel.calls.push((jobs.run(f), followup)); + }; + match action { + SettingsAction::Close | SettingsAction::ReplayOnboarding => {} + SettingsAction::SelectTab(id) => { + let was_privacy = panel.tab_bar.active_tab() == "privacy"; + panel.tab_bar.select(&id); + panel.scroll = 0.0; + if id == "privacy" && !was_privacy { + panel.mount_privacy(jobs); + } + } + SettingsAction::OpenHelp => call( + panel, + Followup::ErrorToast("settings.openHelpFailed"), + Box::new(|b| b.open_url(WIKI_URL).map(|()| Value::Null)), + ), + SettingsAction::OpenLogs => call( + panel, + Followup::ErrorToast("settings.openLogsFailed"), + Box::new(|b| b.open_logs_folder().map(|()| Value::Null)), + ), + SettingsAction::OpenIntegrationsWiki => call( + panel, + Followup::ErrorToast("settings.openHelpFailed"), + Box::new(|b| b.open_url(INTEGRATIONS_WIKI_URL).map(|()| Value::Null)), + ), + SettingsAction::OpenTelemetryDoc => call( + panel, + Followup::ErrorToast("settings.openHelpFailed"), + Box::new(|b| b.open_url(TELEMETRY_DOC_URL).map(|()| Value::Null)), + ), + SettingsAction::Toggle(flag) => { + let d = &mut panel.draft; + match flag { + SettingFlag::ShowCardNotesInline => { + d.account_display.show_card_notes_inline ^= true; + } + SettingFlag::ExpandedFolders => d.account_display.expanded_folders ^= true, + SettingFlag::SuspendGraphicsWhenMinimized => { + d.suspend_graphics_when_minimized ^= true; + } + SettingFlag::MinimizeOnAccountSwitch => d.minimize_on_account_switch ^= true, + SettingFlag::DeepLinks => d.deep_links_enabled ^= true, + SettingFlag::Cli => d.cli_enabled ^= true, + SettingFlag::StreamerMode => { + d.streamer_mode = match d.streamer_mode { + StreamerMode::Auto => StreamerMode::Off, + StreamerMode::Off => StreamerMode::Auto, + }; + } + } + } + SettingsAction::Language(locale) => panel.draft.language = locale, + SettingsAction::Animations(mode) => panel.draft.animations = mode, + SettingsAction::Theme(id) => panel.draft.theme_id = id, + SettingsAction::NumericInput(field, text) => panel.numeric_mut(field).0.input = text, + SettingsAction::NumericCommit(field) => { + let (input, value) = panel.numeric_mut(field); + *value = input.commit(*value); + } + SettingsAction::PinInput(text) => { + panel.pin_code_input = sanitize_pin_digits(&text) + .chars() + .take(PIN_CODE_LENGTH) + .collect(); + } + SettingsAction::CurrentPinInput(text) => { + panel.current_pin_input = sanitize_pin_digits(&text) + .chars() + .take(PIN_CODE_LENGTH) + .collect(); + if panel.current_pin_input.len() == PIN_CODE_LENGTH + && panel.current_pin_busy_until.is_none() + { + // `confirmCurrentPin`. The backend checks the code on + // this thread: the PIN service answers locally. + let code = std::mem::take(&mut panel.current_pin_input); + panel.current_pin_error.clear(); + let check = + unlock_pin_session(self.backend.as_ref(), &code, &panel.draft.pin_hash); + match check.failure_wait() { + None => { + panel.pin_change_authorized = true; + if panel.pin_disable_pending { + panel.disable_pin(); + } else if let accshift_app::secure::pin::PinCheck::Match { + rehashed: Some(hash), + } = check + { + panel.draft.pin_hash = hash; + } + } + Some(wait) => { + let locale = self.booted.as_ref().map_or(Locale::En, |b| b.locale); + panel.current_pin_error = if wait.too_many_attempts { + accshift_app::i18n::translate_with( + locale, + "pin.tooManyAttempts", + &[( + "seconds", + TranslationValue::Num(wait.wait_ms.div_ceil(1000) as f64), + )], + ) + } else { + accshift_app::i18n::translate(locale, "pin.invalid") + }; + panel.current_pin_busy_until = + Some(now + Duration::from_millis(wait.wait_ms)); + } + } + } + } + SettingsAction::TogglePin => { + if panel.pin_guarded() { + panel.pin_disable_pending = !panel.pin_disable_pending; + } else if panel.draft.pin_enabled || panel.pin_setup_pending { + panel.disable_pin(); + } else { + panel.pin_setup_pending = true; + } + } + SettingsAction::ExportTheme => { + if let Some(b) = self.booted.as_ref() { + let theme = b.registry.theme(Some(&panel.draft.theme_id)); + ctx.copy_text(accshift_theme::export_theme_json(&theme)); + toast( + &mut self.toasts, + "settings.themeExportCopied", + ToastType::Info, + now, + ); + } + } + SettingsAction::ToggleTelemetryA => { + if let Some(t) = panel.telemetry.as_mut() { + let next = !t.mode_a_enabled; + t.mode_a_enabled = next; + call( + panel, + Followup::ModeA, + Box::new(move |b| b.telemetry_set_mode_a(next).map(|()| Value::Null)), + ); + } + } + SettingsAction::ToggleTelemetryB => { + if let Some(t) = panel.telemetry.as_ref() { + if !panel.mode_b_busy { + let next = !t.mode_b_enabled; + panel.mode_b_busy = true; + call( + panel, + Followup::ModeB { next }, + Box::new(move |b| b.telemetry_set_mode_b(next).map(|()| Value::Null)), + ); + } + } + } + SettingsAction::RetryTelemetryDeletion => { + if !panel.mode_b_busy { + panel.mode_b_busy = true; + call( + panel, + Followup::RetryForget, + Box::new(|b| b.telemetry_retry_forget().map(|()| Value::Null)), + ); + } + } + SettingsAction::ExportTelemetry => { + if !panel.export_busy { + panel.export_busy = true; + call(panel, Followup::Export, Box::new(|b| b.telemetry_export())); + } + } + SettingsAction::RetryTelemetryLoad => panel.refresh_telemetry(jobs), + } + } +} diff --git a/crates/accshift-egui/src/app/shell.rs b/crates/accshift-egui/src/app/shell.rs index ff96fef..38ecc74 100644 --- a/crates/accshift-egui/src/app/shell.rs +++ b/crates/accshift-egui/src/app/shell.rs @@ -16,10 +16,11 @@ use egui::epaint::{self, ClippedShape}; use egui::layers::ShapeIdx; -use egui::{vec2, Color32, Rect, Ui, UiBuilder}; +use egui::{pos2, vec2, Color32, Rect, Ui, UiBuilder}; use super::platforms::{PlatformDef, RuntimeOs}; -use super::{Action, AppModel}; +use super::{Action, AppModel, Booted}; +use crate::screens::workspace::{self, WorkspaceInput}; use crate::ui::anim::{Animation, Fill, Motion, Timing}; use crate::ui::components::title_bar::{self, TitleBarProps}; use crate::ui::paint::BoxStyle; @@ -149,7 +150,10 @@ pub(super) fn draw(app: &mut AppModel, ui: &mut Ui, now: f64) -> Vec { focus_visible: app.focus_visible, }; let panel_since = booted.panel.1; - let show_spike = !nav.show_settings; + // Settings and personas are not ported: their slot stays empty. + let show_workspace = !nav.show_settings && !nav.show_personas; + let compatible_platforms = enabled.iter().filter(|p| p.supports(os)).count(); + let panel_key = nav.panel_key(); let titlebar_style = page_entrance(&TITLEBAR_ENTRANCE, local, motion); let stage_style = page_entrance(&STAGE_ENTRANCE, local, motion); @@ -176,7 +180,21 @@ pub(super) fn draw(app: &mut AppModel, ui: &mut Ui, now: f64) -> Vec { center * (1.0 - frame.scale) + vec2(0.0, frame.rise), frame.scale, ); - let spike = &mut app.spike; + let jobs = &app.jobs; + let focus_visible = app.focus_visible; + let Some(Booted { + accounts, + workspace: ws, + theme, + stores, + platforms, + locale, + .. + }) = app.booted.as_mut() + else { + return actions; + }; + let platform = platforms.iter().find(|p| p.id == nav.active_tab); ui.with_visual_transform(frame_transform, |ui| { let mut frame_ui = ui.new_child(UiBuilder::new().max_rect(window)); frame_ui.multiply_opacity(frame.opacity); @@ -206,12 +224,32 @@ pub(super) fn draw(app: &mut AppModel, ui: &mut Ui, now: f64) -> Vec { title_bar::title_bar(ui, bar, &props) })); } - // The panel slot. The spike lays its page out below its own - // title bar height, so it gets the whole stage. Settings are not - // ported: their slot stays empty. + // The panel slot, under the title bar. let (o, rise) = panel_style; - if show_spike { - layer(ui, stage, o, rise, |ui| spike.ui(ui)); + let panel = if mac_bar.is_none() { + Rect::from_min_max(pos2(stage.left(), stage.top() + bar_h), stage.max) + } else { + stage + }; + if show_workspace { + let input = WorkspaceInput { + vars: &vars, + resolved: &theme.resolved, + motion, + locale: *locale, + stores, + accounts, + jobs, + platform, + usable: active_usable, + compatible_platforms, + panel_key: &panel_key, + focus_visible, + now, + }; + actions.extend(layer(ui, panel, o, rise, |ui| { + workspace::draw(ui, panel, ws, input) + })); } }); }); diff --git a/crates/accshift-egui/src/app/vars.rs b/crates/accshift-egui/src/app/vars.rs index a6c2460..83759ed 100644 --- a/crates/accshift-egui/src/app/vars.rs +++ b/crates/accshift-egui/src/app/vars.rs @@ -22,10 +22,17 @@ pub struct Vars { pub bg_image: Vec, /// `--bg-card`. pub bg_card: Rgba, + /// `--bg-card-hover`. + pub bg_card_hover: Rgba, + /// `--card-bg-image`: the theme's card gradients, first on top. Empty + /// for `none`. + pub card_bg_image: Vec, /// `--bg-muted`. pub bg_muted: Rgba, /// `--bg-elevated`. pub bg_elevated: Rgba, + /// `--bg-input`. + pub bg_input: Rgba, /// `--bg-overlay`. pub bg_overlay: Rgba, /// `--fg`. @@ -48,6 +55,19 @@ pub struct Vars { pub accent_text: Rgba, /// `--motion-page-entrance`, seconds. pub motion_page_entrance: f64, + /// `--motion-card-entrance`, seconds. + pub motion_card_entrance: f64, + /// `--motion-avatar-fade`, seconds. + pub motion_avatar_fade: f64, + /// `--radius-sm`, CSS px. + pub radius_sm: f32, + /// `--radius-md`, CSS px (`--grid-card-radius`). + pub radius_md: f32, + /// `--avatar-radius`, CSS px (`var(--avatar-radius-default, 6px)`; a + /// percentage falls back to 6 px until the full account card lands). + pub avatar_radius: f32, + /// `--density-scale`. + pub density_scale: f32, } /// `accshift_theme::Rgba` (channels 0 to 1) in the painter's colour type @@ -59,26 +79,38 @@ pub fn rgba(c: accshift_theme::Rgba) -> Rgba { impl Vars { pub fn new(theme: &ResolvedTheme) -> Vars { let color = |name: &str| theme.color(name).map(rgba).unwrap_or(Rgba::TRANSPARENT); - let bg_image = theme - .substitute("--bg-image") - .map(|value| { - let value = value.trim(); - if value.is_empty() || value == "none" { - return Vec::new(); - } - css::split_top(value, ',') - .into_iter() - .filter_map(css::gradient) - .collect() - }) - .unwrap_or_default(); + let images = |name: &str| -> Vec { + theme + .substitute(name) + .map(|value| { + let value = value.trim(); + if value.is_empty() || value == "none" { + return Vec::new(); + } + css::split_top(value, ',') + .into_iter() + .filter_map(css::gradient) + .collect() + }) + .unwrap_or_default() + }; + let seconds = |name: &str, default: f32| { + f64::from(theme.duration_ms(name).unwrap_or(default)) / 1000.0 + }; + let density_scale = theme + .number("--density-scale") + .filter(|s| s.is_finite() && *s > 0.0) + .unwrap_or(1.0); Vars { bg: color("--bg"), bg_solid: color("--bg-solid"), - bg_image, + bg_image: images("--bg-image"), bg_card: color("--bg-card"), + bg_card_hover: color("--bg-card-hover"), + card_bg_image: images("--card-bg-image"), bg_muted: color("--bg-muted"), bg_elevated: color("--bg-elevated"), + bg_input: color("--bg-input"), bg_overlay: color("--bg-overlay"), fg: color("--fg"), fg_muted: color("--fg-muted"), @@ -91,12 +123,36 @@ impl Vars { danger: color("--danger"), accent: color("--accent"), accent_text: color("--accent-text"), - motion_page_entrance: f64::from( - theme.duration_ms("--motion-page-entrance").unwrap_or(200.0), - ) / 1000.0, + motion_page_entrance: seconds("--motion-page-entrance", 200.0), + motion_card_entrance: seconds("--motion-card-entrance", 260.0), + motion_avatar_fade: seconds("--motion-avatar-fade", 220.0), + radius_sm: theme.length_px("--radius-sm").unwrap_or(4.0), + radius_md: theme.length_px("--radius-md").unwrap_or(8.0), + avatar_radius: theme.length_px("--avatar-radius").unwrap_or(6.0), + density_scale, } } + /// `--grid-card-width`: `calc(100px * var(--density-scale))`. + pub fn grid_card_width(&self) -> f32 { + 100.0 * self.density_scale + } + + /// `--grid-card-min-height`: `calc(136px * var(--density-scale))`. + pub fn grid_card_min_height(&self) -> f32 { + 136.0 * self.density_scale + } + + /// `--grid-card-padding`: `calc(8px * var(--density-scale))`. + pub fn grid_card_padding(&self) -> f32 { + 8.0 * self.density_scale + } + + /// `--grid-card-avatar-size`: `calc(68px * var(--density-scale))`. + pub fn grid_card_avatar_size(&self) -> f32 { + 68.0 * self.density_scale + } + /// `var(--border-width) var(--border-style) var(--border)` as a width, /// zero when the style is `none`. pub fn border_px(&self) -> f32 { @@ -139,6 +195,12 @@ mod tests { assert!(v.bg_image.is_empty()); assert_eq!(v.border_px(), 1.0); assert!((v.motion_page_entrance - 0.2).abs() < 1e-9); + assert!((v.motion_card_entrance - 0.26).abs() < 1e-9); + assert!((v.motion_avatar_fade - 0.22).abs() < 1e-9); + assert_eq!(v.grid_card_width(), 100.0); + assert_eq!(v.grid_card_min_height(), 136.0); + assert_eq!(v.radius_md, 8.0); + assert_eq!(v.avatar_radius, 6.0); } #[test] diff --git a/crates/accshift-egui/src/screens/main_spike.rs b/crates/accshift-egui/src/screens/main_spike.rs index 145ad77..045c590 100644 --- a/crates/accshift-egui/src/screens/main_spike.rs +++ b/crates/accshift-egui/src/screens/main_spike.rs @@ -1,1124 +1,9 @@ -//! The main screen: title bar, platform tabs, header, account grid. -//! -//! Everything is painted by hand at the metrics of the Svelte shell -//! (`TitleBar.svelte`, `AccountCard.svelte`, `FolderCard.svelte`, -//! `ViewToggle.svelte`, the `--grid-card-*` tokens of `app.css`), so the two -//! can be laid side by side. +//! What is left of the first spike: the font setup the runner calls before +//! the first frame. The spike's screen is gone; the workspace +//! (`screens::workspace`) fills the panel slot. -use std::collections::HashMap; -use std::sync::mpsc::Receiver; - -use egui::epaint::{RectShape, TextShape}; -use egui::{ - pos2, vec2, Align2, Color32, ColorImage, CornerRadius, FontFamily, FontId, Id, Pos2, Rect, - Sense, Shape, Stroke, StrokeKind, TextureHandle, TextureOptions, Ui, Vec2, -}; - -use crate::avatars::{self, Decoded}; -use crate::bench; -use crate::data::{self, Dataset, Item, Platform}; -use crate::icons; -use crate::theme::{ease_out, fallback_gradient, Rgba, Theme, GLASS_DARK}; - -const TITLE_H: f32 = 36.0; -const GRID_TOP: f32 = 84.0; -const CARD_W: f32 = 100.0; -const CARD_H: f32 = 136.0; -const CARD_PAD: f32 = 8.0; -const CARD_GAP: f32 = 10.0; -const AVATAR: f32 = 68.0; -const SCROLL_GUTTER: f32 = 10.0; -const SWITCH_DELAY: f64 = 0.9; -/// Avatars are rasterized once at this size: 68 pt at 2x, plus the 1.04 hover zoom. -pub const AVATAR_PX: u32 = 160; - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum Tab { - Personas, - Platform(Platform), -} - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum ViewMode { - Grid, - List, -} - -#[derive(Default, Clone, Copy)] -pub struct Options { - /// Paint the recorder's stand-in desktop under the window, for offscreen - /// captures where no OS backdrop exists. - pub desktop_backdrop: bool, - /// Drawn in the app shell's panel slot: the shell paints the window fill - /// and the title bar, the spike only its page. - pub embedded: bool, -} - -struct AvatarTex { - sharp: TextureHandle, - blurred: TextureHandle, - loaded_at: f64, -} - -pub struct AccshiftApp { - theme: &'static Theme, - data: Dataset, - tab: Tab, - folder: Option<&'static str>, - view: ViewMode, - search: String, - armed: Option, - switching: Option<(String, f64)>, - page_key: (Tab, Option<&'static str>, ViewMode), - page_since: f64, - icons: HashMap<(usize, u32), TextureHandle>, - avatars: Vec>, - avatar_rx: Option>, - gradients: HashMap, - backdrop: Option, - embedded: bool, - frame_index: u64, - ready_marked: bool, - all_avatars_drawn_at: Option, -} - -impl AccshiftApp { - pub fn new(ctx: &egui::Context, options: Options) -> Self { - install_fonts(ctx); - let avatar_rx = Some(avatars::spawn(ctx.clone(), AVATAR_PX)); - let backdrop = options.desktop_backdrop.then(|| { - ctx.load_texture( - "desktop", - crate::backdrop::render(860, 440, 2.0), - TextureOptions::LINEAR, - ) - }); - Self { - theme: &GLASS_DARK, - data: data::demo(), - tab: Tab::Platform(Platform::Steam), - folder: None, - view: ViewMode::Grid, - search: String::new(), - armed: None, - switching: None, - page_key: (Tab::Platform(Platform::Steam), None, ViewMode::Grid), - page_since: 0.0, - icons: HashMap::new(), - avatars: (0..data::AVATARS.len()).map(|_| None).collect(), - avatar_rx, - gradients: HashMap::new(), - backdrop, - embedded: options.embedded, - frame_index: 0, - ready_marked: false, - all_avatars_drawn_at: None, - } - } - - /// Shows `id` (`personas` or a platform id) like a tab click, for the app - /// shell's navigation. Unknown ids are ignored. - pub fn show_tab(&mut self, id: &str) { - let tab = match id { - "personas" => Tab::Personas, - "steam" => Tab::Platform(Platform::Steam), - "riot" => Tab::Platform(Platform::Riot), - "roblox" => Tab::Platform(Platform::Roblox), - _ => return, - }; - if self.tab != tab { - self.tab = tab; - self.folder = None; - self.search.clear(); - self.armed = None; - } - } - - /// Every avatar texture has been uploaded (the offscreen captures wait - /// for this before taking a frame). - pub fn avatars_ready(&self) -> bool { - self.avatar_rx.is_none() - } - - pub fn ui(&mut self, ui: &mut Ui) { - let ctx = ui.ctx().clone(); - let now = ctx.input(|i| i.time); - if self.frame_index == 1 { - // Frame 0 has been painted and swapped by now. - bench::mark("first_frame"); - } - if let (Some(at), false) = (self.all_avatars_drawn_at, self.ready_marked) { - if self.frame_index > at { - bench::mark("ready"); - self.ready_marked = true; - } - } - - self.receive_avatars(&ctx, now); - self.finish_switch(now); - - let key = (self.tab, self.folder, self.view); - if key != self.page_key { - self.page_key = key; - self.page_since = now; - } - - let full = ui.max_rect(); - let painter = ui.painter().clone(); - if let Some(desk) = &self.backdrop { - painter.image( - desk.id(), - full, - Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), - Color32::WHITE, - ); - } - if !self.embedded { - painter.rect_filled( - full, - CornerRadius::ZERO, - self.theme.window_fill.to_color32(), - ); - self.title_bar(ui, full, now); - } - match self.tab { - Tab::Personas => self.personas_page(ui, full), - Tab::Platform(platform) => self.platform_page(ui, full, platform, now), - } - - // A press anywhere but on the armed card disarms it, like the - // document mousedown listener of AccountCard.svelte. - if self.armed.is_some() && ctx.input(|i| i.pointer.any_pressed()) { - let over_armed = ctx - .read_response(Id::new(("card", self.armed.clone().unwrap_or_default()))) - .is_some_and(|r| r.hovered()); - if !over_armed { - self.armed = None; - } - } - - if self.frame_index == 0 { - bench::mark("ui_done"); - ctx.request_repaint(); - } - if self.all_avatars_drawn_at.is_some() && !self.ready_marked { - ctx.request_repaint(); - } - self.frame_index += 1; - } - - // ------------------------------------------------------------- loading - - fn receive_avatars(&mut self, ctx: &egui::Context, now: f64) { - let Some(rx) = &self.avatar_rx else { return }; - while let Ok(decoded) = rx.try_recv() { - let sharp = ctx.load_texture( - format!("avatar-{}", decoded.index), - decoded.sharp, - TextureOptions::LINEAR, - ); - let blurred = ctx.load_texture( - format!("avatar-blur-{}", decoded.index), - decoded.blurred, - TextureOptions::LINEAR, - ); - self.avatars[decoded.index] = Some(AvatarTex { - sharp, - blurred, - loaded_at: now, - }); - } - if self.avatars.iter().all(Option::is_some) { - self.avatar_rx = None; - self.all_avatars_drawn_at.get_or_insert(self.frame_index); - } - } - - fn finish_switch(&mut self, now: f64) { - if let Some((id, since)) = &self.switching { - if now - since >= SWITCH_DELAY { - let platform = match self.tab { - Tab::Platform(p) => p, - Tab::Personas => Platform::Steam, - }; - self.data.set_active(platform, id.clone()); - self.switching = None; - } - } - } - - fn icon(&mut self, ctx: &egui::Context, svg: &'static [u8], size: f32) -> TextureHandle { - let ppp = ctx.pixels_per_point(); - let px = (size * ppp).round().max(1.0) as u32; - let key = (svg.as_ptr() as usize, px); - self.icons - .entry(key) - .or_insert_with(|| { - let image = avatars::raster(svg, px) - .unwrap_or_else(|_| ColorImage::filled([1, 1], Color32::TRANSPARENT)); - ctx.load_texture(format!("icon-{px}-{key:?}"), image, TextureOptions::LINEAR) - }) - .clone() - } - - fn paint_icon(&mut self, ui: &Ui, svg: &'static [u8], center: Pos2, size: f32, color: Rgba) { - let tex = self.icon(ui.ctx(), svg, size); - let rect = Rect::from_center_size(center, Vec2::splat(size)); - ui.painter().image( - tex.id(), - rect, - Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), - color.to_color32(), - ); - } - - fn gradient(&mut self, ctx: &egui::Context, seed: &str) -> TextureHandle { - self.gradients - .entry(seed.to_string()) - .or_insert_with(|| { - let (a, b) = fallback_gradient(seed); - ctx.load_texture( - format!("grad-{seed}"), - gradient_image(a, b, 48), - TextureOptions::LINEAR, - ) - }) - .clone() - } - - // ----------------------------------------------------------- title bar - - fn title_bar(&mut self, ui: &mut Ui, full: Rect, now: f64) { - let t = self.theme; - let bar = Rect::from_min_size(full.min, vec2(full.width(), TITLE_H)); - let drag = ui.interact(bar, Id::new("titlebar"), Sense::click_and_drag()); - if drag.drag_started() { - ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); - } - if drag.double_clicked() { - let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)); - ui.ctx() - .send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); - } - ui.painter().hline( - bar.x_range(), - bar.bottom() - 0.5, - Stroke::new(1.0, t.card.to_color32()), - ); - - // Left actions: 26x26 buttons from x = 10, 6 apart. - let actions: [(&'static [u8], &str); 4] = [ - (icons::REFRESH, "Refresh"), - (icons::PLUS, "Add account"), - (icons::SETTINGS, "Settings"), - (icons::BULK_EDIT, "Bulk edit"), - ]; - for (i, (svg, label)) in actions.into_iter().enumerate() { - let rect = Rect::from_min_size( - pos2(full.left() + 10.0 + i as f32 * 32.0, full.top() + 5.0), - Vec2::splat(26.0), - ); - let resp = button(ui, Id::new(("action", i)), rect, label); - let hover = anim(ui, resp.id, resp.hovered(), 0.12); - let bg = Rgba::TRANSPARENT.lerp(t.muted, hover); - ui.painter() - .rect_filled(rect, CornerRadius::same(4), bg.to_color32()); - let color = t.fg_muted.lerp(t.fg, hover); - self.paint_icon(ui, svg, rect.center(), 14.0, color); - } - - // Centered tabs: 32x28, 2 apart. - let tabs = [ - Tab::Personas, - Tab::Platform(Platform::Steam), - Tab::Platform(Platform::Riot), - Tab::Platform(Platform::Roblox), - ]; - let total = tabs.len() as f32 * 32.0 + (tabs.len() as f32 - 1.0) * 2.0; - let x0 = full.center().x - total / 2.0; - for (i, tab) in tabs.into_iter().enumerate() { - let rect = Rect::from_min_size( - pos2(x0 + i as f32 * 34.0, full.top() + 4.0), - vec2(32.0, 28.0), - ); - let (svg, accent, label) = match tab { - Tab::Personas => (icons::PERSONAS, Rgba::hex(0xa855f7, 1.0), "Personas"), - Tab::Platform(p) => ( - match p { - Platform::Steam => icons::STEAM, - Platform::Riot => icons::RIOT, - Platform::Roblox => icons::ROBLOX, - }, - Rgba::hex(p.accent(), 1.0), - p.name(), - ), - }; - let resp = button(ui, Id::new(("tab", i)), rect, label); - if resp.clicked() && self.tab != tab { - self.tab = tab; - self.folder = None; - self.search.clear(); - self.armed = None; - } - let active = self.tab == tab; - let hover = anim(ui, resp.id, resp.hovered(), 0.12); - let on = anim(ui, resp.id.with("on"), active, 0.12); - let bg = Rgba::TRANSPARENT.lerp(t.card, hover.max(on)); - ui.painter() - .rect_filled(rect, CornerRadius::same(4), bg.to_color32()); - let idle = t.fg_subtle.lerp(t.fg_muted, hover); - self.paint_icon(ui, svg, rect.center(), 16.0, idle.lerp(accent, on)); - } - - // Caption strip: 46 px buttons, full height, flush right. - let maximized = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)); - let caption: [(&'static [u8], &str); 3] = [ - (icons::MINIMIZE, "Minimize"), - ( - if maximized { - icons::RESTORE - } else { - icons::MAXIMIZE - }, - "Maximize", - ), - (icons::CLOSE, "Close"), - ]; - for (i, (svg, label)) in caption.into_iter().enumerate() { - let rect = Rect::from_min_size( - pos2(full.right() - 46.0 * (3 - i) as f32, full.top()), - vec2(46.0, TITLE_H - 1.0), - ); - let resp = button(ui, Id::new(("caption", i)), rect, label); - let hover = anim(ui, resp.id, resp.hovered(), 0.12); - let (bg, fg) = if i == 2 { - (t.danger, Rgba::hex(0xffffff, 1.0)) - } else { - (t.muted, t.fg) - }; - ui.painter().rect_filled( - rect, - CornerRadius::ZERO, - Rgba::TRANSPARENT.lerp(bg, hover).to_color32(), - ); - self.paint_icon(ui, svg, rect.center(), 12.0, t.fg_muted.lerp(fg, hover)); - if resp.clicked() { - let cmd = match i { - 0 => egui::ViewportCommand::Minimized(true), - 1 => egui::ViewportCommand::Maximized(!maximized), - _ => egui::ViewportCommand::Close, - }; - ui.ctx().send_viewport_cmd(cmd); - } - } - let _ = now; - } - - // --------------------------------------------------------------- pages - - fn personas_page(&mut self, ui: &mut Ui, full: Rect) { - let t = self.theme; - let painter = ui.painter(); - painter.text( - pos2(full.center().x, full.center().y - 8.0), - Align2::CENTER_CENTER, - "Personas", - FontId::new(15.0, semibold()), - t.fg.to_color32(), - ); - painter.text( - pos2(full.center().x, full.center().y + 14.0), - Align2::CENTER_CENTER, - "Not part of the spike.", - FontId::proportional(12.0), - t.fg_subtle.to_color32(), - ); - } - - fn platform_page(&mut self, ui: &mut Ui, full: Rect, platform: Platform, now: f64) { - let t = self.theme; - let accent = Rgba::hex(platform.accent(), 1.0); - - // Header: section title or breadcrumb on the left, search and view - // toggle on the right. - let header_y = full.top() + 60.0; - let crumb_font = |current: bool| { - if current { - FontId::new(12.0, semibold()) - } else { - FontId::proportional(12.0) - } - }; - let at_root = self.folder.is_none(); - let root_color = if at_root { accent } else { t.fg_subtle }; - let crumb = ui.painter().layout_no_wrap( - platform.name().to_string(), - crumb_font(at_root), - root_color.to_color32(), - ); - let crumb_rect = Rect::from_min_size( - pos2(full.left() + 16.0, header_y - crumb.size().y / 2.0 - 2.0), - crumb.size() + vec2(8.0, 4.0), - ); - let crumb_resp = ui.interact(crumb_rect, Id::new("crumb-root"), Sense::click()); - if crumb_resp.clicked() { - self.folder = None; - } - ui.painter().galley( - crumb_rect.min + vec2(4.0, 2.0), - crumb, - root_color.to_color32(), - ); - let mut x = crumb_rect.right() + 4.0; - if let Some(folder) = self.folder { - let name = match folder { - "demo-folder-smurfs" => "Smurfs", - other => other, - }; - let sep = ui.painter().layout_no_wrap( - "/".into(), - FontId::proportional(11.0), - t.elevated.to_color32(), - ); - ui.painter().galley( - pos2(x, header_y - sep.size().y / 2.0), - sep.clone(), - t.elevated.to_color32(), - ); - x += sep.size().x + 4.0; - let g = ui - .painter() - .layout_no_wrap(name.into(), crumb_font(true), accent.to_color32()); - ui.painter().galley( - pos2(x + 4.0, header_y - g.size().y / 2.0), - g, - accent.to_color32(), - ); - } - - let right = full.right() - SCROLL_GUTTER - 17.0; - let toggle = Rect::from_min_size(pos2(right - 66.0, header_y - 15.0), vec2(66.0, 30.0)); - self.view_toggle(ui, toggle); - let search = Rect::from_min_size( - pos2(toggle.left() - 8.0 - 240.0, header_y - 15.0), - vec2(240.0, 30.0), - ); - self.search_box(ui, search); - - let body = Rect::from_min_max(pos2(full.left(), full.top() + GRID_TOP - 8.0), full.max); - let items = self.visible_items(platform); - let entrance = ease_out(((now - self.page_since) / 0.26) as f32); - if entrance < 1.0 { - ui.ctx().request_repaint(); - } - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(body)); - egui::ScrollArea::vertical() - .id_salt(( - "grid", - self.page_key.0 == Tab::Platform(platform), - self.folder, - )) - .auto_shrink([false, false]) - .show(&mut child, |ui| match self.view { - ViewMode::Grid => self.grid(ui, platform, &items, entrance, now), - ViewMode::List => self.list(ui, platform, &items, entrance), - }); - } - - fn visible_items(&self, platform: Platform) -> Vec { - let query = self.search.trim().to_lowercase(); - let accounts = self.data.accounts(platform); - if !query.is_empty() { - // Search flattens folders, like the web grid. - return accounts - .iter() - .enumerate() - .filter(|(_, a)| a.name.to_lowercase().contains(&query)) - .map(|(i, _)| Item::Account(i)) - .collect(); - } - match (platform, self.folder) { - (Platform::Steam, None) => self.data.steam_root.clone(), - (Platform::Steam, Some(_)) => { - let mut items = vec![Item::Folder { - id: "..", - name: "Back", - }]; - items.extend(self.data.smurfs.iter().cloned()); - items - } - _ => (0..accounts.len()).map(Item::Account).collect(), - } - } - - fn view_toggle(&mut self, ui: &mut Ui, rect: Rect) { - let t = self.theme; - ui.painter() - .rect_filled(rect, CornerRadius::same(8), t.muted.to_color32()); - for (i, (mode, svg, label)) in [ - (ViewMode::Grid, icons::GRID, "Grid view"), - (ViewMode::List, icons::LIST, "List view"), - ] - .into_iter() - .enumerate() - { - let b = Rect::from_min_size( - rect.min + vec2(2.0 + i as f32 * 32.0, 2.0), - vec2(30.0, 26.0), - ); - let resp = button(ui, Id::new(("view", i)), b, label); - if resp.clicked() { - self.view = mode; - } - let on = anim(ui, resp.id.with("on"), self.view == mode, 0.1); - let hover = anim(ui, resp.id, resp.hovered(), 0.1); - ui.painter().rect_filled( - b, - CornerRadius::same(6), - Rgba::TRANSPARENT.lerp(t.card, on).to_color32(), - ); - let color = t.fg_muted.lerp(t.fg, on.max(hover)); - self.paint_icon(ui, svg, b.center(), 14.0, color); - } - } - - fn search_box(&mut self, ui: &mut Ui, rect: Rect) { - let t = self.theme; - let id = Id::new("search"); - let focused = ui.ctx().memory(|m| m.has_focus(id)); - let border = if focused { - t.fg.mix(0.35, t.border) - } else { - t.border - }; - ui.painter().rect( - rect, - CornerRadius::same(8), - t.card.to_color32(), - Stroke::new(1.0, border.to_color32()), - StrokeKind::Inside, - ); - let edit = egui::TextEdit::singleline(&mut self.search) - .id(id) - .frame(egui::Frame::NONE) - .font(FontId::proportional(12.0)) - .text_color(t.fg.to_color32()) - .hint_text( - egui::RichText::new("Search account...") - .size(12.0) - .color(t.fg_subtle.mix(0.55, Rgba::TRANSPARENT).to_color32()), - ) - .margin(egui::Margin::symmetric(0, 0)) - .desired_width(rect.width() - 20.0 - 22.0); - let inner = Rect::from_min_max( - rect.min + vec2(10.0, 7.0), - rect.max - vec2(10.0 + 22.0, 7.0), - ); - let resp = ui.put(inner, edit); - if resp.changed() { - self.armed = None; - } - // Clear button, only with a query (the web field's X). - if !self.search.is_empty() { - let b = Rect::from_center_size( - pos2(rect.right() - 18.0, rect.center().y), - Vec2::splat(20.0), - ); - let clear = button(ui, Id::new("search-clear"), b, "Clear search"); - let hover = anim(ui, clear.id, clear.hovered(), 0.1); - self.paint_icon( - ui, - icons::CLOSE, - b.center(), - 11.0, - t.fg_muted.lerp(t.fg, hover.max(0.7)), - ); - if clear.clicked() { - self.search.clear(); - } - } - } - - // ---------------------------------------------------------------- grid - - fn grid(&mut self, ui: &mut Ui, platform: Platform, items: &[Item], entrance: f32, now: f64) { - let width = ui.available_width(); - let usable = width - SCROLL_GUTTER; - let cols = (((usable + CARD_GAP) / (CARD_W + CARD_GAP)).floor() as usize).max(1); - let grid_w = cols as f32 * CARD_W + (cols as f32 - 1.0) * CARD_GAP; - let rows = items.len().div_ceil(cols); - let height = 8.0 + rows as f32 * (CARD_H + CARD_GAP) + 8.0; - let (area, _) = ui.allocate_exact_size(vec2(width, height), Sense::hover()); - let x0 = area.left() + ((usable - grid_w) / 2.0).round(); - let y0 = area.top() + 8.0; - for (i, item) in items.iter().enumerate() { - let col = i % cols; - let row = i / cols; - let rect = Rect::from_min_size( - pos2( - x0 + col as f32 * (CARD_W + CARD_GAP), - y0 + row as f32 * (CARD_H + CARD_GAP), - ), - vec2(CARD_W, CARD_H), - ); - match item { - Item::Account(idx) => self.account_card(ui, platform, *idx, rect, entrance, now), - Item::Folder { id, name } => self.folder_card(ui, id, name, rect, entrance), - } - } - } - - fn folder_card( - &mut self, - ui: &mut Ui, - id: &'static str, - name: &'static str, - rect: Rect, - entrance: f32, - ) { - let t = self.theme; - let back = id == ".."; - let resp = ui.interact(rect, Id::new(("folder", id)), Sense::click()); - let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name)); - if resp.clicked() { - self.folder = if back { None } else { Some(id) }; - self.armed = None; - } - let h = ease_out(anim(ui, resp.id, resp.hovered(), 0.18)); - let mut painter = ui.painter().clone(); - painter.set_opacity(entrance); - let rect = scale_about_center(rect, 0.98 + 0.02 * entrance).translate(vec2(0.0, -2.0 * h)); - - if h > 0.0 { - shadow(&painter, rect, 0.18 * h); - } - painter.rect_filled( - rect, - CornerRadius::same(8), - Rgba::TRANSPARENT.lerp(t.card_hover, h).to_color32(), - ); - let outline = t.fg_subtle.mix(0.45, Rgba::TRANSPARENT); - painter.rect_stroke( - rect, - CornerRadius::same(8), - Stroke::new(1.0, Rgba::TRANSPARENT.lerp(outline, h).to_color32()), - StrokeKind::Outside, - ); - - let icon_box = Rect::from_min_size( - pos2(rect.center().x - AVATAR / 2.0, rect.top() + CARD_PAD - h), - Vec2::splat(AVATAR), - ); - let box_bg = t.muted.mix(0.52, Rgba::TRANSPARENT).lerp(t.elevated, h); - painter.rect_filled(icon_box, CornerRadius::same(6), box_bg.to_color32()); - let color = t.fg_muted.lerp(t.fg, h); - let svg = if back { icons::BACK } else { icons::FOLDER }; - let size = if back { 26.0 } else { 30.0 }; - let tex = self.icon(ui.ctx(), svg, size); - painter.image( - tex.id(), - Rect::from_center_size(icon_box.center(), Vec2::splat(size)), - Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), - color.to_color32(), - ); - name_label(&painter, rect, name, t.fg); - } - - fn account_card( - &mut self, - ui: &mut Ui, - platform: Platform, - idx: usize, - rect: Rect, - entrance: f32, - now: f64, - ) { - let t = self.theme; - let account = self.data.accounts(platform)[idx].clone(); - let id = Id::new(("card", account.id.clone())); - let resp = ui - .interact(rect, id, Sense::click()) - .on_hover_cursor(egui::CursorIcon::PointingHand); - resp.widget_info(|| { - egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &account.name) - }); - - let is_active = self.data.active(platform) == account.id; - let is_switching = self - .switching - .as_ref() - .is_some_and(|(s, _)| *s == account.id); - if resp.clicked() && self.switching.is_none() { - if self.armed.as_deref() == Some(account.id.as_str()) { - self.armed = None; - self.switching = Some((account.id.clone(), now)); - } else { - self.armed = Some(account.id.clone()); - } - } - let armed = self.armed.as_deref() == Some(account.id.as_str()); - - let lift = !is_active; - let h = ease_out(anim(ui, id.with("hover"), resp.hovered() && lift, 0.18)); - let press = anim( - ui, - id.with("press"), - resp.is_pointer_button_down_on() && lift, - 0.08, - ); - let blur = ease_out(anim(ui, id.with("blur"), armed || is_switching, 0.3)); - let confirm = ease_out(anim(ui, id.with("confirm"), armed, 0.15)); - - let mut painter = ui.painter().clone(); - painter.set_opacity(entrance); - let scale = (0.98 + 0.02 * entrance) * (1.0 - 0.015 * press); - let rect = scale_about_center(rect, scale).translate(vec2(0.0, -2.0 * h * (1.0 - press))); - - // Surface. - let custom = account.color.map(|c| Rgba::hex(c, 1.0)); - let rest = match custom { - Some(c) => c.mix(0.24, t.card), - None if is_active => t.card_hover, - None => t.card, - }; - let hover_bg = match custom { - Some(c) if !is_active => c.mix(0.32, t.card_hover), - _ => rest.lerp(t.card_hover, if is_active { 0.0 } else { 1.0 }), - }; - if h > 0.0 { - shadow(&painter, rect, 0.18 * h); - } - painter.rect_filled( - rect, - CornerRadius::same(8), - rest.lerp(hover_bg, h).to_color32(), - ); - - // Active rings: a bright halo, then a dark band (two spread shadows). - let active_t = ease_out(anim(ui, id.with("active"), is_active, 0.18)); - if active_t > 0.0 { - let (halo, band, band_w) = match custom { - Some(c) => ( - t.fg.mix(0.72, Rgba::TRANSPARENT), - c.mix(0.5, t.bg_solid.mix(0.62, Rgba::TRANSPARENT)), - 3.0, - ), - None => ( - t.fg.mix(0.62, Rgba::TRANSPARENT), - t.bg_solid.mix(0.45, Rgba::TRANSPARENT), - 2.0, - ), - }; - let fade = |c: Rgba| c.alpha(c.a * active_t).to_color32(); - painter.add(RectShape::stroke( - rect.expand(2.0), - CornerRadius::same(10), - Stroke::new(band_w, fade(band)), - StrokeKind::Outside, - )); - painter.add(RectShape::stroke( - rect, - CornerRadius::same(8), - Stroke::new(2.0, fade(halo)), - StrokeKind::Outside, - )); - } - - // Avatar box. - let avatar = Rect::from_min_size( - pos2(rect.center().x - AVATAR / 2.0, rect.top() + CARD_PAD - h), - Vec2::splat(AVATAR), - ); - let box_bg = t.muted.lerp(t.elevated, h); - painter.rect_filled(avatar, CornerRadius::same(6), box_bg.to_color32()); - let zoom = 1.0 + 0.04 * h; - let inset = (1.0 - 1.0 / zoom) / 2.0; - let uv = Rect::from_min_max(pos2(inset, inset), pos2(1.0 - inset, 1.0 - inset)); - let dim = 1.0 - 0.5 * blur; - let dim_color = |alpha: f32| { - let v = (255.0 * dim).round() as u8; - Color32::from_rgba_unmultiplied(v, v, v, (255.0 * alpha).round() as u8) - }; - match account.avatar.and_then(|i| self.avatars[i].as_ref()) { - Some(tex) => { - let fade_in = ease_out(((now - tex.loaded_at) / 0.22) as f32); - if fade_in < 1.0 { - ui.ctx().request_repaint(); - } - painter.add( - RectShape::filled( - avatar, - CornerRadius::same(6), - dim_color(fade_in * (1.0 - blur)), - ) - .with_texture(tex.sharp.id(), uv), - ); - if blur > 0.0 { - painter.add( - RectShape::filled(avatar, CornerRadius::same(6), dim_color(fade_in * blur)) - .with_texture(tex.blurred.id(), uv), - ); - } - } - None if account.avatar.is_none() => { - let seed = format!( - "{}::{}::{}", - account.name, - account.id, - account.name.chars().rev().collect::() - ); - let grad = self.gradient(ui.ctx(), &seed); - painter.add( - RectShape::filled(avatar, CornerRadius::same(6), dim_color(1.0)).with_texture( - grad.id(), - Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), - ), - ); - painter.text( - avatar.center(), - Align2::CENTER_CENTER, - data::initials(&account.name), - FontId::new(20.0, semibold()), - t.fg.alpha(1.0 - 0.5 * blur).to_color32(), - ); - } - None => {} - } - if is_active { - painter.rect_stroke( - avatar, - CornerRadius::same(6), - Stroke::new(2.0, t.fg.mix(0.2, Rgba::TRANSPARENT).to_color32()), - StrokeKind::Outside, - ); - } - - if is_switching { - spinner(&painter, avatar.center(), now, t); - ui.ctx().request_repaint(); - } - if confirm > 0.0 { - let size = 24.0 * (0.8 + 0.2 * confirm); - let tex = self.icon(ui.ctx(), icons::PLAY, 24.0); - painter.image( - tex.id(), - Rect::from_center_size(avatar.center(), Vec2::splat(size)), - Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), - t.fg.alpha(confirm).to_color32(), - ); - } - - name_label(&painter, rect, &account.name, t.fg); - - if confirm > 0.0 { - let inner_w = CARD_W - 2.0 * CARD_PAD - 6.0; - let mut job = egui::text::LayoutJob::single_section( - "Click again to switch".into(), - egui::TextFormat { - font_id: FontId::new(9.0, semibold()), - color: t.fg.alpha(confirm).to_color32(), - line_height: Some(9.0 * 1.2), - ..Default::default() - }, - ); - job.wrap.max_width = inner_w; - job.halign = egui::Align::Center; - let hint = painter.layout_job(job); - let hint_rect = Rect::from_min_max( - pos2( - rect.left() + CARD_PAD, - rect.bottom() - 4.0 - hint.size().y - 4.0, - ), - pos2(rect.right() - CARD_PAD, rect.bottom() - 4.0), - ); - painter.rect_filled( - hint_rect, - CornerRadius::same(4), - t.bg_solid - .mix(0.82, Rgba::TRANSPARENT) - .alpha(0.82 * confirm) - .to_color32(), - ); - // A centred job lays out around x = 0. - let pos = pos2(hint_rect.center().x, hint_rect.top() + 2.0); - painter.galley(pos, hint, t.fg.to_color32()); - } - } - - // ---------------------------------------------------------------- list - - fn list(&mut self, ui: &mut Ui, platform: Platform, items: &[Item], entrance: f32) { - let t = self.theme; - let width = ui.available_width() - SCROLL_GUTTER - 32.0; - let row_h = 44.0; - let height = 8.0 + items.len() as f32 * (row_h + 4.0); - let (area, _) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::hover()); - let mut painter = ui.painter().clone(); - painter.set_opacity(entrance); - for (i, item) in items.iter().enumerate() { - let rect = Rect::from_min_size( - pos2( - area.left() + 16.0, - area.top() + 8.0 + i as f32 * (row_h + 4.0), - ), - vec2(width, row_h), - ); - let (name, last, avatar, id) = match item { - Item::Account(idx) => { - let a = &self.data.accounts(platform)[*idx]; - ( - a.name.clone(), - data::relative_time(a.last_login), - a.avatar, - a.id.clone(), - ) - } - Item::Folder { id, name } => { - (name.to_string(), String::new(), None, id.to_string()) - } - }; - let resp = ui.interact(rect, Id::new(("row", id.clone())), Sense::click()); - let h = ease_out(anim(ui, resp.id, resp.hovered(), 0.15)); - let active = self.data.active(platform) == id; - let bg = if active { - t.card_hover - } else { - t.card.lerp(t.card_hover, h) - }; - painter.rect_filled(rect, CornerRadius::same(8), bg.to_color32()); - if let Item::Folder { id: fid, .. } = item { - if resp.clicked() { - self.folder = if *fid == ".." { None } else { Some(fid) }; - } - } - let av = Rect::from_min_size(rect.min + vec2(8.0, 8.0), Vec2::splat(28.0)); - painter.rect_filled(av, CornerRadius::same(4), t.muted.to_color32()); - if let Item::Folder { id: fid, .. } = item { - let svg = if *fid == ".." { - icons::BACK - } else { - icons::FOLDER - }; - let tex = self.icon(ui.ctx(), svg, 16.0); - painter.image( - tex.id(), - Rect::from_center_size(av.center(), Vec2::splat(16.0)), - Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), - t.fg_muted.to_color32(), - ); - } - if let Some(tex) = avatar.and_then(|a| self.avatars[a].as_ref()) { - painter.add( - RectShape::filled(av, CornerRadius::same(4), Color32::WHITE).with_texture( - tex.sharp.id(), - Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), - ), - ); - } - painter.text( - pos2(av.right() + 10.0, rect.center().y), - Align2::LEFT_CENTER, - name, - FontId::proportional(13.0), - t.fg.to_color32(), - ); - painter.text( - pos2(rect.right() - 12.0, rect.center().y), - Align2::RIGHT_CENTER, - last, - FontId::proportional(11.0), - t.fg_subtle.to_color32(), - ); - } - } -} - -// --------------------------------------------------------------- helpers - -fn semibold() -> FontFamily { - FontFamily::Name(crate::ui::text::SEMIBOLD.into()) -} - -/// The faces WebView2 draws the UI with ([`crate::ui::text`]). The scene -/// runner calls it before the first frame: fonts set mid-frame only apply one -/// frame later. +/// Installs the UI fonts. Fonts set mid-frame only apply one frame later, so +/// the runner calls this before the first frame. pub fn install_fonts(ctx: &egui::Context) { crate::ui::text::install(ctx, false); } - -fn button(ui: &Ui, id: Id, rect: Rect, label: &str) -> egui::Response { - let resp = ui.interact(rect, id, Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); - resp -} - -fn anim(ui: &Ui, id: Id, on: bool, secs: f32) -> f32 { - ui.ctx().animate_bool_with_time(id, on, secs) -} - -fn scale_about_center(rect: Rect, s: f32) -> Rect { - Rect::from_center_size(rect.center(), rect.size() * s) -} - -/// `0 12px 24px rgba(0,0,0,a)`. -fn shadow(painter: &egui::Painter, rect: Rect, alpha: f32) { - painter.add( - RectShape::filled( - rect.translate(vec2(0.0, 12.0)), - CornerRadius::same(8), - Color32::from_black_alpha((255.0 * alpha) as u8), - ) - .with_blur_width(24.0), - ); -} - -/// 12 px at weight 500, centred, ellipsis when too wide. The webview draws -/// 500 with the variable Segoe UI; the static semibold is the closest face. -fn name_label(painter: &egui::Painter, card: Rect, name: &str, fg: Rgba) { - let font = FontId::new(12.0, semibold()); - let max_w = card.width() - 2.0 * CARD_PAD; - let mut job = egui::text::LayoutJob::simple_singleline(name.to_string(), font, fg.to_color32()); - job.wrap = egui::text::TextWrapping::truncate_at_width(max_w); - let galley = painter.layout_job(job); - // Baseline where the webview puts it: line box of 14.4 px under the - // avatar, Segoe's ascent pushing the baseline ~12.2 px into it. - let line_top = card.top() + CARD_PAD + AVATAR + 8.0; - let pos = pos2( - card.center().x - galley.size().x / 2.0, - line_top + (14.4 - galley.size().y) / 2.0 + 1.0, - ); - painter.add(TextShape::new(pos, galley, fg.to_color32())); -} - -fn spinner(painter: &egui::Painter, center: Pos2, now: f64, t: &Theme) { - let r = 9.0; - painter.circle_stroke(center, r, Stroke::new(2.0, t.elevated.to_color32())); - let start = (now / 0.7 * std::f64::consts::TAU) as f32; - let points: Vec = (0..=16) - .map(|i| { - let a = start + i as f32 / 16.0 * std::f32::consts::FRAC_PI_2; - center + vec2(a.cos(), a.sin()) * r - }) - .collect(); - painter.add(Shape::line(points, Stroke::new(2.0, t.fg.to_color32()))); -} - -/// 145 degree linear gradient, as `getAvatarGradientStyle` paints it. -fn gradient_image(a: Rgba, b: Rgba, size: usize) -> ColorImage { - let angle = 145f32.to_radians(); - let dir = vec2(angle.sin(), -angle.cos()); - let half = (dir.x.abs() + dir.y.abs()) / 2.0; - let mut pixels = Vec::with_capacity(size * size); - for y in 0..size { - for x in 0..size { - let p = vec2( - x as f32 / (size - 1) as f32 - 0.5, - y as f32 / (size - 1) as f32 - 0.5, - ); - let t = ((p.dot(dir) / half) + 1.0) / 2.0; - pixels.push(a.lerp(b, t.clamp(0.0, 1.0)).to_color32()); - } - } - ColorImage::new([size, size], pixels) -} diff --git a/crates/accshift-egui/src/screens/mod.rs b/crates/accshift-egui/src/screens/mod.rs index 3d820e8..4c4df61 100644 --- a/crates/accshift-egui/src/screens/mod.rs +++ b/crates/accshift-egui/src/screens/mod.rs @@ -2,6 +2,7 @@ pub mod main_spike; pub mod primitives; +pub mod workspace; use std::path::Path; diff --git a/crates/accshift-egui/src/screens/workspace.rs b/crates/accshift-egui/src/screens/workspace.rs new file mode 100644 index 0000000..35b4e59 --- /dev/null +++ b/crates/accshift-egui/src/screens/workspace.rs @@ -0,0 +1,1614 @@ +//! `src/lib/app/AppWorkspace.svelte` in grid mode: the `main.content` +//! scroll box with its toolbar row (breadcrumb, search, view toggle), the +//! error banner, the centre messages and the card grid, flat or in folder +//! sections. +//! +//! - `.content`: `padding: 10px 16px 16px`, `overflow-y: auto`, +//! `scrollbar-gutter: stable` (the 10 px `::-webkit-scrollbar` of +//! `app.css`, thumb `--bg-muted`, hover `--bg-elevated`, radius 3). +//! - `.toolbar-row`: flex, `align-items: center`, gap 8, `padding-bottom: +//! 8px`; the breadcrumb takes the free width. +//! - `.search-input`: `min(240px, 38vw)` by 30, 1 px `--border`, radius 8, +//! `--bg-card`, 12 px, `padding: 0 10px`; focus border `color-mix(--fg +//! 35%, --border)`. +//! - `.grid-container`: flex wrap, gap 10, `padding-left` from +//! `useGridLayout`. `.sections-wrapper`: column, gap 22; `.section`: +//! column, gap 8; a section grid enters and leaves with `transition:slide +//! {duration: 220}`. +//! +//! The Svelte grid is not virtualised: every card is in the DOM. Here every +//! card is laid out and sensed (so Tab walks them all in DOM order), but +//! only the cards that cross the viewport are painted. +//! +//! Cards are keyed like the Svelte `{#each ... (item.id)}` blocks: a key +//! seen for the first time is a new mount, which restarts `card-entrance` +//! and the avatar fade. A key that disappears for a frame is unmounted. +//! +//! The list view is not ported: `viewMode === "list"` draws a placeholder. + +use std::collections::{HashMap, HashSet}; + +use accshift_app::actions::avatar_fallback::{avatar_gradient, avatar_seed}; +use accshift_app::display::{ + AccountCardItem, DisplayBody, DisplayItem, FolderCardItem, SectionHeaderItem, SectionLabel, +}; +use accshift_app::i18n::{translate, Locale}; +use accshift_app::jobs::Jobs; +use accshift_app::stores::{ClientStores, ViewMode}; +use accshift_theme::ResolvedTheme; +use egui::{pos2, vec2, Event, Id, Key, Rect, Sense, Ui, WidgetInfo, WidgetType}; + +use crate::app::accounts::Accounts; +use crate::app::avatars::AvatarTexture; +use crate::app::platforms::PlatformDef; +use crate::app::vars::{rgba, Vars}; +use crate::app::Action; +use crate::theme::Rgba; +use crate::ui::anim::Motion; +use crate::ui::components::account_card::{ + self, AccountCardProps, AvatarContent, AvatarPicture, CardEvent, +}; +use crate::ui::components::avatar_fade_in; +use crate::ui::components::back_card::{self, BackCardEvent, BackCardProps}; +use crate::ui::components::breadcrumb::{self, BreadcrumbProps, Crumb}; +use crate::ui::components::folder_card::{self, entrance, FolderCardEvent, FolderCardProps}; +use crate::ui::components::section_header::{self, SectionHeaderEvent, SectionHeaderProps}; +use crate::ui::components::use_grid_layout::{grid_layout, GAP}; +use crate::ui::components::view_toggle::{self, ViewToggleProps}; +use crate::ui::css; +use crate::ui::paint::{Border, BoxStyle, Corners, Gradient}; +use crate::ui::snap; +use crate::ui::text::{self, LineHeight, TextAlign, TextStyle}; + +/// `.content { padding: 10px 16px 16px }`. +const PAD_TOP: f32 = 10.0; +const PAD_X: f32 = 16.0; +const PAD_BOTTOM: f32 = 16.0; +/// `::-webkit-scrollbar { width: 10px }`, kept by `scrollbar-gutter: stable`. +pub const SCROLLBAR: f32 = 10.0; +/// `.toolbar-row { gap: 8px; padding-bottom: 8px }`. +const TOOLBAR_GAP: f32 = 8.0; +/// `.search-input { width: min(240px, 38vw); height: 30px }`. +const SEARCH_W: f32 = 240.0; +const SEARCH_H: f32 = 30.0; +/// `.sections-wrapper { gap: 22px }`, `.section { gap: 8px }`. +const SECTIONS_GAP: f32 = 22.0; +const SECTION_GAP: f32 = 8.0; +/// `transition:slide={{ duration: 220 }}` (Svelte's `cubicOut`). +const SLIDE: f64 = 0.22; +/// Chromium's caret blink half period. +const CARET_BLINK: f64 = 0.5; + +/// What the workspace reads this frame. Built by the shell. +pub struct WorkspaceInput<'a> { + pub vars: &'a Vars, + pub resolved: &'a ResolvedTheme, + pub motion: Motion, + pub locale: Locale, + pub stores: &'a ClientStores, + pub accounts: &'a mut Accounts, + pub jobs: &'a Jobs, + /// The active platform's definition. + pub platform: Option<&'a PlatformDef>, + /// `activeTabUsable`. + pub usable: bool, + /// `compatiblePlatformCount`. + pub compatible_platforms: usize, + /// `panelKey`: a new key remounts everything. + pub panel_key: &'a str, + pub focus_visible: bool, + pub now: f64, +} + +/// A section grid entering or leaving. +struct Slide { + start: f64, + intro: bool, + /// The cards of a leaving section, drawn until it is gone. + items: Vec, +} + +/// The search field's editing state (the value lives in `Navigation`). +#[derive(Default)] +struct Search { + /// When the caret blink restarted (focus or edit). + blink_from: f64, + focused: bool, +} + +/// State kept across frames. +#[derive(Default)] +pub struct Workspace { + scroll: f32, + thumb_drag: Option<(f32, f32)>, + search: Search, + /// Key to (mount generation, frame last seen). + mounts: HashMap, + generation: u64, + last_frame: Option, + /// When the latest card mounted. + last_mount: f64, + /// When the latest late avatar was revealed. + last_reveal: f64, + /// Visible cards whose avatar is still decoding. + decoding: HashSet, + visible_decoding: usize, + collapsed: HashMap, + slides: HashMap, + gradients: HashMap<(u16, u16), (Rgba, Option)>, + focused: Option, + /// The pointer at the end of the last frame. + pointer: Option, + /// The last frame's boxes (cards, headers) with their labels, in layout + /// px relative to the content box top left, unscrolled: the golden test + /// reads them. + pub layout: Vec<(String, Rect)>, + /// The scroll offset in layout px (`scrollTop`). + pub content_height: f32, +} + +impl Workspace { + pub fn new() -> Self { + Self::default() + } + + /// `scrollTop` of the content box. + pub fn scroll(&self) -> f32 { + self.scroll + } + + /// Nothing animates and no visible picture is decoding. + pub fn settled(&self, vars: &Vars, motion: Motion, now: f64) -> bool { + let full = motion == Motion::Full; + self.slides.is_empty() + && self.visible_decoding == 0 + && (!full || now - self.last_mount >= vars.motion_card_entrance) + && (!full || now - self.last_reveal >= vars.motion_avatar_fade) + } + + /// The mount id of `key` this frame. + fn mount(&mut self, key: &str, frame: u64, now: f64) -> Id { + let generation = match self.mounts.get_mut(key) { + Some(entry) => { + entry.1 = frame; + entry.0 + } + None => { + self.generation += 1; + self.last_mount = now; + self.mounts + .insert(key.to_string(), (self.generation, frame)); + self.generation + } + }; + Id::new(("ws-card", key, generation)) + } + + /// `getAvatarGradientStyle(seed)`: background colour and gradient. + fn initials(&mut self, resolved: &ResolvedTheme, item: &AccountCardItem) -> AvatarContent { + let account = &item.account; + let g = avatar_gradient(&avatar_seed( + &account.display_name, + &account.username, + &account.id, + )); + let (background, gradient) = self + .gradients + .entry((g.hue, g.hue_fade)) + .or_insert_with(|| { + let hsl = |h: u16, s: u8, l: u8| { + resolved + .eval_color(&format!("hsl({h} {s}% {l}%)")) + .map(rgba) + .unwrap_or(Rgba::TRANSPARENT) + }; + let css_rgba = |c: Rgba| format!("rgba({}, {}, {}, {})", c.r, c.g, c.b, c.a); + let gradient = css::gradient(&format!( + "linear-gradient(145deg, {}, {})", + css_rgba(hsl(g.hue, 80, 56)), + css_rgba(hsl(g.hue_fade, 66, 38)), + )); + (hsl(g.hue, 72, 43), gradient) + }) + .clone(); + AvatarContent::Initials { + background, + gradient, + } + } +} + +/// What a laid out box holds. +enum Node<'a> { + Back, + EmptyFolder, + Header(&'a SectionHeaderItem), + Folder(&'a FolderCardItem), + Account(&'a AccountCardItem), +} + +impl Node<'_> { + /// The card's name, as `DisplayItem::label` gives it. + fn label(&self) -> String { + match self { + Node::Back => "Back".into(), + Node::EmptyFolder => "empty".into(), + Node::Header(h) => match &h.label { + SectionLabel::Folder(f) => f.name.clone(), + SectionLabel::Root => "Root".into(), + }, + Node::Folder(f) => f.folder.name.clone(), + Node::Account(a) => a.name.clone(), + } + } +} + +struct Placed<'a> { + key: String, + /// Layout box relative to the content box top left, unscrolled. + rect: Rect, + /// Clip of a sliding section grid, same space. + clip: Option, + opacity: f32, + node: Node<'a>, +} + +/// One section of the sections layout. +struct Section<'a> { + key: String, + header: Option<&'a SectionHeaderItem>, + items: Vec<&'a DisplayItem>, +} + +fn cubic_out(t: f64) -> f64 { + let f = t - 1.0; + f * f * f + 1.0 +} + +/// `cardColor` of the view (`""`, `"accent"` in bulk edit, or a CSS colour). +fn card_color(resolved: &ResolvedTheme, vars: &Vars, value: &str) -> Option { + match value.trim() { + "" => None, + "accent" => Some(vars.accent), + css => resolved.eval_color(css).map(rgba), + } +} + +/// Lays out `items` as a wrapping row grid from `(left, top)`, appending +/// to `out`. Returns the grid height. +#[allow(clippy::too_many_arguments)] +fn flow<'a>( + out: &mut Vec>, + items: &[&'a DisplayItem], + prefix: &str, + left: f32, + top: f32, + width: f32, + card: egui::Vec2, + clip: Option, + opacity: f32, +) -> f32 { + let per_row = (((width + GAP) / (card.x + GAP)).floor() as usize).max(1); + let mut n = 0; + for item in items { + let node = match item { + DisplayItem::Back => Node::Back, + DisplayItem::Folder(f) => Node::Folder(f), + DisplayItem::Account(a) => Node::Account(a), + DisplayItem::SectionHeader(_) => continue, + }; + let key = match &node { + Node::Back => format!("{prefix}/back"), + Node::Folder(f) => format!("{prefix}/f/{}", f.folder.id), + Node::Account(a) => format!("{prefix}/a/{}", a.account.id), + _ => unreachable!(), + }; + let (col, row) = (n % per_row, n / per_row); + let rect = Rect::from_min_size( + pos2( + left + col as f32 * (card.x + GAP), + top + row as f32 * (card.y + GAP), + ), + card, + ); + out.push(Placed { + key, + rect, + clip, + opacity, + node, + }); + n += 1; + } + if n == 0 { + return 0.0; + } + let rows = n.div_ceil(per_row); + rows as f32 * (card.y + GAP) - GAP +} + +/// Draws the workspace into `rect` (the panel slot below the title bar). +pub fn draw(ui: &mut Ui, rect: Rect, ws: &mut Workspace, input: WorkspaceInput) -> Vec { + let ctx = ui.ctx().clone(); + let ppp = ctx.pixels_per_point(); + let v = input.vars; + let now = input.now; + let frame = ctx.cumulative_frame_nr(); + if ws.last_frame.is_none_or(|last| last + 1 != frame) { + // Not drawn last frame: everything unmounted. + ws.mounts.clear(); + ws.slides.clear(); + ws.collapsed.clear(); + ws.decoding.clear(); + } + ws.last_frame = Some(frame); + let mut actions = Vec::new(); + let accent = input.platform.map_or(v.accent, |p| p.accent); + let platform_name = input.platform.map_or("", |p| p.name.as_str()); + + // `.content`: the scroll box, its gutter on the right. + let viewport = Rect::from_min_max(rect.min, pos2(rect.right() - SCROLLBAR, rect.bottom())); + let inner_w = viewport.width() - 2.0 * PAD_X; + let clip = viewport.intersect(ui.clip_rect()); + let origin = pos2(viewport.left() + PAD_X, viewport.top() + PAD_TOP); + + let mut slides = std::mem::take(&mut ws.slides); + let mut placed: Vec = Vec::new(); + // Everything above the cards, in content coordinates. + let mut y = 0.0; + let path_owned: Vec<(String, String)> = input + .accounts + .navigation + .folder_path(input.stores) + .into_iter() + .map(|f| (f.id.clone(), f.name.clone())) + .collect(); + let path: Vec = path_owned + .iter() + .map(|(id, name)| Crumb { id, name }) + .collect(); + let crumbs = BreadcrumbProps { + vars: v, + platform_name, + path: &path, + accent, + focus_visible: input.focus_visible, + }; + + let Accounts { + view, + prev_view, + lists, + avatars, + navigation, + registry, + .. + } = &mut *input.accounts; + let tab = input.platform.map_or("", |p| p.id.as_str()); + let adapter_ready = registry.adapter(tab).is_some(); + let scroll = ws.scroll; + let to_screen = move |r: Rect| r.translate(origin.to_vec2() - vec2(0.0, scroll)); + + enum Body { + Message { + spinner: bool, + lines: Vec<(String, bool)>, + }, + Toolbar, + } + let body = if input.compatible_platforms == 0 { + Body::Message { + spinner: false, + lines: vec![ + (translate(input.locale, "app.noCompatiblePlatforms"), false), + ( + translate(input.locale, "app.noCompatiblePlatformsHint"), + true, + ), + ], + } + } else if input.usable && !adapter_ready { + Body::Message { + spinner: true, + lines: vec![(translate(input.locale, "app.loading"), true)], + } + } else if input.usable { + Body::Toolbar + } else { + // Breadcrumb, then the unsupported message. + let h = breadcrumb::height(ui, &crumbs, inner_w); + let r = to_screen(Rect::from_min_size(pos2(0.0, y), vec2(inner_w, h))); + if let Some(target) = breadcrumb::breadcrumb(ui, r, &crumbs) { + actions.push(Action::NavigateToFolder(target)); + } + y += h; + let message = accshift_app::text::Text::with( + "app.platformUnsupportedOs", + [("platform", platform_name.to_string())], + ) + .resolve(input.locale); + Body::Message { + spinner: false, + lines: vec![(message, true)], + } + }; + + match body { + Body::Message { spinner, lines } => { + y += center_message(ui, v, accent, to_screen, y, inner_w, spinner, &lines, now); + } + Body::Toolbar => { + // `.toolbar-row`. + let search_w = SEARCH_W.min(0.38 * ctx.content_rect().width()); + let crumb_w = (inner_w - search_w - view_toggle::WIDTH - 2.0 * TOOLBAR_GAP).max(0.0); + let crumb_h = breadcrumb::height(ui, &crumbs, crumb_w); + let row_h = crumb_h.max(SEARCH_H); + let crumb_rect = to_screen(Rect::from_min_size( + pos2(0.0, y + (row_h - crumb_h) / 2.0), + vec2(crumb_w, crumb_h), + )); + if let Some(target) = breadcrumb::breadcrumb(ui, crumb_rect, &crumbs) { + actions.push(Action::NavigateToFolder(target)); + } + let search_rect = to_screen(Rect::from_min_size( + pos2(crumb_w + TOOLBAR_GAP, y + (row_h - SEARCH_H) / 2.0), + vec2(search_w, SEARCH_H), + )); + if let Some(query) = search_field( + ui, + search_rect, + &mut ws.search, + v, + navigation.search_query(), + &translate(input.locale, "app.searchPlaceholder"), + now, + ) { + actions.push(Action::SearchChanged(query)); + } + let toggle_rect = to_screen(Rect::from_min_size( + pos2( + inner_w - view_toggle::WIDTH, + y + (row_h - view_toggle::HEIGHT) / 2.0, + ), + vec2(view_toggle::WIDTH, view_toggle::HEIGHT), + )); + let view_mode = input.stores.view_mode(); + if let Some(mode) = view_toggle::view_toggle( + ui, + toggle_rect, + &ViewToggleProps { + vars: v, + mode: view_mode, + locale: input.locale, + focus_visible: input.focus_visible, + }, + ) { + actions.push(Action::SetViewMode(mode)); + } + y += row_h + TOOLBAR_GAP; + + if let Some(error) = &view.error { + y += error_banner(ui, to_screen, y, inner_w, &error.resolve(input.locale)); + } + + match &view.body { + DisplayBody::Loading => { + let lines = vec![(translate(input.locale, "app.loading"), true)]; + y += center_message(ui, v, accent, to_screen, y, inner_w, true, &lines, now); + } + DisplayBody::NoAccounts { title, hint } => { + let lines = vec![ + (title.resolve(input.locale), false), + (hint.resolve(input.locale), true), + ]; + y += center_message(ui, v, accent, to_screen, y, inner_w, false, &lines, now); + } + DisplayBody::NoSearchResults(text) => { + let lines = vec![(text.resolve(input.locale), false)]; + y += center_message(ui, v, accent, to_screen, y, inner_w, false, &lines, now); + } + DisplayBody::Items { .. } if view_mode == ViewMode::List => { + // The list view is a later port. + let lines = vec![(translate(input.locale, "view.list"), true)]; + y += center_message(ui, v, accent, to_screen, y, inner_w, false, &lines, now); + } + DisplayBody::Items { + items, + folder_empty, + } => { + let layout = grid_layout(inner_w, v.density_scale); + let pad = layout.map_or(0.0, |l| l.padding_left); + let card = vec2(v.grid_card_width(), v.grid_card_min_height()); + let prefix = format!("{}/", input.panel_key); + if let Some(sections) = &lists.sections { + y += lay_sections( + &mut slides, + &mut ws.collapsed, + &mut placed, + sections, + items, + prev_view.items(), + &prefix, + pad, + y, + inner_w - pad, + card, + input.motion, + now, + ); + } else { + let refs: Vec<&DisplayItem> = items.iter().collect(); + let prefix = format!("{prefix}flat"); + let h = flow( + &mut placed, + &refs, + &prefix, + pad, + y, + inner_w - pad, + card, + None, + 1.0, + ); + if *folder_empty { + // `.empty-folder-msg`: its own row under the + // back card, `padding: 2px 2px 0`, 12 px. + let top = y + card.y + GAP; + placed.push(Placed { + key: format!("{prefix}/empty"), + rect: Rect::from_min_size( + pos2(pad, top), + vec2(inner_w - pad, 20.0), + ), + clip: None, + opacity: 1.0, + node: Node::EmptyFolder, + }); + y = top + 20.0; + } else { + y += h; + } + } + } + } + } + } + let content_h = PAD_TOP + y + PAD_BOTTOM; + ws.content_height = content_h; + + // Draw the cards. + let view_rect = Rect::from_min_size(pos2(0.0, scroll - PAD_TOP), viewport.size()); + let avatar_px = (v.grid_card_avatar_size() * ppp).round() as u32; + ws.visible_decoding = 0; + ws.layout.clear(); + let mut focused_rect = None; + let focused_now = ctx.memory(|m| m.focused()); + for p in &placed { + ws.layout.push((p.node.label(), p.rect)); + let id = ws.mount(&p.key, frame, now); + let visible = p + .clip + .map_or(p.rect, |c| p.rect.intersect(c)) + .expand(32.0) + .intersects(view_rect) + && p.clip.is_none_or(|c| c.height() > 0.0); + let screen = to_screen(p.rect); + if focused_now == Some(id) { + focused_rect = Some(p.rect); + } + let avatar = match &p.node { + Node::Account(a) => Some(account_avatar( + ws, + avatars, + input.jobs, + input.resolved, + &p.key, + a, + avatar_px, + visible, + now, + )), + _ => None, + }; + if !visible { + // Laid out and sensed, not painted; its entrance and fade clocks + // run as if it were on screen. + let label = match &p.node { + Node::Back => translate(input.locale, "common.back"), + Node::EmptyFolder => continue, + Node::Header(h) => match &h.label { + SectionLabel::Folder(f) => f.name.clone(), + SectionLabel::Root => translate(input.locale, "list.rootSection"), + }, + Node::Folder(f) => f.folder.name.clone(), + Node::Account(a) => a.name.clone(), + }; + sense_only(ui, screen, id, &label); + if !matches!(p.node, Node::Header(_)) { + entrance(ui, id, v); + } + if let Some(avatar) = &avatar { + let revealed = !matches!(avatar, AvatarContent::Decoding); + avatar_fade_in::avatar_opacity(&ctx, id, revealed, v); + } + continue; + } + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(screen)); + let child_clip = match p.clip { + Some(c) => to_screen(c).intersect(clip), + None => clip, + }; + child.set_clip_rect(child_clip); + child.multiply_opacity(p.opacity); + match &p.node { + Node::Back => { + for event in back_card::back_card( + &mut child, + screen, + &BackCardProps { + vars: v, + id, + locale: input.locale, + accent, + drag_over: false, + focus_visible: input.focus_visible, + }, + ) { + match event { + BackCardEvent::Back => actions.push(Action::NavigateBack), + } + } + } + Node::EmptyFolder => { + let (o, _) = entrance(&child, id, v); + let style = TextStyle::new(12.0, 400) + .line_height(LineHeight::Number(1.5)) + .color(v.fg_muted.alpha(v.fg_muted.a * o).to_color32()); + let r = screen.translate(vec2(2.0, 2.0)); + text::paint( + child.painter(), + r, + &translate(input.locale, "app.folderEmpty"), + &style, + ); + } + Node::Header(h) => { + let (folder, label) = match &h.label { + SectionLabel::Folder(f) => (Some(f.id.as_str()), f.name.clone()), + SectionLabel::Root => (None, translate(input.locale, "list.rootSection")), + }; + for event in section_header::section_header( + &mut child, + screen, + &SectionHeaderProps { + vars: v, + id, + folder, + label: &label, + count: h.count, + card_color: card_color(input.resolved, v, &h.card_color), + accent, + collapsed: h.collapsed, + focus_visible: input.focus_visible, + }, + ) { + match event { + SectionHeaderEvent::Toggle => { + actions.push(Action::ToggleSection(h.collapse_key.clone())) + } + SectionHeaderEvent::Navigate(id) => { + actions.push(Action::NavigateToFolder(Some(id))) + } + SectionHeaderEvent::ContextMenu(_) => {} + } + } + } + Node::Folder(f) => { + for event in folder_card::folder_card( + &mut child, + screen, + &FolderCardProps { + vars: v, + id, + name: &f.folder.name, + card_color: card_color(input.resolved, v, &f.card_color), + accent, + drag_over: false, + dragged: f.dragged, + focus_visible: input.focus_visible, + }, + ) { + match event { + FolderCardEvent::Open => { + actions.push(Action::NavigateToFolder(Some(f.folder.id.clone()))) + } + FolderCardEvent::ContextMenu(_) => {} + } + } + } + Node::Account(a) => { + for event in account_card::account_card( + &mut child, + screen, + &AccountCardProps { + vars: v, + id, + item: a, + card_color: card_color(input.resolved, v, &a.card_color), + avatar: avatar.clone().unwrap_or(AvatarContent::Loading), + focus_visible: input.focus_visible, + }, + ) { + match event { + CardEvent::Activate => { + actions.push(Action::AccountActivate(a.account.id.clone())) + } + CardEvent::Switch => { + actions.push(Action::AccountSwitch(a.account.id.clone())) + } + CardEvent::ContextMenu(_) => {} + } + } + } + } + } + drop(placed); + let sliding = !slides.is_empty(); + ws.slides = slides; + if sliding { + ctx.request_repaint(); + } + // Unmount what was not drawn. + ws.mounts.retain(|_, (_, seen)| *seen == frame); + let mounted: HashSet<&String> = ws.mounts.keys().collect(); + ws.decoding.retain(|k| mounted.contains(k)); + + // Scrolling: the wheel over the box, the thumb, and keyboard focus. + let max_scroll = (content_h - viewport.height()).max(0.0); + // The wheel events themselves, not egui's smoothed delta: Chromium + // applies a point delta at once, at the pointer position of the event + // (a scene can move the pointer again in the same frame). A line is + // Windows' 100 px notch (3 lines of 100/3 px). + let page = viewport.height(); + let target = rect.intersect(ui.clip_rect()); + let dy: f32 = ctx.input(|i| { + let mut at = ws.pointer; + let mut dy = 0.0; + for event in &i.events { + match event { + Event::PointerMoved(p) => at = Some(*p), + Event::PointerGone => at = None, + Event::MouseWheel { unit, delta, .. } if at.is_some_and(|p| target.contains(p)) => { + dy += match unit { + egui::MouseWheelUnit::Point => delta.y, + egui::MouseWheelUnit::Line => delta.y * 100.0, + egui::MouseWheelUnit::Page => delta.y * page, + }; + } + _ => {} + } + } + dy + }); + ws.pointer = ctx.input(|i| i.pointer.latest_pos()); + if dy != 0.0 { + ws.scroll -= dy; + } + if focused_now != ws.focused { + ws.focused = focused_now; + if let Some(r) = focused_rect { + let top = r.top() + PAD_TOP - 2.0; + let bottom = r.bottom() + PAD_TOP + 2.0; + if top < ws.scroll { + ws.scroll = top; + } else if bottom > ws.scroll + viewport.height() { + ws.scroll = bottom - viewport.height(); + } + } + } + scrollbar(ui, rect, ws, v, content_h, max_scroll); + ws.scroll = ws.scroll.clamp(0.0, max_scroll).round(); + if ws.scroll != scroll { + ctx.request_repaint(); + } + actions +} + +/// The avatar box content of an account card, starting its decode. +#[allow(clippy::too_many_arguments)] +fn account_avatar( + ws: &mut Workspace, + avatars: &mut crate::app::avatars::AvatarTextures, + jobs: &Jobs, + resolved: &ResolvedTheme, + key: &str, + a: &AccountCardItem, + px: u32, + visible: bool, + now: f64, +) -> AvatarContent { + let picture = a + .avatar + .url + .as_deref() + .map(|url| avatars.get(jobs, url, px)); + match picture { + Some(AvatarTexture::Ready { texture, size }) => { + if ws.decoding.remove(key) { + ws.last_reveal = now; + } + AvatarContent::Picture(AvatarPicture { texture, size }) + } + Some(AvatarTexture::Pending) => { + ws.decoding.insert(key.to_string()); + if visible { + ws.visible_decoding += 1; + } + AvatarContent::Decoding + } + Some(AvatarTexture::Failed) | None => { + ws.decoding.remove(key); + if a.avatar.loading { + AvatarContent::Loading + } else { + ws.initials(resolved, a) + } + } + } +} + +/// Senses a card that is not painted, so Tab still walks it. +fn sense_only(ui: &mut Ui, rect: Rect, id: Id, label: &str) { + let response = ui.interact(rect, id, Sense::click()); + response.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, label)); + ui.ctx() + .accesskit_node_builder(id, |node| node.set_role(egui::accesskit::Role::Button)); +} + +/// The sections layout: per section its header, then (unless collapsed) +/// its grid 8 px under it; sections 22 px apart. Returns the height. +#[allow(clippy::too_many_arguments)] +fn lay_sections<'a>( + slides: &'a mut HashMap, + collapsed: &mut HashMap, + out: &mut Vec>, + sections: &[accshift_app::folders::FolderSection], + items: &'a [DisplayItem], + previous: &[DisplayItem], + prefix: &str, + left: f32, + top: f32, + width: f32, + card: egui::Vec2, + motion: Motion, + now: f64, +) -> f32 { + // Split the view's items on their headers, following the pipeline's + // sections (a root section with nothing in it has no header). + let mut parts: Vec> = Vec::new(); + let mut it = items.iter().peekable(); + for section in sections { + let count = section.folder_items.len() + section.account_items.len(); + let key = accshift_app::display::section_collapse_key(section).to_string(); + let header = if section.folder.is_some() || count > 0 { + match it.next() { + Some(DisplayItem::SectionHeader(h)) => Some(h), + _ => None, + } + } else { + None + }; + let mut cards = Vec::new(); + while let Some(item) = it.peek() { + if matches!(item, DisplayItem::SectionHeader(_)) { + break; + } + cards.push(*item); + it.next(); + } + parts.push(Section { + key, + header, + items: cards, + }); + } + + // Collapse changes start slides. + let mut seen = HashMap::new(); + for part in &parts { + let Some(h) = part.header else { continue }; + let was = collapsed.get(&part.key).copied(); + if motion == Motion::Full { + match (was, h.collapsed) { + (Some(false), true) => { + let cached = section_items(previous, &part.key); + if !cached.is_empty() { + slides.insert( + part.key.clone(), + Slide { + start: now, + intro: false, + items: cached, + }, + ); + } + } + (Some(true), false) => { + slides.insert( + part.key.clone(), + Slide { + start: now, + intro: true, + items: Vec::new(), + }, + ); + } + _ => {} + } + } + seen.insert(part.key.clone(), h.collapsed); + } + *collapsed = seen; + slides.retain(|key, slide| now - slide.start < SLIDE && parts.iter().any(|p| &p.key == key)); + let slides: &'a HashMap = slides; + + let mut y = top; + for (i, part) in parts.iter().enumerate() { + if i > 0 { + y += SECTIONS_GAP; + } + let section_prefix = format!("{prefix}s/{}", part.key); + if let Some(h) = part.header { + let height = if h.label == SectionLabel::Root { + 23.0 + } else { + 24.0 + }; + out.push(Placed { + key: format!("{section_prefix}/header"), + rect: Rect::from_min_size(pos2(left, y), vec2(width, height)), + clip: None, + opacity: 1.0, + node: Node::Header(h), + }); + y += height; + } + let slide = slides.get(&part.key); + let leaving: Vec<&'a DisplayItem>; + let cards: &[&'a DisplayItem] = match slide { + Some(s) if !s.intro => { + leaving = s.items.iter().collect(); + &leaving + } + _ => &part.items, + }; + if cards.is_empty() { + continue; + } + if part.header.is_some() { + y += SECTION_GAP; + } + // Lay out at full height, then clip to the slide's height. + let start = out.len(); + let full = flow(out, cards, §ion_prefix, left, y, width, card, None, 1.0); + let factor = slide.map_or(1.0, |s| { + let e = cubic_out(((now - s.start) / SLIDE).clamp(0.0, 1.0)); + if s.intro { + e + } else { + 1.0 - e + } + }) as f32; + if slide.is_some() { + let clip = + Rect::from_min_size(pos2(left - 64.0, y), vec2(width + 128.0, full * factor)); + for p in &mut out[start..] { + p.clip = Some(clip); + p.opacity = (factor * 20.0).min(1.0); + } + } + y += full * factor; + } + y - top +} + +/// The cards of section `key` in a previous frame's items. +fn section_items(items: &[DisplayItem], key: &str) -> Vec { + let mut out = Vec::new(); + let mut inside = false; + for item in items { + match item { + DisplayItem::SectionHeader(h) => { + if inside { + break; + } + inside = h.collapse_key == key; + } + other if inside => out.push(other.clone()), + _ => {} + } + } + out +} + +/// `.center-msg`: `padding: 48px 0`, centred column, `--fg-muted`. Lines +/// are `(text, small)`: a plain `

` (16 px, line-height 1.5) or `.text-sm +/// mt-1 opacity-70` (14 px over 20 px). Returns the height. +#[allow(clippy::too_many_arguments)] +fn center_message( + ui: &mut Ui, + v: &Vars, + accent: Rgba, + to_screen: impl Fn(Rect) -> Rect, + top: f32, + width: f32, + spinner: bool, + lines: &[(String, bool)], + now: f64, +) -> f32 { + let mut y = top + 48.0; + if spinner { + // `.spinner`: 20 px ring, 2 px `--border`, top `accent`, 0.7 s turn. + let r = to_screen(Rect::from_min_size( + pos2((width - 20.0) / 2.0, y), + vec2(20.0, 20.0), + )); + let painter = ui.painter(); + let c = r.center(); + painter.circle_stroke(c, 9.0, egui::Stroke::new(2.0, v.border.to_color32())); + let turn = (now / 0.7).fract() as f32 * std::f32::consts::TAU; + let start = -3.0 * std::f32::consts::FRAC_PI_4 + turn; + let points: Vec = (0..=12) + .map(|k| { + let a = start + k as f32 / 12.0 * std::f32::consts::FRAC_PI_2; + c + vec2(a.cos(), a.sin()) * 9.0 + }) + .collect(); + painter.add(egui::Shape::line( + points, + egui::Stroke::new(2.0, accent.to_color32()), + )); + ui.ctx().request_repaint(); + y += 20.0; + } + for (i, (line, small)) in lines.iter().enumerate() { + let (size, line_h, color) = if *small { + ( + 14.0, + 20.0, + v.fg_muted + .alpha(v.fg_muted.a * if spinner { 1.0 } else { 0.7 }), + ) + } else { + (16.0, 24.0, v.fg_muted) + }; + if *small && i > 0 { + y += 4.0; + } + let style = TextStyle::new(size, 400) + .line_height(LineHeight::Px(line_h)) + .align(TextAlign::Center) + .color(color.to_color32()); + let r = to_screen(Rect::from_min_size(pos2(0.0, y), vec2(width, line_h))); + let laid = text::paint(ui.painter(), r, line, &style); + y += laid.map_or(line_h, |l| l.height.max(line_h)); + } + y + 48.0 - top +} + +/// `.error-banner`: `margin-bottom: 16px; padding: 12px; radius 8; 13px; +/// rgba(239,68,68,.1)` on `#f87171`. Returns its height with the margin. +fn error_banner( + ui: &mut Ui, + to_screen: impl Fn(Rect) -> Rect, + top: f32, + width: f32, + message: &str, +) -> f32 { + let style = TextStyle::new(13.0, 400) + .line_height(LineHeight::Number(1.5)) + .color(Rgba::hex(0xf87171, 1.0).to_color32()); + let inner = Rect::from_min_size(pos2(12.0, top + 12.0), vec2(width - 24.0, 19.5)); + let text_h = + text::layout(ui.ctx(), message, &style, to_screen(inner)).map_or(19.5, |l| l.height); + let r = to_screen(Rect::from_min_size( + pos2(0.0, top), + vec2(width, text_h + 24.0), + )); + BoxStyle { + background: Rgba::new(239.0, 68.0, 68.0, 0.1), + radius: Corners::all(8.0), + ..BoxStyle::default() + } + .paint(ui.painter(), r); + text::paint(ui.painter(), to_screen(inner), message, &style); + text_h + 24.0 + 16.0 +} + +/// The ``: returns the new value when it changed. +fn search_field( + ui: &mut Ui, + rect: Rect, + state: &mut Search, + v: &Vars, + value: &str, + placeholder: &str, + now: f64, +) -> Option { + let ctx = ui.ctx().clone(); + let ppp = ctx.pixels_per_point(); + let rect = snap::rect(rect, ppp); + let id = Id::new("workspace-search"); + let response = ui.interact(rect, id, Sense::click()); + let response = response.on_hover_cursor(egui::CursorIcon::Text); + response.widget_info(|| WidgetInfo::text_edit(true, value, value, placeholder)); + ctx.accesskit_node_builder(id, |node| { + node.set_role(egui::accesskit::Role::SearchInput); + node.set_label(placeholder); + }); + if response.clicked() { + response.request_focus(); + } + let focused = response.has_focus(); + if focused && !state.focused { + state.blink_from = now; + } + state.focused = focused; + + let mut next = value.to_string(); + if focused { + ctx.input(|i| { + for event in &i.events { + match event { + Event::Text(t) => next.push_str(t), + Event::Key { + key: Key::Backspace, + pressed: true, + .. + } => { + next.pop(); + } + // `type="search"`: Escape clears the field. + Event::Key { + key: Key::Escape, + pressed: true, + .. + } => next.clear(), + _ => {} + } + } + }); + if next != value { + state.blink_from = now; + } + } + + let border = if focused { + v.fg.mix(0.35, v.border) + } else { + v.border + }; + BoxStyle { + background: v.bg_card, + radius: Corners::all(8.0), + border: Border::all(snap::border_width(1.0, ppp), border), + ..BoxStyle::default() + } + .paint(ui.painter(), rect); + // The inner editor: 12 px over a 1.5 line, centred in the content box. + let line = 18.0; + let text_rect = Rect::from_min_max( + pos2(rect.left() + 11.0, rect.top() + (SEARCH_H - line) / 2.0), + pos2(rect.right() - 11.0, rect.top() + (SEARCH_H + line) / 2.0), + ); + let mut painter = ui.painter().clone(); + painter.set_clip_rect(ui.clip_rect().intersect(Rect::from_min_max( + pos2(text_rect.left(), rect.top()), + pos2(text_rect.right(), rect.bottom()), + ))); + let shown = next.as_str(); + let style = TextStyle::new(12.0, 400) + .line_height(LineHeight::Number(1.5)) + .nowrap(false); + if shown.is_empty() { + let color = v.fg_subtle.mix(0.55, Rgba::TRANSPARENT); + text::paint( + &painter, + text_rect, + placeholder, + &style.clone().color(color.to_color32()), + ); + } else { + text::paint( + &painter, + text_rect, + shown, + &style.clone().color(v.fg.to_color32()), + ); + } + if focused { + let phase = ((now - state.blink_from) / CARET_BLINK).floor() as i64; + if phase % 2 == 0 { + let x = text_rect.left() + text::measure(&ctx, shown, &style).unwrap_or(0.0); + let x = snap::coord(x, ppp); + let caret = Rect::from_min_size(pos2(x, text_rect.top()), vec2(1.0, line)); + painter.rect_filled(caret, 0.0, v.fg.to_color32()); + } + let next_flip = state.blink_from + (phase + 1) as f64 * CARET_BLINK; + ctx.request_repaint_after(std::time::Duration::from_secs_f64( + (next_flip - now).max(0.0), + )); + } + (next != value).then_some(next) +} + +/// The `::-webkit-scrollbar` of the content box, with a draggable thumb. +fn scrollbar( + ui: &mut Ui, + rect: Rect, + ws: &mut Workspace, + v: &Vars, + content_h: f32, + max_scroll: f32, +) { + if max_scroll <= 0.0 { + ws.thumb_drag = None; + return; + } + let track = Rect::from_min_max(pos2(rect.right() - SCROLLBAR, rect.top()), rect.max); + let track_h = track.height(); + let thumb_h = (track_h * track_h / content_h).round().max(SCROLLBAR); + let travel = track_h - thumb_h; + let top = (travel * ws.scroll.clamp(0.0, max_scroll) / max_scroll).round(); + let thumb = Rect::from_min_size( + pos2(track.left(), track.top() + top), + vec2(SCROLLBAR, thumb_h), + ); + let id = Id::new("workspace-scrollbar"); + let response = ui.interact(thumb, id, Sense::drag()); + if response.drag_started() { + if let Some(p) = response.interact_pointer_pos() { + ws.thumb_drag = Some((p.y, ws.scroll)); + } + } + if response.dragged() { + if let (Some((from, start)), Some(p)) = (ws.thumb_drag, response.interact_pointer_pos()) { + if travel > 0.0 { + ws.scroll = start + (p.y - from) * max_scroll / travel; + } + } + } else { + ws.thumb_drag = None; + } + let color = if response.hovered() || response.dragged() { + v.bg_elevated + } else { + v.bg_muted + }; + BoxStyle { + background: color, + radius: Corners::all(3.0), + ..BoxStyle::default() + } + .paint(ui.painter(), snap::rect(thumb, ui.ctx().pixels_per_point())); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slide_easing_ends_where_it_should() { + assert_eq!(cubic_out(0.0), 0.0); + assert_eq!(cubic_out(1.0), 1.0); + assert!(cubic_out(0.5) > 0.8); + } + + #[test] + fn section_items_takes_one_section() { + use accshift_app::display::SectionHeaderItem; + let header = |key: &str| { + DisplayItem::SectionHeader(SectionHeaderItem { + label: SectionLabel::Root, + collapse_key: key.into(), + count: 1, + card_color: String::new(), + collapsed: false, + dragged: false, + }) + }; + let items = vec![ + header("a"), + DisplayItem::Back, + header("b"), + DisplayItem::Back, + ]; + assert_eq!(section_items(&items, "a"), vec![DisplayItem::Back]); + assert_eq!(section_items(&items, "c"), Vec::::new()); + } +} + +/// The workspace driven like a scene run, without a window: the app on the +/// mock, frames on the wall clock, pointer events by hand. +#[cfg(test)] +mod scene_tests { + use std::path::PathBuf; + use std::time::{Duration, Instant}; + + use egui::{pos2, vec2, Context, Event, Modifiers, PointerButton, Pos2, RawInput}; + + use crate::harness; + use crate::screens::Screen; + + type Box4 = (String, f32, f32, f32, f32); + + pub(super) struct Run { + pub ctx: Context, + pub screen: Screen, + start: Instant, + events: Vec, + scale: f32, + } + + impl Run { + pub fn new(scene: &str, scale: f32) -> Run { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("parity/scenes") + .join(format!("{scene}.json")); + let scene = harness::load(&path).unwrap(); + let ctx = Context::default(); + let screen = Screen::for_scene(&ctx, &scene).unwrap(); + let mut run = Run { + ctx, + screen, + start: Instant::now(), + events: Vec::new(), + scale, + }; + run.frame(); + run + } + + pub fn input(&mut self) -> RawInput { + let mut input = RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(860.0, 440.0))), + time: Some(self.start.elapsed().as_secs_f64()), + events: std::mem::take(&mut self.events), + ..Default::default() + }; + input + .viewports + .entry(egui::ViewportId::ROOT) + .or_default() + .native_pixels_per_point = Some(self.scale); + input + } + + pub fn frame(&mut self) -> egui::FullOutput { + let input = self.input(); + let screen = &mut self.screen; + let mut out = self.ctx.run_ui(input, |ui| screen.ui(ui)); + out.textures_delta.clear(); + out + } + + /// Frames until the screen is ready three frames in a row. + pub fn settle(&mut self) { + let deadline = Instant::now() + Duration::from_secs(20); + let mut streak = 0; + while streak < 3 { + assert!(Instant::now() < deadline, "never settled"); + self.frame(); + streak = if self.screen.ready() { streak + 1 } else { 0 }; + std::thread::sleep(Duration::from_millis(4)); + } + } + + pub fn send(&mut self, event: Event) { + self.events.push(event); + } + + pub fn click(&mut self, at: Pos2) { + self.send(Event::PointerMoved(at)); + self.frame(); + for pressed in [true, false] { + self.send(Event::PointerButton { + pos: at, + button: PointerButton::Primary, + pressed, + modifiers: Modifiers::NONE, + }); + self.frame(); + std::thread::sleep(Duration::from_millis(40)); + } + self.send(Event::PointerMoved(pos2(250.0, 18.0))); + self.frame(); + self.settle(); + } + + pub fn workspace(&self) -> &super::Workspace { + match &self.screen { + Screen::App(app) => &app.booted().unwrap().workspace, + _ => panic!("not the app"), + } + } + + /// `(label, x, y, w, h)` of every box on screen, scrolled. + pub fn boxes(&self) -> Vec { + let ws = self.workspace(); + // `.content` at (0, 36), padding 10 16. + ws.layout + .iter() + .map(|(label, r)| { + ( + label.clone(), + r.left() + super::PAD_X, + r.top() + 36.0 + super::PAD_TOP - ws.scroll(), + r.width(), + r.height(), + ) + }) + .collect() + } + } + + /// Rows of 7 cards from `(45, first_y)`, 110 by 146 apart, all 100 by + /// 136: what the WebView2 DOM dump gives for these scenes. + fn cards(labels: &[&str], first_y: f32) -> Vec { + labels + .iter() + .enumerate() + .map(|(i, l)| { + ( + l.to_string(), + 45.0 + 110.0 * (i % 7) as f32, + first_y + 146.0 * (i / 7) as f32, + 100.0, + 136.0, + ) + }) + .collect() + } + + fn only_cards(boxes: Vec) -> Vec { + boxes.into_iter().filter(|b| b.4 == 136.0).collect() + } + + /// Card order and positions equal the reference's DOM rects + /// (`getBoundingClientRect` in the `workspace-*` reference scenes). + #[test] + fn golden_card_positions() { + let mut run = Run::new("workspace-glass-dark-100", 1.0); + run.settle(); + let root = [ + "Smurfs", + "main", + "bro\u{27}s account", + "alt", + "trading", + "faceit", + "ranked grind", + "chill", + "lan party", + "streams", + "esea", + "tournaments", + "family", + "practice", + ]; + assert_eq!(only_cards(run.boxes()), cards(&root, 84.0)); + run.click(pos2(95.0, 152.0)); + let smurfs = ["Back", "smurf 1", "smurf 2", "smurf 3", "smurf 4"]; + assert_eq!(only_cards(run.boxes()), cards(&smurfs, 84.0)); + run.click(pos2(95.0, 152.0)); + run.click(pos2(639.0, 60.0)); + run.send(Event::Text("r".into())); + run.frame(); + run.send(Event::Text("a".into())); + run.frame(); + run.settle(); + let search = ["trading", "ranked grind", "practice"]; + assert_eq!(only_cards(run.boxes()), cards(&search, 84.0)); + + let mut run = Run::new("workspace-sections-glass-dark-100", 1.0); + run.settle(); + let boxes = run.boxes(); + let headers: Vec = boxes.iter().filter(|b| b.4 != 136.0).cloned().collect(); + assert_eq!( + headers, + vec![ + ("Smurfs".to_string(), 45.0, 84.0, 789.0, 24.0), + ("Root".to_string(), 45.0, 274.0, 789.0, 23.0), + ] + ); + let all = only_cards(boxes); + assert_eq!( + all[..3], + cards(&["Ranked", "smurf 1", "smurf 2"], 116.0)[..] + ); + let root13 = &root[1..]; + assert_eq!(all[3..], cards(root13, 305.0)[..]); + // `scrollHeight` 567. + assert_eq!(run.workspace().content_height, 567.0); + + let mut run = Run::new("workspace-deep-glass-dark-100", 1.0); + run.settle(); + run.click(pos2(95.0, 152.0)); + run.click(pos2(205.0, 152.0)); + run.click(pos2(205.0, 152.0)); + assert_eq!(only_cards(run.boxes()), cards(&["Back", "smurf 4"], 84.0)); + + let mut run = Run::new("workspace-huge-100", 1.0); + run.settle(); + assert_eq!(run.workspace().content_height, 4288.0); + run.send(Event::PointerMoved(pos2(430.0, 260.0))); + run.send(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -1942.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + }); + run.frame(); + run.settle(); + assert_eq!(run.workspace().scroll(), 1942.0); + let boxes = only_cards(run.boxes()); + assert_eq!(boxes.len(), 200); + assert_eq!( + boxes[0], + ("account 1".to_string(), 45.0, -1858.0, 100.0, 136.0) + ); + assert_eq!(boxes[7].2, -1712.0); + } + + /// The huge scenario scrolled down and back with the CPU renderer: + /// median and p99 of the whole frame (ui, tessellation, raster) over 300 + /// frames. `cargo test --release --lib huge_scroll -- --ignored + /// --nocapture`. + #[cfg(feature = "software")] + #[test] + #[ignore] + fn huge_scroll_frame_time() { + use egui_software_backend::{BufferMutRef, ColorFieldOrder, EguiSoftwareRender}; + for scale in [1.0f32, 1.5] { + let mut run = Run::new("workspace-huge-100", scale); + run.settle(); + let (w, h) = ((860.0 * scale) as usize, (440.0 * scale) as usize); + let mut pixels = vec![[0u8; 4]; w * h]; + let mut render = EguiSoftwareRender::new(ColorFieldOrder::Bgra) + .with_glyph_coverage(crate::ui::text::coverage_tables()); + let mut times = Vec::new(); + let mut textures = egui::TexturesDelta::default(); + for i in 0..330 { + // 24 px a frame down, then back up. + let dy = if (i / 165) % 2 == 0 { 24.0 } else { -24.0 }; + run.send(Event::PointerMoved(pos2(430.0, 260.0))); + run.send(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -dy), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + }); + let started = Instant::now(); + let input = run.input(); + let screen = &mut run.screen; + let mut out = run.ctx.run_ui(input, |ui| screen.ui(ui)); + textures.append(std::mem::take(&mut out.textures_delta)); + let prims = run.ctx.tessellate(out.shapes, out.pixels_per_point); + let mut buffer = BufferMutRef::new(&mut pixels, w, h); + render.render(&mut buffer, &prims, &textures, out.pixels_per_point); + textures.clear(); + if i >= 30 { + times.push(started.elapsed().as_secs_f64() * 1000.0); + } + } + times.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let median = times[times.len() / 2]; + let p99 = times[(times.len() * 99).div_ceil(100) - 1]; + println!( + "huge scroll @{scale}: {} frames, median {median:.2} ms, p99 {p99:.2} ms, max {:.2} ms", + times.len(), + times[times.len() - 1] + ); + } + } +} diff --git a/crates/accshift-egui/src/ui/components/account_card.rs b/crates/accshift-egui/src/ui/components/account_card.rs new file mode 100644 index 0000000..473a24c --- /dev/null +++ b/crates/accshift-egui/src/ui/components/account_card.rs @@ -0,0 +1,375 @@ +//! `src/lib/shared/components/AccountCard.svelte`, outer box only. +//! +//! This is the stub the workspace grid lays out with: the card box, its +//! hover lift and shadow, the active rings, the custom colour, the avatar +//! box with its picture (or initials) and the name line are drawn with the +//! component's CSS values; the username line, notes, badges, last login, +//! warnings, the confirm overlay, the loaders, the marquee and the extension +//! panel are not. The full port replaces this file behind the same +//! signature. +//! +//! - `.card`: `--grid-card-width` by `--grid-card-min-height`, padding +//! `--grid-card-padding`, radius `--grid-card-radius`, `--bg-card` under +//! `--card-bg-image`; background, transform and shadow transition 180 ms +//! ease-out; `card-entrance` on mount. +//! - `:not(.active):hover`: `--bg-card-hover`, `translateY(-2px)`, `0 12px +//! 24px rgba(0,0,0,.18)`; `:active` `translateY(0) scale(.985)`. +//! - `.custom-color`: `color-mix(c 24%, --bg-card)`, hover `color-mix(c 32%, +//! --bg-card-hover)`. +//! - `.active`: rings `0 0 0 2px color-mix(--fg 62%, transparent), 0 0 0 4px +//! color-mix(--bg-solid 45%, transparent)` on `--bg-card-hover`; with a +//! custom colour `2px color-mix(--fg 72%)`, `5px color-mix(c 50%, +//! color-mix(--bg-solid 62%, transparent))`. +//! - `.avatar`: `--grid-card-avatar-size`, `margin: 0 auto 8px`, radius +//! `--avatar-radius`, `--bg-muted`; on card hover `--bg-elevated` and +//! `translateY(-1px)`, the picture `scale(1.04)`; `.avatar.active` outline +//! 2 px `color-mix(--fg 20%, transparent)`. +//! - `.name-inner`: 12 px 500 `--fg`, `line-height: 1.2`, centred. + +use egui::accesskit::Role; +use egui::{pos2, vec2, Color32, Id, Pos2, Rect, Sense, TextureId, Ui, WidgetInfo, WidgetType}; + +use accshift_app::actions::avatar_fallback::avatar_initials; +use accshift_app::display::AccountCardItem; + +use super::avatar_fade_in; +use super::folder_card::{entrance, map_rect}; +use crate::app::vars::Vars; +use crate::theme::Rgba; +use crate::ui::anim::{self, Timing, Transition}; +use crate::ui::paint::{self, BoxShadow, BoxStyle, Corners, Gradient, Transform}; +use crate::ui::snap; +use crate::ui::text::{self, LineHeight, TextAlign, TextStyle}; + +/// `transition: background 180ms ease-out, transform 180ms ease-out, +/// box-shadow 180ms ease-out`. +const T180: Transition = Transition::new(0.18, Timing::EASE_OUT); +/// `.avatar { transition: background 150ms }` (`ease`). +const T150: Transition = Transition::new(0.15, Timing::EASE); +/// `img { transition: transform 220ms ease-out }`. +const T220: Transition = Transition::new(0.22, Timing::EASE_OUT); +/// Where `.name-inner`'s line box starts under the name row's top, measured +/// in WebView2 (the `.name` line box around the inline-block); the full port +/// lays the name section out instead. +const NAME_INNER_TOP: f32 = 3.609; + +/// The decoded avatar, as the workspace's texture cache hands it over. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AvatarPicture { + pub texture: TextureId, + /// The texture's size in device pixels (for `object-fit: cover`). + pub size: [usize; 2], +} + +/// What the avatar box shows. +#[derive(Clone, Debug, PartialEq)] +pub enum AvatarContent { + /// `avatarUrl` set and decoded. + Picture(AvatarPicture), + /// `avatarUrl` set, still decoding: the `` is mounted at opacity 0. + Decoding, + /// No picture: the initials on `getAvatarGradientStyle(seed)` + /// (`!isLoadingAvatar`): its `background-color` and gradient. + Initials { + background: Rgba, + gradient: Option, + }, + /// `isLoadingAvatar`: the box alone (the loader is not ported). + Loading, +} + +/// `AccountCard.svelte`'s props, minus the callbacks. The display +/// pipeline's [`AccountCardItem`] carries every per-account prop; the rest +/// are the workspace's. +#[derive(Clone, Debug)] +pub struct AccountCardProps<'a> { + pub vars: &'a Vars, + /// Stable for one mount of the card. + pub id: Id, + pub item: &'a AccountCardItem, + /// `cardColor` resolved, `None` for `""`. + pub card_color: Option, + pub avatar: AvatarContent, + pub focus_visible: bool, +} + +/// What the card reports. +#[derive(Clone, Debug, PartialEq)] +pub enum CardEvent { + /// Click: `onActivate` (selection and keyboard focus). + Activate, + /// `onSwitch`: a click with `singleClickSwitch` (bulk edit). The double + /// click confirm flow belongs to the full port. + Switch, + /// Right click: `onContextMenu`. + ContextMenu(Pos2), +} + +/// Draws the card over `rect` (its layout box) and returns what the user did. +pub fn account_card(ui: &mut Ui, rect: Rect, props: &AccountCardProps) -> Vec { + let v = props.vars; + let item = props.item; + let ctx = ui.ctx().clone(); + let ppp = ctx.pixels_per_point(); + let rect = snap::rect(rect, ppp); + let id = props.id; + let disabled = item.pending_setup; + let response = ui.interact( + rect, + id, + if disabled { + Sense::hover() + } else { + Sense::click() + }, + ); + let response = response.on_hover_cursor(egui::CursorIcon::PointingHand); + response.widget_info(|| { + WidgetInfo::selected(WidgetType::Button, !disabled, item.active, &item.name) + }); + ctx.accesskit_node_builder(id, |node| node.set_role(Role::Button)); + + let hovered = response.hovered(); + let pressed = response.is_pointer_button_down_on(); + let active = item.active; + let dragged = item.dragged; + let lift = hovered && !active && !dragged; + let transparent = Rgba::TRANSPARENT; + let background = match (props.card_color, active, lift) { + (Some(c), false, true) => c.mix(0.32, v.bg_card_hover), + (Some(c), _, _) => c.mix(0.24, v.bg_card), + (None, true, _) | (None, false, true) => v.bg_card_hover, + (None, false, false) => v.bg_card, + }; + let (ty, scale) = if dragged { + (0.0, 0.95) + } else if pressed && !active { + (0.0, 0.985) + } else if lift { + (-2.0, 1.0) + } else { + (0.0, 1.0) + }; + let background = anim::transition(&ctx, id.with("bg"), background, &T180); + let ty = anim::transition(&ctx, id.with("ty"), ty, &T180); + let scale = anim::transition(&ctx, id.with("scale"), scale, &T180); + let avatar_bg = anim::transition( + &ctx, + id.with("avatar-bg"), + if lift { v.bg_elevated } else { v.bg_muted }, + &T150, + ); + let avatar_lift = anim::transition( + &ctx, + id.with("avatar-ty"), + if lift { -1.0f32 } else { 0.0 }, + &T180, + ); + let zoom = anim::transition( + &ctx, + id.with("img-scale"), + if lift { 1.04f32 } else { 1.0 }, + &T220, + ); + + let (enter_opacity, enter_scale) = entrance(ui, id, v); + let transform = if enter_opacity < 1.0 { + Transform::scale(enter_scale) + } else { + Transform { + translate: vec2(0.0, ty), + scale, + } + }; + let opacity = enter_opacity * if dragged { 0.4 } else { 1.0 }; + + // Shadows: the active rings, else the hover shadow. Ring lists and the + // drop shadow interpolate as lists, padded with transparent shadows. + let shadows = if active { + let (halo, band, band_w) = match props.card_color { + Some(c) => ( + v.fg.mix(0.72, transparent), + c.mix(0.5, v.bg_solid.mix(0.62, transparent)), + 5.0, + ), + None => ( + v.fg.mix(0.62, transparent), + v.bg_solid.mix(0.45, transparent), + 4.0, + ), + }; + vec![ring(2.0, halo), ring(band_w, band)] + } else if lift { + vec![BoxShadow { + offset: vec2(0.0, 12.0), + blur: 24.0, + spread: 0.0, + color: Rgba::new(0.0, 0.0, 0.0, 0.18), + inset: false, + }] + } else { + Vec::new() + }; + BoxStyle { + background, + images: v.card_bg_image.clone(), + radius: Corners::all(v.radius_md), + shadows, + opacity, + ..BoxStyle::default() + } + .paint_transformed(ui.painter(), rect, transform); + + let mut painter = ui.painter().clone(); + painter.multiply_opacity(opacity); + + // Avatar box. + let pad = v.grid_card_padding(); + let size = v.grid_card_avatar_size(); + let content_w = rect.width() - 2.0 * pad; + let avatar = Rect::from_min_size( + pos2( + rect.left() + pad + snap::centre(content_w, size, ppp), + rect.top() + pad, + ), + vec2(size, size), + ); + let lifted = avatar.translate(vec2(0.0, avatar_lift)); + let mapped = map_rect(rect, transform, lifted); + let avatar_transform = Transform { + translate: mapped.center() - avatar.center(), + scale: transform.scale, + }; + BoxStyle { + background: avatar_bg, + radius: Corners::all(v.avatar_radius), + ..BoxStyle::default() + } + .paint_transformed(&painter, avatar, avatar_transform); + let radius = Corners::all(v.avatar_radius * transform.scale); + match &props.avatar { + AvatarContent::Picture(picture) => { + let reveal = avatar_fade_in::avatar_opacity(&ctx, id, true, v); + if reveal > 0.0 { + let alpha = (reveal * 255.0).round() as u8; + paint::paint_image( + &painter, + picture.texture, + snap::rect(mapped, ppp), + cover_uv(picture.size, zoom), + radius, + Color32::from_white_alpha(alpha), + ); + } + } + AvatarContent::Decoding => { + avatar_fade_in::avatar_opacity(&ctx, id, false, v); + } + AvatarContent::Initials { + background, + gradient, + } => { + BoxStyle { + background: *background, + images: gradient.iter().cloned().collect(), + radius: Corners::all(v.avatar_radius), + ..BoxStyle::default() + } + .paint_transformed(&painter, avatar, avatar_transform); + let style = TextStyle::new(20.0, 600) + .line_height(LineHeight::Number(1.5)) + .nowrap(false) + .align(TextAlign::Center) + .color(v.fg.to_color32()); + let line = 30.0; + let text_rect = Rect::from_min_size( + pos2(mapped.left(), mapped.center().y - line / 2.0), + vec2(mapped.width(), line), + ); + text::paint(&painter, text_rect, &avatar_initials(&item.name), &style); + } + AvatarContent::Loading => {} + } + if active { + // `.avatar.active { outline: 2px solid color-mix(--fg 20%) }`. + BoxStyle { + radius: Corners::all(v.avatar_radius), + shadows: vec![ring( + snap::border_width(2.0, ppp), + v.fg.mix(0.2, transparent), + )], + ..BoxStyle::default() + } + .paint_transformed(&painter, avatar, avatar_transform); + } + + // Name. + let name_top = rect.top() + pad + size + 8.0 + NAME_INNER_TOP; + let name_box = Rect::from_min_size(pos2(rect.left() + pad, name_top), vec2(content_w, 14.4)); + let name_mapped = map_rect(rect, transform, name_box); + let name_rect = Rect::from_center_size(name_mapped.center(), name_box.size()); + let style = TextStyle::new(12.0, 500) + .line_height(LineHeight::Number(1.2)) + .nowrap(false) + .align(TextAlign::Center) + .color(v.fg.to_color32()); + let mut name_painter = painter.clone(); + name_painter.set_clip_rect(painter.clip_rect().intersect(Rect::from_min_max( + pos2(name_rect.left(), name_rect.top() - 4.0), + pos2(name_rect.right(), name_rect.bottom() + 6.0), + ))); + let mut nowrap = style.clone(); + nowrap.nowrap = true; + text::paint(&name_painter, name_rect, &item.name, &nowrap); + + if props.focus_visible && response.has_focus() { + super::focus::card_ring(ui, rect, v.radius_md, v); + } + let mut events = Vec::new(); + if !disabled && !dragged && response.clicked() { + events.push(CardEvent::Activate); + if item.single_click_switch { + events.push(CardEvent::Switch); + } + } + if response.secondary_clicked() { + let at = response.interact_pointer_pos().unwrap_or(rect.center()); + events.push(CardEvent::ContextMenu(at)); + } + events +} + +/// `0 0 0 color`. +fn ring(spread: f32, color: Rgba) -> BoxShadow { + BoxShadow { + offset: egui::Vec2::ZERO, + blur: 0.0, + spread, + color, + inset: false, + } +} + +/// `object-fit: cover` of a `size` picture in a square box, then `scale()` +/// about the centre (the box clips it). +fn cover_uv(size: [usize; 2], zoom: f32) -> Rect { + let (w, h) = (size[0].max(1) as f32, size[1].max(1) as f32); + let (uw, uh) = if w > h { (h / w, 1.0) } else { (1.0, w / h) }; + let (uw, uh) = (uw / zoom, uh / zoom); + Rect::from_center_size(pos2(0.5, 0.5), vec2(uw, uh)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cover_crops_the_long_side() { + let square = cover_uv([64, 64], 1.0); + assert_eq!(square, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0))); + let wide = cover_uv([200, 100], 1.0); + assert!((wide.width() - 0.5).abs() < 1e-6 && (wide.height() - 1.0).abs() < 1e-6); + let zoomed = cover_uv([64, 64], 2.0); + assert!((zoomed.width() - 0.5).abs() < 1e-6); + } +} diff --git a/crates/accshift-egui/src/ui/components/avatar_fade_in.rs b/crates/accshift-egui/src/ui/components/avatar_fade_in.rs new file mode 100644 index 0000000..4531849 --- /dev/null +++ b/crates/accshift-egui/src/ui/components/avatar_fade_in.rs @@ -0,0 +1,66 @@ +//! `src/lib/shared/avatarFadeIn.ts` with the avatar `` rule of +//! `AccountCard.svelte`: the picture starts at `opacity: 0` and is revealed +//! once its bytes are decoded (or failed), through `transition: opacity +//! var(--motion-avatar-fade) ease-out`. +//! +//! A picture already decoded when the card mounts (`node.complete`) is +//! revealed before the first style: no fade. One decoded later fades from 0. +//! That is exactly a CSS transition whose first value is the state at mount, +//! so each mount of a card needs its own id. + +use egui::{Context, Id}; + +use crate::app::vars::Vars; +use crate::ui::anim::{self, Timing, Transition}; + +/// The `` opacity this frame. `revealed`: the texture is uploaded (or +/// the load failed). +pub fn avatar_opacity(ctx: &Context, id: Id, revealed: bool, vars: &Vars) -> f32 { + anim::transition_bool( + ctx, + id.with("avatar-fade-in"), + revealed, + &Transition::new(vars.motion_avatar_fade, Timing::EASE_OUT), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn vars() -> Vars { + Vars::new(&accshift_theme::ResolvedTheme::new( + &accshift_theme::theme_application( + &accshift_theme::ThemeRegistry::new().theme(Some("dark")), + 100.0, + true, + ), + )) + } + + fn at(ctx: &Context, t: f64, f: impl Fn(&Context) -> f32) -> f32 { + let mut out = 0.0; + let input = egui::RawInput { + time: Some(t), + ..Default::default() + }; + let mut output = ctx.run_ui(input, |ui| out = f(ui.ctx())); + output.textures_delta.clear(); + out + } + + #[test] + fn cached_pictures_show_at_once_late_ones_fade() { + let v = vars(); + let ctx = Context::default(); + let id = Id::new("card"); + assert_eq!(at(&ctx, 0.0, |c| avatar_opacity(c, id, true, &v)), 1.0); + let late = Id::new("late"); + assert_eq!(at(&ctx, 0.0, |c| avatar_opacity(c, late, false, &v)), 0.0); + let mid = at(&ctx, 0.1, |c| avatar_opacity(c, late, true, &v)); + assert!(mid < 0.05, "starts the fade at the decode: {mid}"); + let half = at(&ctx, 0.21, |c| avatar_opacity(c, late, true, &v)); + assert!(half > 0.5 && half < 1.0, "{half}"); + assert_eq!(at(&ctx, 0.4, |c| avatar_opacity(c, late, true, &v)), 1.0); + } +} diff --git a/crates/accshift-egui/src/ui/components/back_card.rs b/crates/accshift-egui/src/ui/components/back_card.rs new file mode 100644 index 0000000..2a3fecd --- /dev/null +++ b/crates/accshift-egui/src/ui/components/back_card.rs @@ -0,0 +1,77 @@ +//! `src/lib/features/folders/BackCard.svelte`: the first card inside a +//! folder, back to the parent. +//! +//! Same `.card` box as the folder card ([`super::folder_card::frame`]), a +//! 26 px arrow (stroke 2) in the icon wrap and `common.back` under it in +//! `.name { font-size: 12px; color: var(--fg-muted) }`: a ` - - - {/snippet} - - - diff --git a/src/lib/theme/backdrop.ts b/src/lib/theme/backdrop.ts deleted file mode 100644 index 158e8c7..0000000 --- a/src/lib/theme/backdrop.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { invoke } from "@tauri-apps/api/core"; -import { getCurrentWindow, Effect } from "@tauri-apps/api/window"; - -let appliedKey: string | null = null; - -/** Acrylic tint per glass theme. Only honored on the SWCA acrylic path - * (Windows 10 / early 11); DWM system backdrops ignore the color, and macOS - * ignores it too. The Windows default tint is a mid gray that reads plastic: - * glass dark wants a heavy black so the backdrop reads black instead of gray. */ -const ACRYLIC_TINTS: Record = { - "glass-dark": [0, 0, 0, 150], -}; - -/** macOS vibrancy material per glass theme. setEffects applies the first - * effect the platform supports, so listing a Windows effect and a macOS one - * together covers both from a single call. */ -const MAC_EFFECTS: Record = { - "glass-dark": Effect.HudWindow, - "glass-light": Effect.Popover, - "liquid-glass": Effect.UnderWindowBackground, -}; - -/** - * Applies (or clears) the OS backdrop effect that blurs whatever sits behind - * the transparent window. Driven entirely by the theme: glass themes get the - * platform's blur material, everything else stays plain transparent. Linux - * has no cross-compositor blur protocol: the call no-ops there and - * applyThemeToDocument paints a near-solid window instead (osBackdrop: false). - * - * Windows materials: glass dark/light use Acrylic (the only DWM backdrop - * compatible with transparent(true); Mica/Tabbed render black on layered - * windows). Liquid glass uses NO Windows material at all: the window stays - * purely transparent and the crisp desktop shows through the CSS surfaces. - * DWM stops rendering system backdrops on unfocused windows, so a native - * WM_NCACTIVATE subclass (set_keep_backdrop_active) keeps them alive while an - * acrylic glass theme is active. - */ -export async function applyWindowBackdrop( - glass: boolean, - themeId: string, - liquid = themeId === "liquid-glass", -): Promise { - const materialId = liquid ? "liquid-glass" : themeId; - const key = glass ? `backdrop:${materialId}` : "off"; - if (key === appliedKey) return; - try { - const appWindow = getCurrentWindow(); - const acrylic = glass && !liquid; - if (glass) { - if (!acrylic) { - // apply_effects with no Windows effect in the list is a no-op there, - // so drop any acrylic left by a previous glass theme explicitly. - await appWindow.clearEffects(); - } - const tint = ACRYLIC_TINTS[materialId]; - await appWindow.setEffects({ - effects: [ - ...(acrylic ? [Effect.Acrylic] : []), - MAC_EFFECTS[materialId] ?? Effect.UnderWindowBackground, - ], - ...(tint ? { color: tint } : {}), - }); - } else { - await appWindow.clearEffects(); - } - void invoke("set_keep_backdrop_active", { enabled: acrylic }).catch(() => {}); - appliedKey = key; - } catch { - // Unsupported platform or webview: keep the plain transparent window. - } -} diff --git a/src/lib/theme/contrast.test.ts b/src/lib/theme/contrast.test.ts deleted file mode 100644 index 26a92e6..0000000 --- a/src/lib/theme/contrast.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { blend, contrastRatio, parseColor, relativeLuminance, roundRatio } from "./contrast"; - -describe("parseColor", () => { - it("reads every notation a theme token may hold", () => { - expect(parseColor("#fff")).toEqual({ r: 255, g: 255, b: 255 }); - expect(parseColor("#1c1c1f")).toEqual({ r: 28, g: 28, b: 31 }); - expect(parseColor("rgb(9, 9, 11)")).toEqual({ r: 9, g: 9, b: 11 }); - expect(parseColor("rgba(9 9 11 / 0.5)")).toEqual({ r: 9, g: 9, b: 11 }); - // The window fill is stored as a bare triplet so an alpha can be applied. - expect(parseColor("9 9 11")).toEqual({ r: 9, g: 9, b: 11 }); - expect(parseColor("white")).toEqual({ r: 255, g: 255, b: 255 }); - }); - - it("returns null rather than guessing", () => { - expect(parseColor("")).toBeNull(); - expect(parseColor("#12345")).toBeNull(); - expect(parseColor("rebeccapurple")).toBeNull(); - expect(parseColor("var(--fg)")).toBeNull(); - }); -}); - -describe("contrastRatio", () => { - it("matches the WCAG anchors", () => { - const white = { r: 255, g: 255, b: 255 }; - const black = { r: 0, g: 0, b: 0 }; - - expect(relativeLuminance(white)).toBeCloseTo(1, 5); - expect(relativeLuminance(black)).toBeCloseTo(0, 5); - expect(roundRatio(contrastRatio(white, black))).toBe(21); - expect(contrastRatio(white, white)).toBe(1); - // Symmetric: the check does not care which colour is the background. - expect(contrastRatio(black, white)).toBe(contrastRatio(white, black)); - }); - - it("scores a mid grey on white the way WebAIM does", () => { - expect(roundRatio(contrastRatio({ r: 119, g: 119, b: 119 }, { r: 255, g: 255, b: 255 }))).toBe( - 4.5, - ); - }); -}); - -describe("blend", () => { - it("composites a translucent surface over its backdrop", () => { - const white = { r: 255, g: 255, b: 255 }; - const dark = { r: 20, g: 20, b: 20 }; - - expect(blend(white, 0, dark)).toEqual(dark); - expect(blend(white, 1, dark)).toEqual(white); - expect(blend(white, 0.5, dark)).toEqual({ r: 137.5, g: 137.5, b: 137.5 }); - }); -}); diff --git a/src/lib/theme/contrast.ts b/src/lib/theme/contrast.ts deleted file mode 100644 index ea661c3..0000000 --- a/src/lib/theme/contrast.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * WCAG 2.1 contrast, used by the theme editor to tell a user that a colour - * they just picked makes text unreadable. Only the colour notations a theme - * token may hold are parsed (hex, rgb(), a few keywords); anything else - * returns null and the check is skipped rather than guessed. - */ - -export interface Rgb { - r: number; - g: number; - b: number; -} - -const KEYWORDS: Record = { - white: { r: 255, g: 255, b: 255 }, - black: { r: 0, g: 0, b: 0 }, -}; - -export function parseColor(value: string): Rgb | null { - const raw = value.trim(); - if (!raw) return null; - - if (raw.startsWith("#")) { - const hex = raw.slice(1); - const normalized = - hex.length === 3 - ? hex - .split("") - .map((char) => `${char}${char}`) - .join("") - : hex; - if (!/^[0-9a-fA-F]{6}$/.test(normalized)) return null; - return { - r: Number.parseInt(normalized.slice(0, 2), 16), - g: Number.parseInt(normalized.slice(2, 4), 16), - b: Number.parseInt(normalized.slice(4, 6), 16), - }; - } - - const fn = raw.match(/^rgba?\(([^)]+)\)$/); - if (fn) { - const parts = fn[1] - .split(/[\s,/]+/) - .filter(Boolean) - .map((part) => Number.parseFloat(part)); - if (parts.length < 3 || parts.slice(0, 3).some((part) => Number.isNaN(part))) return null; - return { r: parts[0], g: parts[1], b: parts[2] }; - } - - // "9 9 11": the triplet notation the window fill token uses. - const triplet = raw.split(/\s+/); - if (triplet.length === 3 && triplet.every((part) => /^\d{1,3}$/.test(part))) { - return { r: Number(triplet[0]), g: Number(triplet[1]), b: Number(triplet[2]) }; - } - - return KEYWORDS[raw.toLowerCase()] ?? null; -} - -/** Source colour painted at `alpha` over an opaque backdrop. */ -export function blend(source: Rgb, alpha: number, backdrop: Rgb): Rgb { - const a = Math.min(1, Math.max(0, alpha)); - return { - r: source.r * a + backdrop.r * (1 - a), - g: source.g * a + backdrop.g * (1 - a), - b: source.b * a + backdrop.b * (1 - a), - }; -} - -function channelLuminance(channel: number): number { - const c = Math.min(255, Math.max(0, channel)) / 255; - return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; -} - -export function relativeLuminance(color: Rgb): number { - return ( - 0.2126 * channelLuminance(color.r) + - 0.7152 * channelLuminance(color.g) + - 0.0722 * channelLuminance(color.b) - ); -} - -/** WCAG contrast ratio, 1 (identical) to 21 (black on white). */ -export function contrastRatio(a: Rgb, b: Rgb): number { - const la = relativeLuminance(a); - const lb = relativeLuminance(b); - const lighter = Math.max(la, lb); - const darker = Math.min(la, lb); - return (lighter + 0.05) / (darker + 0.05); -} - -/** Rounded to one decimal, which is how the editor prints it. */ -export function roundRatio(ratio: number): number { - return Math.round(ratio * 10) / 10; -} diff --git a/src/lib/theme/cssSafety.ts b/src/lib/theme/cssSafety.ts deleted file mode 100644 index f23fdba..0000000 --- a/src/lib/theme/cssSafety.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * The safety filter for a theme's custom CSS. - * - * Kept apart from the schema so the one piece that stands between a shared - * file and a network request can be read, and tested, on its own. - */ - -/** - * Constructs a theme's custom CSS may not contain. - * - * CSS cannot run code here, but it can still reach out: a `url()` or an - * `@import` turns applying a shared theme into a request that tells its author - * the app started, and ` = [ - { label: "@import", pattern: /@import\b/i }, - { label: "url()", pattern: /\burl\s*\(/i }, - // These take a bare string and fetch it just like url() does. - { label: "image-set()", pattern: /image-set\s*\(/i }, - { label: "image()", pattern: /\bimage\s*\(/i }, - { label: "src()", pattern: /\bsrc\s*\(/i }, - { label: "expression()", pattern: /\bexpression\s*\(/i }, - { label: "javascript:", pattern: /javascript\s*:/i }, - { label: "-moz-binding", pattern: /-moz-binding/i }, - { label: " { - if (hex !== undefined) { - const code = Number.parseInt(hex, 16); - if (code === 0 || code > 0x10ffff) return ""; - return String.fromCodePoint(code); - } - return char ?? ""; - }, - ); -} - -/** The construct that makes this CSS unusable, or null when it is clean. */ -export function unsafeCssConstruct(css: string): string | null { - const normalized = normalizeCssForSafety(css); - return FORBIDDEN_CSS.find((entry) => entry.pattern.test(normalized))?.label ?? null; -} diff --git a/src/lib/theme/motion.ts b/src/lib/theme/motion.ts deleted file mode 100644 index d238c4f..0000000 --- a/src/lib/theme/motion.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { AnimationsMode } from "$lib/features/settings/types"; - -const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"; - -/** - * Applies the animations preference as `data-motion="full" | "reduced"` on - * . Stylesheets target `html[data-motion="reduced"]` instead of the - * `prefers-reduced-motion` media query so the user can override the OS. - * Returns a cleanup that detaches the OS listener (attached in "system" mode). - */ -export function applyMotionPreference(mode: AnimationsMode, doc: Document = document): () => void { - const query = doc.defaultView?.matchMedia(REDUCED_MOTION_QUERY) ?? null; - const apply = () => { - const reduced = mode === "off" || (mode === "system" && (query?.matches ?? false)); - doc.documentElement.dataset.motion = reduced ? "reduced" : "full"; - }; - apply(); - if (mode === "system" && query) { - query.addEventListener("change", apply); - return () => query.removeEventListener("change", apply); - } - return () => {}; -} diff --git a/src/lib/theme/schema.test.ts b/src/lib/theme/schema.test.ts deleted file mode 100644 index 8879f19..0000000 --- a/src/lib/theme/schema.test.ts +++ /dev/null @@ -1,410 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - MAX_THEME_CSS_LENGTH, - duplicateThemeDocument, - parseThemeDocument, - resolveThemeTokens, - serializeThemeDocument, - validateThemeDocument, - type ThemeDocument, -} from "./schema"; -import { - THEME_CONTRACT_VERSION, - THEME_TOKEN_KEYS, - THEME_TOKEN_SPECS, - isEssentialToken, - type ThemeTokens, -} from "./tokens"; - -/** Roots whose every value names itself, so a test can see where one came from. */ -function labelledRoot(scheme: string): ThemeTokens { - return Object.fromEntries( - THEME_TOKEN_KEYS.map((key) => [key, `${scheme}-${key}`]), - ) as unknown as ThemeTokens; -} - -const ROOTS: Record<"dark" | "light", ThemeTokens> = { - dark: labelledRoot("dark"), - light: labelledRoot("light"), -}; - -function doc(overrides: Partial = {}): ThemeDocument { - return { - schemaVersion: THEME_CONTRACT_VERSION, - id: "test", - name: "Test", - colorScheme: "dark", - tokens: {}, - ...overrides, - }; -} - -/** Tokens a theme may leave out without being called incomplete. */ -const STRUCTURE_KEYS: readonly string[] = THEME_TOKEN_SPECS.filter( - (spec) => !isEssentialToken(spec.key), -).map((spec) => spec.key); - -function registry(...documents: ThemeDocument[]) { - const byId = new Map(documents.map((document) => [document.id, document])); - return (id: string) => byId.get(id); -} - -describe("parseThemeDocument", () => { - it("refuses what is not a theme file", () => { - expect(parseThemeDocument("{nope").error).toBe("invalidJson"); - expect(parseThemeDocument("[]").error).toBe("invalidShape"); - expect(parseThemeDocument({ name: "No id", tokens: {} }).error).toBe("invalidId"); - expect(parseThemeDocument({ id: "../etc", name: "Escape", tokens: {} }).error).toBe( - "invalidId", - ); - expect(parseThemeDocument({ id: "ok", tokens: {} }).error).toBe("invalidName"); - }); - - it("refuses a file written for a contract it cannot understand", () => { - const result = parseThemeDocument({ - schemaVersion: THEME_CONTRACT_VERSION + 1, - id: "future", - name: "Future", - colorScheme: "dark", - tokens: { bgCard: "#000000" }, - }); - - // Half applying it would produce a theme its author never wrote. - expect(result.error).toBe("unsupportedVersion"); - expect(result.document).toBeNull(); - expect(result.declaredVersion).toBe(THEME_CONTRACT_VERSION + 1); - }); - - it("migrates a version 1 file by pointing it at the built-in of its scheme", () => { - const result = parseThemeDocument({ - id: "legacy", - name: "Legacy", - colorScheme: "light", - tokens: { bgCard: "#ffffff", fg: "#000000" }, - }); - - expect(result.error).toBeNull(); - expect(result.migratedFrom).toBe(1); - expect(result.document?.extends).toBe("light"); - expect(result.document?.schemaVersion).toBe(THEME_CONTRACT_VERSION); - // The colours the user chose still win over everything they inherit. - expect(result.document?.tokens.bgCard).toBe("#ffffff"); - }); - - it("drops values it cannot use instead of refusing the file", () => { - const result = parseThemeDocument({ - schemaVersion: 2, - id: "sloppy", - name: "Sloppy", - colorScheme: "dark", - tokens: { - bgCard: "#101010", - bgMuted: "not-a-colour", - density: "roomy", - elevationLow: "url(https://example.com/x.png)", - somethingElse: "#ffffff", - }, - }); - - expect(result.document?.tokens).toEqual({ bgCard: "#101010" }); - expect(result.rejectedTokens.sort()).toEqual([ - "bgMuted", - "density", - "elevationLow", - "somethingElse", - ]); - }); - - it("takes the version 3 structure tokens and refuses what they must not carry", () => { - const result = parseThemeDocument({ - schemaVersion: 3, - id: "structured", - name: "Structured", - colorScheme: "light", - tokens: { - bgImage: "linear-gradient(180deg, #ffffff 0%, #c0c0c0 100%)", - borderStyle: "ridge", - avatarShape: "square", - letterSpacing: "-0.02em", - lineHeight: "1.2", - motionScale: "0", - // A gradient is the one token that could name a remote file. - cardBgImage: "linear-gradient(#fff, url(https://example.com/x.png))", - // Out of the bounds the spec declares, not merely an odd choice. - fontSmoothing: "subpixel", - focusRing: "ridge", - }, - }); - - expect(result.document?.tokens).toEqual({ - bgImage: "linear-gradient(180deg, #ffffff 0%, #c0c0c0 100%)", - borderStyle: "ridge", - avatarShape: "square", - letterSpacing: "-0.02em", - lineHeight: "1.2", - motionScale: "0", - }); - expect(result.rejectedTokens.sort()).toEqual(["cardBgImage", "focusRing", "fontSmoothing"]); - }); - - it("keeps custom CSS but drops what would let a theme reach out", () => { - const clean = parseThemeDocument({ - schemaVersion: 2, - id: "styled", - name: "Styled", - colorScheme: "dark", - tokens: {}, - css: ".account-card { letter-spacing: 0.02em; }", - }); - - expect(clean.document?.css).toContain("letter-spacing"); - expect(clean.rejectedCss).toBeNull(); - - for (const css of [ - '@import "https://example.com/x.css";', - ".card { background: url(https://example.com/x.png); }", - ".card { color: red; }", - ".card { background: \\75\\72\\6c(https://example.com/x.png); }", - ".card { background: u/**/rl(https://example.com/x.png); }", - "@\\69mport url(https://example.com/x.css);", - // Non-hex escapes resolve to the escaped character in the tokenizer. - ".card { background: u\\rl(https://example.com/x.png); }", - ".card { background: \\u\\r\\l(https://example.com/x.png); }", - "@imp\\ort 'https://example.com/x.css';", - "@\\IMP\\ORT 'https://example.com/x.css';", - ".card { background: U\\RL(https://example.com/x.png); }", - ".card { background: u/**/\\rl(https://example.com/x.png); }", - ".card { background: \\75 \\r\\6C(https://example.com/x.png); }", - // image-set() and friends fetch a string URL without any url( token. - '.card { background: image-set("https://example.com/x.png" 1x); }', - '.card { background: -webkit-image-set("https://example.com/x.png" 1x); }', - '.card { background: IMAGE-SET("https://example.com/x.png" 1x); }', - '.card { background: image-\\73 et("https://example.com/x.png" 1x); }', - '.card { background: ima\\ge-s/**/et("https://example.com/x.png" 1x); }', - '.card { background: image("https://example.com/x.png"); }', - '.card { background: src("https://example.com/x.png"); }', - ]) { - const result = parseThemeDocument({ - schemaVersion: 2, - id: "hostile", - name: "Hostile", - colorScheme: "dark", - tokens: { accent: "#123456" }, - css, - }); - - // The theme still applies: only the CSS is dropped, and the caller is - // told which construct did it. - expect(result.document?.css, css).toBeUndefined(); - expect(result.document?.tokens.accent, css).toBe("#123456"); - expect(result.rejectedCss, css).not.toBeNull(); - } - - // Escapes are ordinary CSS; only the constructs above make CSS unsafe. - const escaped = parseThemeDocument({ - schemaVersion: 2, - id: "escaped", - name: "Escaped", - colorScheme: "dark", - tokens: {}, - css: '.w-1\\/2::before { content: "\\201C"; background-image: linear-gradient(red, blue); }', - }); - expect(escaped.rejectedCss).toBeNull(); - }); - - it("round trips through the exported file, byte for byte", () => { - const source = doc({ - id: "nord", - name: "Nord", - author: "someone", - version: "1.2.0", - extends: "dark", - glass: true, - tokens: { accent: "#88c0d0", radiusMd: "10px" }, - css: ".account-card { letter-spacing: 0.02em; }", - }); - - const json = serializeThemeDocument(source); - const parsed = parseThemeDocument(json).document; - - expect(parsed).toEqual(source); - expect(serializeThemeDocument(parsed as ThemeDocument)).toBe(json); - }); -}); - -describe("resolveThemeTokens", () => { - it("lets the nearest document win and fills the rest from the root", () => { - const parent = doc({ id: "parent", tokens: { accent: "#111111", fg: "#eeeeee" } }); - const child = doc({ id: "child", extends: "parent", tokens: { accent: "#222222" } }); - - const resolved = resolveThemeTokens(child, registry(parent, child), ROOTS); - - expect(resolved.tokens.accent).toBe("#222222"); - expect(resolved.tokens.fg).toBe("#eeeeee"); - expect(resolved.tokens.bgCard).toBe("dark-bgCard"); - expect(resolved.chain).toEqual(["child", "parent"]); - expect(resolved.brokenExtends).toBeNull(); - }); - - it("still produces a complete theme when the file is empty", () => { - const resolved = resolveThemeTokens(doc(), registry(), ROOTS); - - for (const key of THEME_TOKEN_KEYS) { - expect(resolved.tokens[key]).toBe(`dark-${key}`); - } - expect(resolved.filledFromRoot).toHaveLength(THEME_TOKEN_KEYS.length); - }); - - it("survives a base that does not exist", () => { - const orphan = doc({ id: "orphan", extends: "ghost", tokens: { fg: "#abcdef" } }); - - const resolved = resolveThemeTokens(orphan, registry(orphan), ROOTS); - - expect(resolved.brokenExtends).toBe("ghost"); - expect(resolved.tokens.fg).toBe("#abcdef"); - expect(resolved.tokens.bgCard).toBe("dark-bgCard"); - }); - - it("survives a cycle rather than spinning on it", () => { - const a = doc({ id: "a", extends: "b", tokens: { fg: "#a0a0a0" } }); - const b = doc({ id: "b", extends: "a", tokens: { bgCard: "#0b0b0b" } }); - - const resolved = resolveThemeTokens(a, registry(a, b), ROOTS); - - expect(resolved.brokenExtends).toBe("a"); - expect(resolved.chain).toEqual(["a", "b"]); - expect(resolved.tokens.fg).toBe("#a0a0a0"); - expect(resolved.tokens.bgCard).toBe("#0b0b0b"); - }); -}); - -describe("validateThemeDocument", () => { - const readable = doc({ - tokens: { - bgRgb: "9 9 11", - bgCard: "#1c1c1f", - fg: "#fafafa", - fgMuted: "#a1a1aa", - fgSubtle: "#71717a", - afkText: "#ffffff", - accent: "#2563eb", - accentFg: "#ffffff", - success: "#22c55e", - warning: "#eab308", - danger: "#dc2626", - }, - }); - - function issuesFor(document: ThemeDocument) { - const resolved = resolveThemeTokens(document, registry(document), { - dark: { ...ROOTS.dark, ...readable.tokens } as ThemeTokens, - light: ROOTS.light, - }); - return validateThemeDocument(document, resolved); - } - - it("says nothing about a readable palette", () => { - expect(issuesFor(readable).filter((issue) => issue.code === "contrast")).toEqual([]); - }); - - it("reports text that cannot be read on its own background", () => { - const unreadable = doc({ tokens: { ...readable.tokens, fg: "#1d1d20" } }); - - const issue = issuesFor(unreadable).find( - (candidate) => - candidate.code === "contrast" && candidate.token === "fg" && candidate.against === "bgCard", - ); - - expect(issue?.level).toBe("error"); - expect(issue?.ratio).toBeLessThan(3); - }); - - it("measures a translucent card as it is actually painted", () => { - // White text on a white card is unreadable on paper, but a glass card at - // 13% over a dark window is not white at all. - const glass = doc({ - tokens: { ...readable.tokens, bgRgb: "22 24 30", bgCard: "#ffffff", fg: "#ffffff" }, - }); - const resolved = resolveThemeTokens(glass, registry(glass), ROOTS); - - const opaque = validateThemeDocument(glass, resolved).filter( - (issue) => issue.code === "contrast" && issue.token === "fg", - ); - const painted = validateThemeDocument(glass, resolved, { cardAlpha: 0.13 }).filter( - (issue) => issue.code === "contrast" && issue.token === "fg", - ); - - expect(opaque).not.toEqual([]); - expect(painted).toEqual([]); - }); - - it("flags a hole only when the theme inherits from nothing", () => { - const standalone = doc({ tokens: { fg: "#fafafa" } }); - const derived = doc({ extends: "dark", tokens: { fg: "#fafafa" } }); - - expect(issuesFor(standalone).some((issue) => issue.code === "missingToken")).toBe(true); - expect(issuesFor(derived).some((issue) => issue.code === "missingToken")).toBe(false); - }); - - it("does not call a theme incomplete for leaving the structure alone", () => { - // What every theme written before version 3 looks like: a full palette and - // not one word about border style, tracking or motion. - const palette = doc({ - tokens: Object.fromEntries( - THEME_TOKEN_KEYS.filter((key) => !STRUCTURE_KEYS.includes(key)).map((key) => [ - key, - `dark-${key}`, - ]), - ), - }); - - const missing = issuesFor(palette).filter((issue) => issue.code === "missingToken"); - expect(missing).toEqual([]); - }); - - it("blocks CSS that reaches outside the window, and CSS past the cap", () => { - const hostile = doc({ - tokens: readable.tokens, - css: "@import url(https://example.com/x.css);", - }); - const huge = doc({ tokens: readable.tokens, css: `/*${"x".repeat(MAX_THEME_CSS_LENGTH)}*/` }); - - const unsafe = issuesFor(hostile).find((issue) => issue.code === "unsafeCss"); - const tooLong = issuesFor(huge).find((issue) => issue.code === "cssTooLong"); - - expect(unsafe?.level).toBe("error"); - expect(unsafe?.construct).toBe("@import"); - expect(tooLong?.level).toBe("error"); - expect(tooLong?.limit).toBe(MAX_THEME_CSS_LENGTH); - }); - - it("reports a value the editor refuses to store", () => { - const broken = doc({ tokens: { ...readable.tokens, radiusMd: "12 pixels" } }); - - const issue = issuesFor(broken).find((candidate) => candidate.code === "invalidValue"); - - expect(issue?.level).toBe("error"); - expect(issue?.token).toBe("radiusMd"); - expect(issue?.expected).toBe("length"); - }); -}); - -describe("duplicateThemeDocument", () => { - it("extends the source instead of copying its tokens", () => { - const source = doc({ - id: "glass-dark", - glass: true, - tokens: { fg: "#f4f4f6" }, - css: ".account-card { letter-spacing: 0.02em; }", - }); - - const copy = duplicateThemeDocument(source, "my-glass", "My Glass"); - - expect(copy.extends).toBe("glass-dark"); - expect(copy.tokens).toEqual({}); - // CSS does not travel through `extends`, so a copy has to carry it. - expect(copy.css).toBe(source.css); - expect(copy.glass).toBe(true); - expect(copy.colorScheme).toBe(source.colorScheme); - }); -}); diff --git a/src/lib/theme/schema.ts b/src/lib/theme/schema.ts deleted file mode 100644 index a662c32..0000000 --- a/src/lib/theme/schema.ts +++ /dev/null @@ -1,453 +0,0 @@ -import { - THEME_CONTRACT_VERSION, - THEME_TOKEN_KEYS, - getTokenSpec, - isEssentialToken, - isThemeTokenKey, - isValidTokenValue, - type ThemeTokenKey, - type ThemeTokens, -} from "./tokens"; -import { blend, contrastRatio, parseColor, roundRatio } from "./contrast"; -import { unsafeCssConstruct } from "./cssSafety"; - -export { unsafeCssConstruct }; - -/** - * A theme file, exactly as it is written on disk and exchanged between users. - * Only `tokens` a theme actually cares about are listed: everything else comes - * from the theme named by `extends`, so overriding an accent is a four line - * file instead of a full palette. - */ -export interface ThemeDocument { - schemaVersion: number; - id: string; - name: string; - author?: string; - /** The theme author's own version string, free form. Not the contract version. */ - version?: string; - colorScheme: "dark" | "light"; - extends?: string; - glass?: boolean; - tokens: Partial>; - /** - * Raw CSS appended to the document when the theme is applied, for what the - * tokens cannot express. Never inherited through `extends`: a rule written - * against one theme's markup has no reason to follow a copy of it. - */ - css?: string; -} - -export type ThemeParseErrorCode = - | "invalidJson" - | "invalidShape" - | "invalidId" - | "invalidName" - | "unsupportedVersion"; - -export interface ThemeParseResult { - document: ThemeDocument | null; - error: ThemeParseErrorCode | null; - /** Contract version the file was written for, when it is older than ours. */ - migratedFrom: number | null; - /** Keys refused because they are unknown or malformed. */ - rejectedTokens: string[]; - /** Construct that got the custom CSS dropped, when the file carried one. */ - rejectedCss: string | null; - /** Version the file declares, kept for the "written for a newer accshift" message. */ - declaredVersion: number; -} - -export const THEME_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/; -const MAX_NAME_LENGTH = 64; -const MAX_META_LENGTH = 64; -/** Guard against a file that extends a chain built to spin the resolver. */ -const MAX_EXTENDS_DEPTH = 8; -export const MAX_THEME_CSS_LENGTH = 20000; - -/** True when this document is Liquid Glass or walks to it through extends. */ -export function documentIsLiquidGlass( - document: ThemeDocument, - getDocument: (id: string) => ThemeDocument | undefined, -): boolean { - let current: ThemeDocument | undefined = document; - const seen = new Set(); - for (let depth = 0; current && depth < MAX_EXTENDS_DEPTH; depth++) { - if (current.id === "liquid-glass") return true; - const parentId = current.extends; - if (!parentId || seen.has(parentId)) return false; - if (parentId === "liquid-glass") return true; - seen.add(parentId); - current = getDocument(parentId); - } - return false; -} - -function readString(value: unknown, maxLength: number): string { - return typeof value === "string" ? value.trim().slice(0, maxLength) : ""; -} - -/** The known, well-formed tokens of a file, and the keys refused. */ -function readTokens(rawTokens: unknown): { - tokens: Partial>; - rejectedTokens: string[]; -} { - const tokens: Partial> = {}; - const rejectedTokens: string[] = []; - if (!rawTokens || typeof rawTokens !== "object" || Array.isArray(rawTokens)) { - return { tokens, rejectedTokens }; - } - for (const [key, value] of Object.entries(rawTokens as Record)) { - if (!isThemeTokenKey(key) || !isValidTokenValue(key, value)) { - rejectedTokens.push(key); - continue; - } - tokens[key] = (value as string).trim(); - } - return { tokens, rejectedTokens }; -} - -/** - * Reads a theme file into a document, or says why it cannot. - * - * A file with no `schemaVersion` is a version 1 file, written when a theme was - * eleven colours and nothing else: it is migrated by pointing it at the - * built-in theme of its own colour scheme, so its eleven colours keep winning - * and the tokens added since are inherited instead of missing. A file that - * declares a version we do not know is refused whole, never half applied. - * - * Every later version migrates by doing nothing at all, which is the property - * the contract is built for: a token a file does not set is resolved from what - * it extends and finally from the built-in root, so a version 2 file gets the - * version 3 structure tokens the app already painted with before they had - * names. - */ -export function parseThemeDocument(input: unknown): ThemeParseResult { - const empty: ThemeParseResult = { - document: null, - error: null, - migratedFrom: null, - rejectedTokens: [], - rejectedCss: null, - declaredVersion: THEME_CONTRACT_VERSION, - }; - - let raw: unknown = input; - if (typeof input === "string") { - try { - raw = JSON.parse(input); - } catch { - return { ...empty, error: "invalidJson" }; - } - } - if (!raw || typeof raw !== "object" || Array.isArray(raw)) { - return { ...empty, error: "invalidShape" }; - } - - const record = raw as Record; - const declaredVersion = - typeof record.schemaVersion === "number" && Number.isFinite(record.schemaVersion) - ? Math.trunc(record.schemaVersion) - : 1; - if (declaredVersion > THEME_CONTRACT_VERSION) { - return { ...empty, error: "unsupportedVersion", declaredVersion }; - } - - const id = readString(record.id, 64); - if (!THEME_ID_RE.test(id)) return { ...empty, error: "invalidId", declaredVersion }; - - const name = readString(record.name, MAX_NAME_LENGTH); - if (!name) return { ...empty, error: "invalidName", declaredVersion }; - - const colorScheme = record.colorScheme === "light" ? "light" : "dark"; - const rawExtends = readString(record.extends, 64); - const extendsId = THEME_ID_RE.test(rawExtends) ? rawExtends : undefined; - - const { tokens, rejectedTokens } = readTokens(record.tokens); - - const author = readString(record.author, MAX_META_LENGTH); - const version = readString(record.version, MAX_META_LENGTH); - - const document: ThemeDocument = { - schemaVersion: THEME_CONTRACT_VERSION, - id, - name, - colorScheme, - // A version 1 file never named a base; pointing it at the built-in of its - // own scheme is what turns eleven colours into a complete theme. - extends: extendsId ?? (declaredVersion < 2 ? colorScheme : undefined), - tokens, - }; - if (author) document.author = author; - if (version) document.version = version; - if (typeof record.glass === "boolean") document.glass = record.glass; - - // Same treatment as a malformed token: the rest of the theme still applies, - // and the caller is told what was dropped rather than left with a file that - // silently does less than it says. - let rejectedCss: string | null = null; - const css = typeof record.css === "string" ? record.css.slice(0, MAX_THEME_CSS_LENGTH) : ""; - if (css.trim()) { - rejectedCss = unsafeCssConstruct(css); - if (!rejectedCss) document.css = css; - } - - return { - document, - error: null, - migratedFrom: declaredVersion < THEME_CONTRACT_VERSION ? declaredVersion : null, - rejectedTokens, - rejectedCss, - declaredVersion, - }; -} - -/** Stable key order so a re-export produces the same file, byte for byte. */ -export function serializeThemeDocument(document: ThemeDocument): string { - const tokens: Record = {}; - for (const key of THEME_TOKEN_KEYS) { - const value = document.tokens[key]; - if (value !== undefined) tokens[key] = value; - } - const ordered: Record = { - schemaVersion: document.schemaVersion, - id: document.id, - name: document.name, - }; - if (document.author) ordered.author = document.author; - if (document.version) ordered.version = document.version; - ordered.colorScheme = document.colorScheme; - if (document.extends) ordered.extends = document.extends; - if (document.glass) ordered.glass = document.glass; - ordered.tokens = tokens; - if (document.css?.trim()) ordered.css = document.css; - return JSON.stringify(ordered, null, 2); -} - -export interface ResolvedTokens { - tokens: ThemeTokens; - /** Documents walked, nearest first, the document itself included. */ - chain: string[]; - /** Tokens no document in the chain sets, filled from the built-in root. */ - filledFromRoot: ThemeTokenKey[]; - /** `extends` pointed at a theme that does not exist, or at a cycle. */ - brokenExtends: string | null; -} - -/** - * Flattens a document and everything it extends into a complete token set. - * - * The built-in root of the document's colour scheme is always the last word, - * which is what keeps a half-written, truncated or hostile file from taking - * the interface down with it: every token it fails to provide is simply the - * built-in one. - */ -export function resolveThemeTokens( - document: ThemeDocument, - getDocument: (id: string) => ThemeDocument | undefined, - rootTokens: Record<"dark" | "light", ThemeTokens>, -): ResolvedTokens { - const chain: string[] = [document.id]; - const layers: Array>> = [document.tokens]; - const seen = new Set([document.id]); - let brokenExtends: string | null = null; - - let current = document; - while (current.extends && chain.length < MAX_EXTENDS_DEPTH) { - const parentId = current.extends; - if (seen.has(parentId)) { - brokenExtends = parentId; - break; - } - const parent = getDocument(parentId); - if (!parent) { - brokenExtends = parentId; - break; - } - seen.add(parentId); - chain.push(parentId); - layers.push(parent.tokens); - current = parent; - } - - const root = rootTokens[document.colorScheme]; - const tokens = { ...root } as ThemeTokens; - const filledFromRoot: ThemeTokenKey[] = []; - for (const key of THEME_TOKEN_KEYS) { - const layer = layers.find((candidate) => candidate[key] !== undefined); - if (layer) tokens[key] = layer[key] as string; - else filledFromRoot.push(key); - } - - return { tokens, chain, filledFromRoot, brokenExtends }; -} - -export type ThemeIssueCode = - | "invalidValue" - | "unknownToken" - | "missingToken" - | "unknownBase" - | "unsafeCss" - | "cssTooLong" - | "contrast"; - -export interface ThemeIssue { - level: "error" | "warning"; - code: ThemeIssueCode; - /** Token the issue is about, so the editor can scroll to and flag its row. */ - token?: ThemeTokenKey | string; - against?: ThemeTokenKey; - ratio?: number; - target?: number; - expected?: string; - base?: string; - /** Forbidden construct found in the custom CSS. */ - construct?: string; - /** Character count of the custom CSS, against its cap. */ - length?: number; - limit?: number; -} - -interface ContrastCheck { - foreground: ThemeTokenKey; - background: ThemeTokenKey; - /** WCAG AA target for that pair. Below it the editor warns. */ - target: number; - /** Below this the pair is unreadable, not merely tight. */ - floor: number; -} - -const CONTRAST_CHECKS: readonly ContrastCheck[] = [ - { foreground: "fg", background: "bgRgb", target: 4.5, floor: 3 }, - { foreground: "fg", background: "bgCard", target: 4.5, floor: 3 }, - { foreground: "fgMuted", background: "bgCard", target: 4.5, floor: 3 }, - { foreground: "fgSubtle", background: "bgCard", target: 3, floor: 2 }, - { foreground: "afkText", background: "bgCard", target: 3, floor: 2 }, - { foreground: "danger", background: "bgCard", target: 3, floor: 2 }, - { foreground: "success", background: "bgCard", target: 3, floor: 2 }, - { foreground: "warning", background: "bgCard", target: 3, floor: 2 }, - { foreground: "accent", background: "bgCard", target: 3, floor: 2 }, - { foreground: "accentFg", background: "accent", target: 4.5, floor: 3 }, -]; - -export interface ContrastOptions { - /** - * Alpha the card surfaces are actually painted at. On a glass theme a card - * is a sheet of colour over the window fill, so checking the raw token would - * measure a surface nobody ever sees. - */ - cardAlpha?: number; -} - -/** - * Surfaces `cardAlpha` applies to. The window fill is the backdrop itself, and - * accent is a solid colour painted on top of everything: compositing either of - * them would measure a colour that is never on screen. - */ -const TRANSLUCENT_SURFACES = new Set([ - "bgCard", - "bgCardHover", - "bgMuted", - "bgElevated", -]); - -/** - * Everything wrong with a theme, in the order a user should fix it: refused - * values first, then what it inherits by accident, then unreadable text. - */ -export function validateThemeDocument( - document: ThemeDocument, - resolved: ResolvedTokens, - options: ContrastOptions = {}, -): ThemeIssue[] { - const issues: ThemeIssue[] = []; - - for (const [key, value] of Object.entries(document.tokens)) { - if (!isThemeTokenKey(key)) { - issues.push({ level: "warning", code: "unknownToken", token: key }); - continue; - } - if (!isValidTokenValue(key, value)) { - issues.push({ - level: "error", - code: "invalidValue", - token: key, - expected: getTokenSpec(key)?.kind, - }); - } - } - - if (resolved.brokenExtends) { - issues.push({ level: "warning", code: "unknownBase", base: resolved.brokenExtends }); - } - - const css = document.css ?? ""; - const construct = css.trim() ? unsafeCssConstruct(css) : null; - if (construct) { - issues.push({ level: "error", code: "unsafeCss", construct }); - } - if (css.length > MAX_THEME_CSS_LENGTH) { - issues.push({ - level: "error", - code: "cssTooLong", - length: css.length, - limit: MAX_THEME_CSS_LENGTH, - }); - } - - // Inheriting from the theme you named is the point; inheriting because a - // colour is nowhere in the chain is a hole the author should know about. A - // theme written before version 3 sets no letter spacing and no border - // style, and it is complete all the same. - if (!document.extends) { - for (const key of resolved.filledFromRoot) { - if (!isEssentialToken(key)) continue; - issues.push({ level: "warning", code: "missingToken", token: key }); - } - } - - const backdrop = parseColor(resolved.tokens.bgRgb); - const cardAlpha = options.cardAlpha ?? 1; - for (const check of CONTRAST_CHECKS) { - const foreground = parseColor(resolved.tokens[check.foreground]); - let background = parseColor(resolved.tokens[check.background]); - if (!foreground || !background) continue; - if (TRANSLUCENT_SURFACES.has(check.background) && backdrop && cardAlpha < 1) { - background = blend(background, cardAlpha, backdrop); - } - const ratio = contrastRatio(foreground, background); - if (ratio >= check.target) continue; - issues.push({ - level: ratio < check.floor ? "error" : "warning", - code: "contrast", - token: check.foreground, - against: check.background, - ratio: roundRatio(ratio), - target: check.target, - }); - } - - return issues; -} - -/** A copy of `source`, ready to be edited under a new identity. */ -export function duplicateThemeDocument( - source: ThemeDocument, - id: string, - name: string, -): ThemeDocument { - return { - schemaVersion: THEME_CONTRACT_VERSION, - id, - name, - colorScheme: source.colorScheme, - // Extending the source rather than copying its tokens: the duplicate then - // follows the built-in it came from when that one is retuned. - extends: source.id, - glass: source.glass, - tokens: {}, - // Copied rather than inherited: custom CSS does not travel through - // `extends`, so a duplicate that dropped it would not look like its source. - ...(source.css ? { css: source.css } : {}), - }; -} diff --git a/src/lib/theme/surfaceOpacities.test.ts b/src/lib/theme/surfaceOpacities.test.ts deleted file mode 100644 index 6bf52a3..0000000 --- a/src/lib/theme/surfaceOpacities.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { getThemeDefinition, resolveThemeSurfaceOpacities } from "./themes"; - -const IDS = ["dark", "light", "midnight", "glass-dark", "glass-light", "liquid-glass"]; -const PERCENTS = [0, 25, 42, 55, 80, 100]; - -/** - * Every built-in theme, six slider positions, backdrop present and absent. - * - * These are the exact numbers the app painted with, captured before the four - * surface rules became a table. A cap, a bump or a floor edited by accident - * shows up here as a changed line instead of as a slightly wrong shade nobody - * notices. Regenerate from a run, never by hand. - */ -const GOLDEN = [ - 'dark 0 true {"windowOpacity":0,"cardOpacity":0.66,"hoverOpacity":0.72,"mutedOpacity":0.72,"elevatedOpacity":0.78,"overlayOpacity":0.86,"isLiquid":false}', - 'dark 0 false {"windowOpacity":0,"cardOpacity":0.66,"hoverOpacity":0.72,"mutedOpacity":0.72,"elevatedOpacity":0.78,"overlayOpacity":0.86,"isLiquid":false}', - 'dark 25 true {"windowOpacity":0.25,"cardOpacity":0.66,"hoverOpacity":0.72,"mutedOpacity":0.72,"elevatedOpacity":0.78,"overlayOpacity":0.86,"isLiquid":false}', - 'dark 25 false {"windowOpacity":0.25,"cardOpacity":0.66,"hoverOpacity":0.72,"mutedOpacity":0.72,"elevatedOpacity":0.78,"overlayOpacity":0.86,"isLiquid":false}', - 'dark 42 true {"windowOpacity":0.42,"cardOpacity":0.66,"hoverOpacity":0.72,"mutedOpacity":0.72,"elevatedOpacity":0.78,"overlayOpacity":0.86,"isLiquid":false}', - 'dark 42 false {"windowOpacity":0.42,"cardOpacity":0.66,"hoverOpacity":0.72,"mutedOpacity":0.72,"elevatedOpacity":0.78,"overlayOpacity":0.86,"isLiquid":false}', - 'dark 55 true {"windowOpacity":0.55,"cardOpacity":0.6900000000000001,"hoverOpacity":0.75,"mutedOpacity":0.73,"elevatedOpacity":0.78,"overlayOpacity":0.86,"isLiquid":false}', - 'dark 55 false {"windowOpacity":0.55,"cardOpacity":0.6900000000000001,"hoverOpacity":0.75,"mutedOpacity":0.73,"elevatedOpacity":0.78,"overlayOpacity":0.86,"isLiquid":false}', - 'dark 80 true {"windowOpacity":0.8,"cardOpacity":0.9400000000000001,"hoverOpacity":1,"mutedOpacity":0.98,"elevatedOpacity":1,"overlayOpacity":1,"isLiquid":false}', - 'dark 80 false {"windowOpacity":0.8,"cardOpacity":0.9400000000000001,"hoverOpacity":1,"mutedOpacity":0.98,"elevatedOpacity":1,"overlayOpacity":1,"isLiquid":false}', - 'dark 100 true {"windowOpacity":1,"cardOpacity":1,"hoverOpacity":1,"mutedOpacity":1,"elevatedOpacity":1,"overlayOpacity":1,"isLiquid":false}', - 'dark 100 false {"windowOpacity":1,"cardOpacity":1,"hoverOpacity":1,"mutedOpacity":1,"elevatedOpacity":1,"overlayOpacity":1,"isLiquid":false}', - 'light 0 true {"windowOpacity":0,"cardOpacity":0.66,"hoverOpacity":0.72,"mutedOpacity":0.72,"elevatedOpacity":0.78,"overlayOpacity":0.86,"isLiquid":false}', - 'light 0 false {"windowOpacity":0,"cardOpacity":0.66,"hoverOpacity":0.72,"mutedOpacity":0.72,"elevatedOpacity":0.78,"overlayOpacity":0.86,"isLiquid":false}', - 'light 25 true {"windowOpacity":0.25,"cardOpacity":0.66,"hoverOpacity":0.72,"mutedOpacity":0.72,"elevatedOpacity":0.78,"overlayOpacity":0.86,"isLiquid":false}', - 'light 25 false {"windowOpacity":0.25,"cardOpacity":0.66,"hoverOpacity":0.72,"mutedOpacity":0.72,"elevatedOpacity":0.78,"overlayOpacity":0.86,"isLiquid":false}', - 'light 42 true {"windowOpacity":0.42,"cardOpacity":0.66,"hoverOpacity":0.72,"mutedOpacity":0.72,"elevatedOpacity":0.78,"overlayOpacity":0.86,"isLiquid":false}', - 'light 42 false {"windowOpacity":0.42,"cardOpacity":0.66,"hoverOpacity":0.72,"mutedOpacity":0.72,"elevatedOpacity":0.78,"overlayOpacity":0.86,"isLiquid":false}', - 'light 55 true {"windowOpacity":0.55,"cardOpacity":0.6900000000000001,"hoverOpacity":0.75,"mutedOpacity":0.73,"elevatedOpacity":0.78,"overlayOpacity":0.86,"isLiquid":false}', - 'light 55 false {"windowOpacity":0.55,"cardOpacity":0.6900000000000001,"hoverOpacity":0.75,"mutedOpacity":0.73,"elevatedOpacity":0.78,"overlayOpacity":0.86,"isLiquid":false}', - 'light 80 true {"windowOpacity":0.8,"cardOpacity":0.9400000000000001,"hoverOpacity":1,"mutedOpacity":0.98,"elevatedOpacity":1,"overlayOpacity":1,"isLiquid":false}', - 'light 80 false {"windowOpacity":0.8,"cardOpacity":0.9400000000000001,"hoverOpacity":1,"mutedOpacity":0.98,"elevatedOpacity":1,"overlayOpacity":1,"isLiquid":false}', - 'light 100 true {"windowOpacity":1,"cardOpacity":1,"hoverOpacity":1,"mutedOpacity":1,"elevatedOpacity":1,"overlayOpacity":1,"isLiquid":false}', - 'light 100 false {"windowOpacity":1,"cardOpacity":1,"hoverOpacity":1,"mutedOpacity":1,"elevatedOpacity":1,"overlayOpacity":1,"isLiquid":false}', - 'midnight 0 true {"windowOpacity":0,"cardOpacity":0.66,"hoverOpacity":0.72,"mutedOpacity":0.72,"elevatedOpacity":0.78,"overlayOpacity":0.86,"isLiquid":false}', - 'midnight 0 false {"windowOpacity":0,"cardOpacity":0.66,"hoverOpacity":0.72,"mutedOpacity":0.72,"elevatedOpacity":0.78,"overlayOpacity":0.86,"isLiquid":false}', - 'midnight 25 true {"windowOpacity":0.25,"cardOpacity":0.66,"hoverOpacity":0.72,"mutedOpacity":0.72,"elevatedOpacity":0.78,"overlayOpacity":0.86,"isLiquid":false}', - 'midnight 25 false {"windowOpacity":0.25,"cardOpacity":0.66,"hoverOpacity":0.72,"mutedOpacity":0.72,"elevatedOpacity":0.78,"overlayOpacity":0.86,"isLiquid":false}', - 'midnight 42 true {"windowOpacity":0.42,"cardOpacity":0.66,"hoverOpacity":0.72,"mutedOpacity":0.72,"elevatedOpacity":0.78,"overlayOpacity":0.86,"isLiquid":false}', - 'midnight 42 false {"windowOpacity":0.42,"cardOpacity":0.66,"hoverOpacity":0.72,"mutedOpacity":0.72,"elevatedOpacity":0.78,"overlayOpacity":0.86,"isLiquid":false}', - 'midnight 55 true {"windowOpacity":0.55,"cardOpacity":0.6900000000000001,"hoverOpacity":0.75,"mutedOpacity":0.73,"elevatedOpacity":0.78,"overlayOpacity":0.86,"isLiquid":false}', - 'midnight 55 false {"windowOpacity":0.55,"cardOpacity":0.6900000000000001,"hoverOpacity":0.75,"mutedOpacity":0.73,"elevatedOpacity":0.78,"overlayOpacity":0.86,"isLiquid":false}', - 'midnight 80 true {"windowOpacity":0.8,"cardOpacity":0.9400000000000001,"hoverOpacity":1,"mutedOpacity":0.98,"elevatedOpacity":1,"overlayOpacity":1,"isLiquid":false}', - 'midnight 80 false {"windowOpacity":0.8,"cardOpacity":0.9400000000000001,"hoverOpacity":1,"mutedOpacity":0.98,"elevatedOpacity":1,"overlayOpacity":1,"isLiquid":false}', - 'midnight 100 true {"windowOpacity":1,"cardOpacity":1,"hoverOpacity":1,"mutedOpacity":1,"elevatedOpacity":1,"overlayOpacity":1,"isLiquid":false}', - 'midnight 100 false {"windowOpacity":1,"cardOpacity":1,"hoverOpacity":1,"mutedOpacity":1,"elevatedOpacity":1,"overlayOpacity":1,"isLiquid":false}', - 'glass-dark 0 true {"windowOpacity":0.55,"cardOpacity":0.65,"hoverOpacity":0.73,"mutedOpacity":0.6900000000000001,"elevatedOpacity":0.75,"overlayOpacity":0.86,"isLiquid":false}', - 'glass-dark 0 false {"windowOpacity":0.96,"cardOpacity":0.72,"hoverOpacity":0.7999999999999999,"mutedOpacity":0.78,"elevatedOpacity":0.85,"overlayOpacity":1,"isLiquid":false}', - 'glass-dark 25 true {"windowOpacity":0.55,"cardOpacity":0.65,"hoverOpacity":0.73,"mutedOpacity":0.6900000000000001,"elevatedOpacity":0.75,"overlayOpacity":0.86,"isLiquid":false}', - 'glass-dark 25 false {"windowOpacity":0.96,"cardOpacity":0.72,"hoverOpacity":0.7999999999999999,"mutedOpacity":0.78,"elevatedOpacity":0.85,"overlayOpacity":1,"isLiquid":false}', - 'glass-dark 42 true {"windowOpacity":0.55,"cardOpacity":0.65,"hoverOpacity":0.73,"mutedOpacity":0.6900000000000001,"elevatedOpacity":0.75,"overlayOpacity":0.86,"isLiquid":false}', - 'glass-dark 42 false {"windowOpacity":0.96,"cardOpacity":0.72,"hoverOpacity":0.7999999999999999,"mutedOpacity":0.78,"elevatedOpacity":0.85,"overlayOpacity":1,"isLiquid":false}', - 'glass-dark 55 true {"windowOpacity":0.55,"cardOpacity":0.65,"hoverOpacity":0.73,"mutedOpacity":0.6900000000000001,"elevatedOpacity":0.75,"overlayOpacity":0.86,"isLiquid":false}', - 'glass-dark 55 false {"windowOpacity":0.96,"cardOpacity":0.72,"hoverOpacity":0.7999999999999999,"mutedOpacity":0.78,"elevatedOpacity":0.85,"overlayOpacity":1,"isLiquid":false}', - 'glass-dark 80 true {"windowOpacity":0.55,"cardOpacity":0.65,"hoverOpacity":0.73,"mutedOpacity":0.6900000000000001,"elevatedOpacity":0.75,"overlayOpacity":0.86,"isLiquid":false}', - 'glass-dark 80 false {"windowOpacity":0.96,"cardOpacity":0.72,"hoverOpacity":0.7999999999999999,"mutedOpacity":0.78,"elevatedOpacity":0.85,"overlayOpacity":1,"isLiquid":false}', - 'glass-dark 100 true {"windowOpacity":0.55,"cardOpacity":0.65,"hoverOpacity":0.73,"mutedOpacity":0.6900000000000001,"elevatedOpacity":0.75,"overlayOpacity":0.86,"isLiquid":false}', - 'glass-dark 100 false {"windowOpacity":0.96,"cardOpacity":0.72,"hoverOpacity":0.7999999999999999,"mutedOpacity":0.78,"elevatedOpacity":0.85,"overlayOpacity":1,"isLiquid":false}', - 'glass-light 0 true {"windowOpacity":0.55,"cardOpacity":0.65,"hoverOpacity":0.73,"mutedOpacity":0.6900000000000001,"elevatedOpacity":0.75,"overlayOpacity":0.86,"isLiquid":false}', - 'glass-light 0 false {"windowOpacity":0.96,"cardOpacity":0.72,"hoverOpacity":0.7999999999999999,"mutedOpacity":0.78,"elevatedOpacity":0.85,"overlayOpacity":1,"isLiquid":false}', - 'glass-light 25 true {"windowOpacity":0.55,"cardOpacity":0.65,"hoverOpacity":0.73,"mutedOpacity":0.6900000000000001,"elevatedOpacity":0.75,"overlayOpacity":0.86,"isLiquid":false}', - 'glass-light 25 false {"windowOpacity":0.96,"cardOpacity":0.72,"hoverOpacity":0.7999999999999999,"mutedOpacity":0.78,"elevatedOpacity":0.85,"overlayOpacity":1,"isLiquid":false}', - 'glass-light 42 true {"windowOpacity":0.55,"cardOpacity":0.65,"hoverOpacity":0.73,"mutedOpacity":0.6900000000000001,"elevatedOpacity":0.75,"overlayOpacity":0.86,"isLiquid":false}', - 'glass-light 42 false {"windowOpacity":0.96,"cardOpacity":0.72,"hoverOpacity":0.7999999999999999,"mutedOpacity":0.78,"elevatedOpacity":0.85,"overlayOpacity":1,"isLiquid":false}', - 'glass-light 55 true {"windowOpacity":0.55,"cardOpacity":0.65,"hoverOpacity":0.73,"mutedOpacity":0.6900000000000001,"elevatedOpacity":0.75,"overlayOpacity":0.86,"isLiquid":false}', - 'glass-light 55 false {"windowOpacity":0.96,"cardOpacity":0.72,"hoverOpacity":0.7999999999999999,"mutedOpacity":0.78,"elevatedOpacity":0.85,"overlayOpacity":1,"isLiquid":false}', - 'glass-light 80 true {"windowOpacity":0.55,"cardOpacity":0.65,"hoverOpacity":0.73,"mutedOpacity":0.6900000000000001,"elevatedOpacity":0.75,"overlayOpacity":0.86,"isLiquid":false}', - 'glass-light 80 false {"windowOpacity":0.96,"cardOpacity":0.72,"hoverOpacity":0.7999999999999999,"mutedOpacity":0.78,"elevatedOpacity":0.85,"overlayOpacity":1,"isLiquid":false}', - 'glass-light 100 true {"windowOpacity":0.55,"cardOpacity":0.65,"hoverOpacity":0.73,"mutedOpacity":0.6900000000000001,"elevatedOpacity":0.75,"overlayOpacity":0.86,"isLiquid":false}', - 'glass-light 100 false {"windowOpacity":0.96,"cardOpacity":0.72,"hoverOpacity":0.7999999999999999,"mutedOpacity":0.78,"elevatedOpacity":0.85,"overlayOpacity":1,"isLiquid":false}', - 'liquid-glass 0 true {"windowOpacity":0.18,"cardOpacity":0.13,"hoverOpacity":0.21,"mutedOpacity":0.16,"elevatedOpacity":0.34,"overlayOpacity":0.86,"isLiquid":true}', - 'liquid-glass 0 false {"windowOpacity":0.96,"cardOpacity":0.72,"hoverOpacity":0.7999999999999999,"mutedOpacity":0.78,"elevatedOpacity":0.85,"overlayOpacity":1,"isLiquid":false}', - 'liquid-glass 25 true {"windowOpacity":0.18,"cardOpacity":0.13,"hoverOpacity":0.21,"mutedOpacity":0.16,"elevatedOpacity":0.34,"overlayOpacity":0.86,"isLiquid":true}', - 'liquid-glass 25 false {"windowOpacity":0.96,"cardOpacity":0.72,"hoverOpacity":0.7999999999999999,"mutedOpacity":0.78,"elevatedOpacity":0.85,"overlayOpacity":1,"isLiquid":false}', - 'liquid-glass 42 true {"windowOpacity":0.18,"cardOpacity":0.13,"hoverOpacity":0.21,"mutedOpacity":0.16,"elevatedOpacity":0.34,"overlayOpacity":0.86,"isLiquid":true}', - 'liquid-glass 42 false {"windowOpacity":0.96,"cardOpacity":0.72,"hoverOpacity":0.7999999999999999,"mutedOpacity":0.78,"elevatedOpacity":0.85,"overlayOpacity":1,"isLiquid":false}', - 'liquid-glass 55 true {"windowOpacity":0.18,"cardOpacity":0.13,"hoverOpacity":0.21,"mutedOpacity":0.16,"elevatedOpacity":0.34,"overlayOpacity":0.86,"isLiquid":true}', - 'liquid-glass 55 false {"windowOpacity":0.96,"cardOpacity":0.72,"hoverOpacity":0.7999999999999999,"mutedOpacity":0.78,"elevatedOpacity":0.85,"overlayOpacity":1,"isLiquid":false}', - 'liquid-glass 80 true {"windowOpacity":0.18,"cardOpacity":0.13,"hoverOpacity":0.21,"mutedOpacity":0.16,"elevatedOpacity":0.34,"overlayOpacity":0.86,"isLiquid":true}', - 'liquid-glass 80 false {"windowOpacity":0.96,"cardOpacity":0.72,"hoverOpacity":0.7999999999999999,"mutedOpacity":0.78,"elevatedOpacity":0.85,"overlayOpacity":1,"isLiquid":false}', - 'liquid-glass 100 true {"windowOpacity":0.18,"cardOpacity":0.13,"hoverOpacity":0.21,"mutedOpacity":0.16,"elevatedOpacity":0.34,"overlayOpacity":0.86,"isLiquid":true}', - 'liquid-glass 100 false {"windowOpacity":0.96,"cardOpacity":0.72,"hoverOpacity":0.7999999999999999,"mutedOpacity":0.78,"elevatedOpacity":0.85,"overlayOpacity":1,"isLiquid":false}', -]; - -function measured(): string[] { - const rows: string[] = []; - for (const id of IDS) { - const theme = getThemeDefinition(id); - for (const percent of PERCENTS) { - for (const backdropAvailable of [true, false]) { - const values = resolveThemeSurfaceOpacities(theme, percent, { backdropAvailable }); - rows.push(`${id} ${percent} ${backdropAvailable} ${JSON.stringify(values)}`); - } - } - } - return rows; -} - -describe("surface opacity rules", () => { - it("paints every built-in theme exactly as before the table", () => { - expect(measured()).toEqual(GOLDEN); - }); - - it("clamps a slider value outside 0 to 100", () => { - const dark = getThemeDefinition("dark"); - expect(resolveThemeSurfaceOpacities(dark, -40).windowOpacity).toBe(0); - expect(resolveThemeSurfaceOpacities(dark, 400).windowOpacity).toBe(1); - }); -}); diff --git a/src/lib/theme/themeSave.test.ts b/src/lib/theme/themeSave.test.ts deleted file mode 100644 index c6f9baa..0000000 --- a/src/lib/theme/themeSave.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { persistThemeDraft } from "./themeSave"; - -describe("persistThemeDraft", () => { - it("returns saved when the document write resolves", async () => { - await expect(persistThemeDraft(async () => {})).resolves.toBe("saved"); - }); - - it("returns failed when the document write rejects, without throwing", async () => { - const error = vi.spyOn(console, "error").mockImplementation(() => {}); - await expect( - persistThemeDraft(async () => { - throw new Error("disk"); - }), - ).resolves.toBe("failed"); - error.mockRestore(); - }); -}); diff --git a/src/lib/theme/themeSave.ts b/src/lib/theme/themeSave.ts deleted file mode 100644 index bb9c166..0000000 --- a/src/lib/theme/themeSave.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** Writes a theme draft. A rejection keeps the open editor and its fields. */ -export async function persistThemeDraft(save: () => Promise): Promise<"saved" | "failed"> { - try { - await save(); - return "saved"; - } catch (error) { - console.error("theme save failed", error); - return "failed"; - } -} diff --git a/src/lib/theme/themes.test.ts b/src/lib/theme/themes.test.ts deleted file mode 100644 index ba0f122..0000000 --- a/src/lib/theme/themes.test.ts +++ /dev/null @@ -1,365 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -const invokeMock = vi.fn((..._args: unknown[]): Promise => Promise.resolve(undefined)); -vi.mock("@tauri-apps/api/core", () => ({ - invoke: (...args: unknown[]) => invokeMock(...args), -})); - -import { - BUILT_IN_THEMES, - ROOT_TOKENS, - applyCustomThemePayloads, - applyThemeToDocument, - getThemeDefinition, - resolveThemeSurfaceOpacities, - saveThemeDocument, - themeFromDocument, - themeUsesLiquidGlass, -} from "./themes"; -import { resolveThemeTokens, validateThemeDocument } from "./schema"; -import { THEME_TOKEN_KEYS, THEME_TOKEN_SPECS, isValidTokenValue } from "./tokens"; - -interface StubElement { - id: string; - textContent: string; - remove(): void; -} - -/** Enough of a Document for applyThemeToDocument, in a test with no DOM. */ -function stubDocument() { - const properties = new Map(); - const documentElement = { - dataset: {} as Record, - style: { - colorScheme: "", - setProperty(name: string, value: string) { - properties.set(name, value); - }, - }, - }; - const head: StubElement[] = []; - const doc = { - documentElement, - head: { - appendChild(element: StubElement) { - head.push(element); - return element; - }, - }, - createElement() { - const element: StubElement = { - id: "", - textContent: "", - remove() { - const index = head.indexOf(element); - if (index !== -1) head.splice(index, 1); - }, - }; - return element; - }, - getElementById(id: string) { - return head.find((element) => element.id === id) ?? null; - }, - }; - return { doc: doc as unknown as Document, properties, head }; -} - -describe("theme surface fallback", () => { - it("uses tuned translucent values when the Liquid Glass wallpaper is available", () => { - const values = resolveThemeSurfaceOpacities(getThemeDefinition("liquid-glass"), 50, { - backdropAvailable: true, - }); - - expect(values.isLiquid).toBe(true); - expect(values.windowOpacity).toBe(0.18); - expect(values.cardOpacity).toBe(0.13); - }); - - it("uses a readable near-solid fallback when wallpaper capture fails", () => { - const values = resolveThemeSurfaceOpacities(getThemeDefinition("liquid-glass"), 50, { - backdropAvailable: false, - }); - - expect(values.isLiquid).toBe(false); - expect(values.windowOpacity).toBe(0.96); - expect(values.cardOpacity).toBe(0.72); - expect(values.overlayOpacity).toBe(1); - }); - - it("does not change regular theme opacity when no backdrop exists", () => { - const values = resolveThemeSurfaceOpacities(getThemeDefinition("dark"), 42, { - backdropAvailable: false, - }); - - expect(values.windowOpacity).toBe(0.42); - }); - - it("treats a theme that extends Liquid Glass as liquid", () => { - const copy = themeFromDocument({ - schemaVersion: 3, - id: "my-liquid", - name: "My Liquid", - colorScheme: "dark", - extends: "liquid-glass", - glass: true, - tokens: {}, - }); - - expect(themeUsesLiquidGlass(copy)).toBe(true); - const values = resolveThemeSurfaceOpacities(copy, 50, { backdropAvailable: true }); - expect(values.isLiquid).toBe(true); - expect(values.windowOpacity).toBe(0.18); - expect(values.cardOpacity).toBe(0.13); - }); -}); - -describe("built-in themes", () => { - it("gives both roots a valid value for every token", () => { - // The roots are the last word of every resolution, so a hole here is a - // hole in every theme that does not fill it itself. - for (const scheme of ["dark", "light"] as const) { - for (const key of THEME_TOKEN_KEYS) { - const value = ROOT_TOKENS[scheme][key]; - expect(value, `${scheme}.${key}`).toBeTruthy(); - expect(isValidTokenValue(key, value), `${scheme}.${key} = ${value}`).toBe(true); - } - } - }); - - it("ships nothing a user could not read", () => { - for (const theme of BUILT_IN_THEMES) { - const resolved = resolveThemeTokens( - theme.document, - (id) => BUILT_IN_THEMES.find((candidate) => candidate.id === id)?.document, - ROOT_TOKENS, - ); - const { cardOpacity } = resolveThemeSurfaceOpacities(theme, 100); - const errors = validateThemeDocument(theme.document, resolved, { - cardAlpha: cardOpacity, - }).filter((issue) => issue.level === "error"); - - expect(errors, `${theme.id}: ${JSON.stringify(errors)}`).toEqual([]); - } - }); - - it("keeps the two JSON built-ins expressible in the public format", () => { - // midnight and glass-dark are loaded from theme files, not from code: if - // one of them stopped resolving, the format would no longer be enough to - // write the themes we ship with. - for (const id of ["midnight", "glass-dark"]) { - const theme = getThemeDefinition(id); - expect(theme.id).toBe(id); - expect(theme.document.extends).toBe("dark"); - // Inherited from dark, never written in the file. - expect(theme.tokens.accent).toBe(ROOT_TOKENS.dark.accent); - expect(theme.tokens.density).toBe(ROOT_TOKENS.dark.density); - // Its own, written in the file. - expect(theme.tokens.bgCard).toBe(theme.document.tokens.bgCard); - } - }); -}); - -describe("custom themes", () => { - it("keeps a version 1 theme working and hands it the tokens added since", () => { - applyCustomThemePayloads([ - { - id: "legacy", - name: "Legacy", - colorScheme: "dark", - tokens: { bgCard: "#101014", fg: "#f0f0f0" }, - }, - ]); - - const theme = getThemeDefinition("legacy"); - - expect(theme.isCustom).toBe(true); - expect(theme.tokens.bgCard).toBe("#101014"); - expect(theme.tokens.fg).toBe("#f0f0f0"); - expect(theme.tokens.accent).toBe(ROOT_TOKENS.dark.accent); - expect(theme.tokens.radiusMd).toBe(ROOT_TOKENS.dark.radiusMd); - }); - - it("degrades a broken theme to its base instead of breaking the interface", () => { - applyCustomThemePayloads([ - { - schemaVersion: 2, - id: "broken", - name: "Broken", - colorScheme: "dark", - extends: "does-not-exist", - tokens: { bgCard: "javascript:alert(1)" }, - }, - { id: "", name: "Nameless", colorScheme: "dark", tokens: {} }, - ]); - - const theme = getThemeDefinition("broken"); - - expect(theme.tokens.bgCard).toBe(ROOT_TOKENS.dark.bgCard); - for (const key of THEME_TOKEN_KEYS) { - expect(theme.tokens[key], key).toBeTruthy(); - } - // The file with no usable id never made it into the registry. - expect(getThemeDefinition("").id).toBe("dark"); - }); - - it("forgets themes that are no longer on disk", () => { - applyCustomThemePayloads([]); - - expect(getThemeDefinition("legacy").id).toBe("dark"); - }); - - it("writes CSS the filter refused back to the file when the editor saves", async () => { - // An older release accepted CSS this build refuses. Editing the theme - // must not erase it from the user's file. - const refused = '.card { background: image-set("a.png" 1x); }'; - applyCustomThemePayloads([ - { - schemaVersion: 3, - id: "older", - name: "Older", - colorScheme: "dark", - tokens: {}, - css: refused, - }, - ]); - const theme = getThemeDefinition("older"); - expect(theme.document.css).toBeUndefined(); - - const savedCss = async (options?: { keepRefusedCss?: boolean }, css?: string) => { - invokeMock.mockClear(); - await saveThemeDocument({ ...theme.document, name: "Renamed", css }, options); - const [command, args] = invokeMock.mock.calls[0] as [string, { theme: { css: unknown } }]; - expect(command).toBe("save_custom_theme"); - return args.theme.css; - }; - - expect(await savedCss({ keepRefusedCss: true })).toBe(refused); - // CSS typed in the editor replaces it. - expect(await savedCss({ keepRefusedCss: true }, ".card { opacity: 1; }")).toBe( - ".card { opacity: 1; }", - ); - // Once replaced, the refused CSS is gone for good. - expect(await savedCss({ keepRefusedCss: true })).toBeNull(); - - // An import replaces the file with what it brings. - applyCustomThemePayloads([ - { - schemaVersion: 3, - id: "older", - name: "Older", - colorScheme: "dark", - tokens: {}, - css: refused, - }, - ]); - expect(await savedCss()).toBeNull(); - }); -}); - -describe("applyThemeToDocument", () => { - it("writes every token to the custom property its spec declares", () => { - const { doc, properties } = stubDocument(); - - applyThemeToDocument(getThemeDefinition("dark"), 100, doc); - - for (const spec of THEME_TOKEN_SPECS) { - expect(properties.has(spec.cssVar), spec.cssVar).toBe(true); - } - expect(properties.get("--accent")).toBe(ROOT_TOKENS.dark.accent); - expect(properties.get("--radius-md")).toBe(ROOT_TOKENS.dark.radiusMd); - // Density is a name in the file and a multiplier in CSS. - expect(properties.get("--density-scale")).toBe("1"); - // The theme font leads the base stack rather than replacing it, so the - // Cyrillic and Han fallbacks stay behind it. - expect(properties.get("--font-ui")).toBe("Inter, var(--font-stack-base)"); - }); - - it("turns the structure tokens into what CSS can actually use", () => { - const { doc, properties } = stubDocument(); - const theme = themeFromDocument({ - schemaVersion: 3, - id: "bevelled", - name: "Bevelled", - colorScheme: "light", - tokens: { - avatarShape: "circle", - fontSmoothing: "none", - borderStyle: "ridge", - borderWidth: "2px", - motionScale: "0", - fontDisplay: "Tahoma", - cardBgImage: "linear-gradient(180deg, #ffffff, #c0c0c0)", - }, - }); - - applyThemeToDocument(theme, 100, doc); - - // A name in the file, a length in CSS. - expect(properties.get("--avatar-radius")).toBe("50%"); - // One name, two properties, and macOS needs the opposite keyword. - expect(properties.get("--font-smoothing")).toBe("none"); - expect(properties.get("--font-smoothing-mac")).toBe("auto"); - expect(properties.get("--border-style")).toBe("ridge"); - expect(properties.get("--border-width")).toBe("2px"); - expect(properties.get("--motion-scale")).toBe("0"); - // The display font falls back to the interface font, never to nothing. - expect(properties.get("--font-display")).toBe("Tahoma, var(--font-ui)"); - expect(properties.get("--card-bg-image")).toBe("linear-gradient(180deg, #ffffff, #c0c0c0)"); - }); - - it("keeps a gradient off the surfaces a glass theme reads the desktop through", () => { - const { doc, properties } = stubDocument(); - const theme = themeFromDocument({ - schemaVersion: 3, - id: "glassy", - name: "Glassy", - colorScheme: "dark", - glass: true, - tokens: { - bgImage: "linear-gradient(180deg, #ffffff, #000000)", - cardBgImage: "linear-gradient(180deg, #ffffff, #000000)", - }, - }); - - applyThemeToDocument(theme, 100, doc); - - expect(properties.get("--bg-image")).toBe("none"); - expect(properties.get("--card-bg-image")).toBe("none"); - }); - - it("paints a theme it has never seen without falling over", () => { - const { doc, properties } = stubDocument(); - const theme = themeFromDocument({ - schemaVersion: 2, - id: "sparse", - name: "Sparse", - colorScheme: "light", - tokens: { accent: "#ff0088" }, - }); - - applyThemeToDocument(theme, 100, doc); - - expect(properties.get("--accent")).toBe("#ff0088"); - expect(properties.get("--fg")).toBe(ROOT_TOKENS.light.fg); - }); - - it("carries the theme's own CSS and takes it away with the theme", () => { - const { doc, head } = stubDocument(); - const withCss = themeFromDocument({ - schemaVersion: 2, - id: "with-css", - name: "With CSS", - colorScheme: "dark", - tokens: {}, - css: ".account-card { letter-spacing: 0.02em; }", - }); - - applyThemeToDocument(withCss, 100, doc); - expect(head).toHaveLength(1); - expect(head[0].textContent).toContain("letter-spacing"); - - // Switching to a theme without CSS must not leave the previous rules on. - applyThemeToDocument(getThemeDefinition("dark"), 100, doc); - expect(head).toHaveLength(0); - }); -}); diff --git a/src/lib/theme/themes.ts b/src/lib/theme/themes.ts deleted file mode 100644 index 4e8d4e3..0000000 --- a/src/lib/theme/themes.ts +++ /dev/null @@ -1,760 +0,0 @@ -import { invoke } from "@tauri-apps/api/core"; -import type { MessageKey } from "$lib/i18n"; -import { - AVATAR_RADIUS, - DENSITY_SCALE, - FONT_SMOOTHING_MAC_VALUE, - FONT_SMOOTHING_VALUE, - THEME_CONTRACT_VERSION, - THEME_TOKEN_SPECS, - type ThemeTokens, -} from "./tokens"; -import { - documentIsLiquidGlass, - parseThemeDocument, - resolveThemeTokens, - serializeThemeDocument, - type ThemeDocument, - type ThemeParseResult, -} from "./schema"; -import { applyWindowBackdrop } from "./backdrop"; -import midnightDocument from "./builtin/midnight.json"; -import glassDarkDocument from "./builtin/glass-dark.json"; - -export type { ThemeTokens }; -export { THEME_CONTRACT_VERSION }; - -export interface AppThemeDefinition { - id: string; - labelKey: MessageKey; - colorScheme: "dark" | "light"; - tokens: ThemeTokens; - /** Glassmorphism theme: caps surface opacities low so the backdrop shows through. */ - glass?: boolean; - isCustom?: boolean; - /** Raw CSS the theme carries, already checked by the parser. */ - css?: string; - displayName?: string; - /** What the theme was resolved from: what export writes and the editor edits. */ - document: ThemeDocument; -} - -/** A theme file as it crosses the IPC boundary, identical to the on-disk shape. */ -export interface CustomThemePayload { - schemaVersion?: number; - id: string; - name: string; - author?: string | null; - version?: string | null; - colorScheme: string; - extends?: string | null; - glass?: boolean | null; - tokens: Record; - css?: string | null; -} - -function hexToRgbTriplet(color: string): string { - const hex = color.trim().replace(/^#/, ""); - const normalized = - hex.length === 3 - ? hex - .split("") - .map((char) => `${char}${char}`) - .join("") - : hex; - - if (!/^[0-9a-fA-F]{6}$/.test(normalized)) { - // Malformed token: fall back instead of throwing mid-render. - return "0 0 0"; - } - - const r = Number.parseInt(normalized.slice(0, 2), 16); - const g = Number.parseInt(normalized.slice(2, 4), 16); - const b = Number.parseInt(normalized.slice(4, 6), 16); - return `${r} ${g} ${b}`; -} - -/** - * The two roots. Every other theme, built in or written by a user, is these - * plus a handful of overrides, and any token a theme fails to provide is taken - * from the root of its colour scheme. They are therefore the only token sets - * that must be complete, which `themes.test.ts` enforces. - */ -/** - * Version 3 tokens, and the values the app had hardcoded before they existed. - * They describe structure rather than colour, so both roots carry the same - * ones: a light theme is not built differently from a dark one, it is lit - * differently. - */ -const SHARED_STRUCTURE_TOKENS = { - bgImage: "none", - cardBgImage: "none", - borderWidth: "1px", - borderStyle: "solid", - avatarShape: "rounded", - focusRing: "solid", - overlayBlur: "4px", - fontDisplay: "Inter", - lineHeight: "1.5", - letterSpacing: "0", - labelCase: "none", - fontSmoothing: "auto", - motionScale: "1", -} as const; - -const DARK_TOKENS: ThemeTokens = { - bgRgb: "9 9 11", - bgCard: "#1c1c1f", - bgCardHover: "#252528", - bgMuted: "#27272a", - bgElevated: "#3f3f46", - fg: "#fafafa", - fgMuted: "#a1a1aa", - fgSubtle: "#71717a", - border: "#27272a", - danger: "#dc2626", - afkText: "#ffffff", - accent: "#2563eb", - accentFg: "#ffffff", - success: "#22c55e", - warning: "#eab308", - radiusSm: "4px", - radiusMd: "8px", - radiusLg: "12px", - elevationLow: "0 2px 8px rgb(0 0 0 / 0.18)", - elevationMedium: "0 8px 24px rgb(0 0 0 / 0.32)", - elevationHigh: "0 18px 48px rgb(0 0 0 / 0.45)", - density: "cozy", - fontUi: "Inter", - ...SHARED_STRUCTURE_TOKENS, -}; - -const LIGHT_TOKENS: ThemeTokens = { - bgRgb: "241 241 243", - bgCard: "#d8d8de", - bgCardHover: "#cfcfd7", - bgMuted: "#c4c4ce", - bgElevated: "#aeaebc", - fg: "#0b0b0f", - fgMuted: "#2b2b36", - fgSubtle: "#4e4e5d", - border: "#b8b8c5", - danger: "#dc2626", - afkText: "#000000", - accent: "#2563eb", - accentFg: "#ffffff", - // Light surfaces need the darker end of the scale: the vivid greens and - // ambers that read well on a dark card fall under 2:1 on a pale one. - success: "#15803d", - warning: "#a16207", - radiusSm: "4px", - radiusMd: "8px", - radiusLg: "12px", - elevationLow: "0 2px 8px rgb(0 0 0 / 0.10)", - elevationMedium: "0 8px 24px rgb(0 0 0 / 0.16)", - elevationHigh: "0 18px 48px rgb(0 0 0 / 0.22)", - density: "cozy", - fontUi: "Inter", - ...SHARED_STRUCTURE_TOKENS, -}; - -export const ROOT_TOKENS: Record<"dark" | "light", ThemeTokens> = { - dark: DARK_TOKENS, - light: LIGHT_TOKENS, -}; - -export const DEFAULT_THEME_ID = "dark"; - -/** - * Built-in theme documents. `midnight` and `glass-dark` are loaded from JSON - * files written in the public theme format, the same one an exported theme - * uses: if the format could not express a shipped theme it would not be worth - * handing to users. - */ -const BUILT_IN_DOCUMENTS: ThemeDocument[] = [ - { - schemaVersion: THEME_CONTRACT_VERSION, - id: "dark", - name: "Dark", - colorScheme: "dark", - tokens: { ...DARK_TOKENS }, - }, - { - schemaVersion: THEME_CONTRACT_VERSION, - id: "light", - name: "Light", - colorScheme: "light", - tokens: { ...LIGHT_TOKENS }, - }, - midnightDocument as ThemeDocument, - glassDarkDocument as ThemeDocument, - { - schemaVersion: THEME_CONTRACT_VERSION, - id: "glass-light", - name: "Glass Light", - colorScheme: "light", - extends: "light", - glass: true, - tokens: { - bgRgb: "236 238 244", - bgCard: "#fbfbfd", - bgCardHover: "#f0f1f6", - bgMuted: "#e2e4ec", - bgElevated: "#c8ccd8", - fg: "#101018", - fgMuted: "#3a3a48", - fgSubtle: "#5e5e70", - border: "#c2c6d2", - danger: "#dc2626", - afkText: "#000000", - }, - }, - { - schemaVersion: THEME_CONTRACT_VERSION, - id: "liquid-glass", - name: "Liquid Glass", - colorScheme: "dark", - extends: "dark", - glass: true, - tokens: { - // Milky white surfaces at very low alpha (set in applyThemeToDocument): - // interior panels must read as frosted glass over the desktop, not as - // dark smoked acrylic. bgRgb stays dark as a faint contrast veil. - bgRgb: "22 24 30", - bgCard: "#ffffff", - bgCardHover: "#ffffff", - bgMuted: "#ffffff", - bgElevated: "#ffffff", - fg: "#ffffff", - fgMuted: "#dbdfe7", - fgSubtle: "#b0b6c2", - border: "#a9b1c0", - danger: "#ef4444", - afkText: "#f4f6f9", - }, - }, -]; - -const BUILT_IN_LABEL_KEYS: Record = { - dark: "theme.dark", - light: "theme.light", - midnight: "theme.midnight", - "glass-dark": "theme.glassDark", - "glass-light": "theme.glassLight", - "liquid-glass": "theme.liquidGlass", -}; - -const builtInDocuments = new Map(BUILT_IN_DOCUMENTS.map((document) => [document.id, document])); -const customDocuments = new Map(); -const resolvedCache = new Map(); - -export function getThemeDocument(id: string): ThemeDocument | undefined { - return customDocuments.get(id) ?? builtInDocuments.get(id); -} - -/** - * Turns a document into the theme the app paints with. Unknown bases, cycles - * and holes are absorbed by the resolver, so this never fails: the worst a - * broken file can do is look exactly like the built-in it should have extended. - */ -export function themeFromDocument(document: ThemeDocument): AppThemeDefinition { - const resolved = resolveThemeTokens(document, getThemeDocument, ROOT_TOKENS); - const isCustom = !builtInDocuments.has(document.id); - return { - id: document.id, - labelKey: isCustom ? ("theme.custom" as MessageKey) : BUILT_IN_LABEL_KEYS[document.id], - colorScheme: document.colorScheme, - tokens: resolved.tokens, - glass: document.glass ? true : undefined, - isCustom: isCustom || undefined, - css: document.css, - displayName: document.name, - document, - }; -} - -function resolveById(id: string): AppThemeDefinition | undefined { - const cached = resolvedCache.get(id); - if (cached) return cached; - const document = getThemeDocument(id); - if (!document) return undefined; - const theme = themeFromDocument(document); - resolvedCache.set(id, theme); - return theme; -} - -export function getThemeDefinition(themeId: string | null | undefined): AppThemeDefinition { - return (themeId ? resolveById(themeId) : undefined) ?? resolveById(DEFAULT_THEME_ID)!; -} - -export function getAllThemes(): AppThemeDefinition[] { - return [ - ...BUILT_IN_DOCUMENTS.map((document) => resolveById(document.id)!), - ...[...customDocuments.keys()] - .filter((id) => !builtInDocuments.has(id)) - .map((id) => resolveById(id)!), - ]; -} - -export const BUILT_IN_THEMES: AppThemeDefinition[] = BUILT_IN_DOCUMENTS.map((document) => - resolveById(document.id)!, -); - -/** Built-in themes are read only: editing one means editing a copy of it. */ -export function isBuiltInTheme(id: string): boolean { - return builtInDocuments.has(id); -} - -function invalidateResolved() { - resolvedCache.clear(); - // A same-id theme can come back with new tokens; drop the applied-theme memo - // so the next apply repaints instead of matching on a stale identity. - resetAppliedThemeMemo(); -} - -/** - * CSS a theme file carries but the safety filter refused, by theme id. The - * filter has grown since older releases, so a file written by one of them can - * hold CSS this build will not apply. An editor save of that theme writes it - * back untouched rather than erasing it from the user's file. - */ -const refusedCss = new Map(); - -export function applyCustomThemePayloads(payloads: CustomThemePayload[]): void { - invalidateResolved(); - customDocuments.clear(); - refusedCss.clear(); - for (const payload of payloads) { - // A file that fails to parse is skipped rather than surfaced: it was - // already on disk when the app started, and a modal at boot helps nobody. - // The editor and the import path report the same failures out loud. - const { document, rejectedCss } = parseThemeDocument(payload); - if (!document) continue; - if (builtInDocuments.has(document.id)) continue; - customDocuments.set(document.id, document); - if (rejectedCss && typeof payload.css === "string") refusedCss.set(document.id, payload.css); - } -} - -export async function loadCustomThemes(): Promise { - try { - const payloads = await invoke("list_custom_themes"); - applyCustomThemePayloads(payloads); - } catch { - // themes dir may not exist yet, that's fine - } -} - -/** - * `keepRefusedCss`: an edit of a theme already on disk, whose refused CSS - * stays in the file unless the edit brings CSS of its own. An import replaces - * the file with what it brings. - */ -export async function saveThemeDocument( - document: ThemeDocument, - { keepRefusedCss = false }: { keepRefusedCss?: boolean } = {}, -): Promise { - if (builtInDocuments.has(document.id)) { - throw new Error(`Cannot overwrite built-in theme: ${document.id}`); - } - const keptCss = keepRefusedCss && !document.css ? refusedCss.get(document.id) : undefined; - const payload: CustomThemePayload = { - schemaVersion: document.schemaVersion, - id: document.id, - name: document.name, - author: document.author ?? null, - version: document.version ?? null, - colorScheme: document.colorScheme, - extends: document.extends ?? null, - glass: document.glass ?? null, - tokens: document.tokens as Record, - css: document.css ?? keptCss ?? null, - }; - await invoke("save_custom_theme", { theme: payload }); - invalidateResolved(); - customDocuments.set(document.id, document); - if (!keptCss) refusedCss.delete(document.id); -} - -export async function deleteCustomTheme(themeId: string): Promise { - await invoke("delete_custom_theme", { themeId }); - invalidateResolved(); - customDocuments.delete(themeId); -} - -/** The shareable file: one self-contained JSON document, metadata included. */ -export function exportThemeJson(theme: AppThemeDefinition): string { - return serializeThemeDocument(theme.document); -} - -export function importThemeJson(json: string): ThemeParseResult { - return parseThemeDocument(json); -} - -/** An id derived from a name, unique against everything already registered. */ -export function suggestThemeId(name: string): string { - const base = - name - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 48) || "theme"; - let candidate = base; - let counter = 2; - while (getThemeDocument(candidate)) { - candidate = `${base}-${counter}`; - counter += 1; - } - return candidate; -} - -/** Fixed window fill per glass theme. The user slider only applies to regular - * themes: glass needs a tuned fill for the material to read right, exposing - * the slider there just produced broken combinations. */ -const GLASS_WINDOW_OPACITY: Record = { - "glass-dark": 0.55, - "glass-light": 0.55, - "liquid-glass": 0.18, -}; - -export interface ThemeSurfaceOpacities { - windowOpacity: number; - cardOpacity: number; - hoverOpacity: number; - mutedOpacity: number; - elevatedOpacity: number; - overlayOpacity: number; - isLiquid: boolean; -} - -/** True for Liquid Glass and for any theme that extends it. Customize - * duplicates the built-in under a new id, so the material must follow - * `extends`, not the shipped id. */ -export function themeUsesLiquidGlass(theme: AppThemeDefinition): boolean { - return documentIsLiquidGlass(theme.document, getThemeDocument); -} - -/** - * How solid one card surface sits above the window fill. - * - * The four of them answer the same question and differ only in numbers: a - * fixed alpha while the live backdrop does the work, otherwise the base plus a - * bump, held between a floor and a cap. Written once, a fifth surface is a row - * here rather than a fourth copy of the same three-way ternary. - */ -interface SurfaceRule { - /** Used as-is while the liquid backdrop is live. */ - liquid: number; - /** Cap, bump and floor on a glass theme with no live backdrop. */ - glass: readonly [cap: number, bump: number, floor: number]; - /** The same three on a regular theme. */ - plain: readonly [cap: number, bump: number, floor: number]; -} - -const SURFACE_RULES = { - card: { liquid: 0.13, glass: [0.72, 0.1, 0.4], plain: [1, 0.14, 0.66] }, - // Hover has no glass floor of its own: it is built on the card, which - // already floors at 0.4, so anything lower cannot reach it. - hover: { liquid: 0.21, glass: [0.8, 0.08, 0], plain: [1, 0.06, 0.72] }, - muted: { liquid: 0.16, glass: [0.78, 0.14, 0.46], plain: [1, 0.18, 0.72] }, - elevated: { liquid: 0.34, glass: [0.85, 0.2, 0.55], plain: [1, 0.22, 0.78] }, -} as const satisfies Record; - -function surfaceOpacity( - rule: SurfaceRule, - base: number, - isLiquid: boolean, - glass: boolean, -): number { - if (isLiquid) return rule.liquid; - const [cap, bump, floor] = glass ? rule.glass : rule.plain; - return Math.min(cap, Math.max(base + bump, floor)); -} - -/** - * How solid the window itself is. - * - * A regular theme follows the slider. A glass theme ignores it: the fill is - * what its blur was designed against, and with no backdrop to blur it falls - * back to near-solid so the text stays readable. - */ -function windowFill( - theme: AppThemeDefinition, - rawOpacity: number, - backdropAvailable: boolean, - liquid: boolean, -): number { - if (!theme.glass) return rawOpacity; - if (!backdropAvailable) return 0.96; - if (liquid) return GLASS_WINDOW_OPACITY["liquid-glass"]; - return GLASS_WINDOW_OPACITY[theme.id] ?? 0.55; -} - -export function resolveThemeSurfaceOpacities( - theme: AppThemeDefinition, - backgroundOpacityPercent: number, - opts: { backdropAvailable?: boolean } = {}, -): ThemeSurfaceOpacities { - const rawOpacity = Math.min(100, Math.max(0, backgroundOpacityPercent)) / 100; - // A failed wallpaper capture and Linux's missing compositor-independent blur - // both need the same readable, near-solid fallback. - const backdropAvailable = opts.backdropAvailable !== false; - const liquid = themeUsesLiquidGlass(theme); - const isLiquid = liquid && backdropAvailable; - const glass = theme.glass === true; - const windowOpacity = windowFill(theme, rawOpacity, backdropAvailable, liquid); - const cardOpacity = surfaceOpacity(SURFACE_RULES.card, windowOpacity, isLiquid, glass); - // Hover builds on the card rather than on the window: the two must stay a - // step apart whatever the slider does to the fill underneath them. - const hoverOpacity = surfaceOpacity(SURFACE_RULES.hover, cardOpacity, isLiquid, glass); - const mutedOpacity = surfaceOpacity(SURFACE_RULES.muted, windowOpacity, isLiquid, glass); - const elevatedOpacity = surfaceOpacity(SURFACE_RULES.elevated, windowOpacity, isLiquid, glass); - const overlayOpacity = Math.min(1, Math.max(windowOpacity + 0.3, 0.86)); - return { - windowOpacity, - cardOpacity, - hoverOpacity, - mutedOpacity, - elevatedOpacity, - overlayOpacity, - isLiquid, - }; -} - -const THEME_CSS_ELEMENT_ID = "accshift-theme-css"; - -/** - * Writes the theme's own CSS into the document. - * - * It goes last in the head, so a theme can reach a rule the tokens do not - * cover, and it is a single element that is rewritten or removed on every - * apply: switching themes must never leave the previous one's CSS behind. - * What may appear in it is decided by the parser, never here. - */ -function applyThemeCss(css: string | undefined, doc: Document): void { - const head = doc.head; - if (!head) return; - const existing = doc.getElementById(THEME_CSS_ELEMENT_ID); - if (!css?.trim()) { - existing?.remove(); - return; - } - const element = existing ?? doc.createElement("style"); - if (!existing) { - element.id = THEME_CSS_ELEMENT_ID; - head.appendChild(element); - } - if (element.textContent !== css) element.textContent = css; -} - -/** Inputs of the last application to the real document. The caller is an - * effect that also tracks locale, card outlines and the wallpaper snapshot, - * so it re-runs far more often than the theme actually changes; every such - * re-run rewrote ~30 root custom properties to the same values. Object - * identity is enough: theme definitions and token objects are replaced, never - * mutated in place (edits go through applyCustomThemePayloads / saveThemeDocument, - * which reset this). */ -interface AppliedTheme { - theme: AppThemeDefinition; - tokens: AppThemeDefinition["tokens"]; - opacity: number; - backdropAvailable: boolean | undefined; -} - -let lastApplied: AppliedTheme | null = null; - -export function resetAppliedThemeMemo() { - lastApplied = null; -} - -export function applyThemeToDocument( - theme: AppThemeDefinition, - backgroundOpacityPercent: number, - doc: Document = document, - opts: { backdropAvailable?: boolean; preview?: boolean } = {}, -) { - // Only memoize the real document; an explicitly passed doc (theme preview, - // tests) always applies. `globalThis.document` rather than the bare global so - // this stays callable where there is no DOM at all. - const memoizable = doc === globalThis.document; - // The App effect re-applies the saved theme whenever wallpaper, locale or - // outlines tick. While the editor owns the document, those writes would - // paint over the draft until the next keystroke. - if (memoizable && previewSnapshot && !opts.preview) { - return; - } - if ( - memoizable && - lastApplied && - lastApplied.theme === theme && - lastApplied.tokens === theme.tokens && - lastApplied.opacity === backgroundOpacityPercent && - lastApplied.backdropAvailable === opts.backdropAvailable - ) { - return; - } - // Glass themes use a fixed window fill (see GLASS_WINDOW_OPACITY); the - // slider only drives regular themes. Liquid Glass runs its own scale: - // white surfaces at very low alpha so the blurred desktop dominates - // (milky glass) instead of the smoked-acrylic stack. - const { - windowOpacity, - cardOpacity, - hoverOpacity, - mutedOpacity, - elevatedOpacity, - overlayOpacity, - isLiquid, - } = resolveThemeSurfaceOpacities(theme, backgroundOpacityPercent, opts); - const root = doc.documentElement; - const bgCardRgb = hexToRgbTriplet(theme.tokens.bgCard); - const bgCardHoverRgb = hexToRgbTriplet(theme.tokens.bgCardHover); - const bgMutedRgb = hexToRgbTriplet(theme.tokens.bgMuted); - const bgElevatedRgb = hexToRgbTriplet(theme.tokens.bgElevated); - - root.dataset.theme = theme.id; - root.dataset.glass = theme.glass ? "1" : "0"; - root.style.colorScheme = theme.colorScheme; - root.style.setProperty("--bg-rgb", theme.tokens.bgRgb); - root.style.setProperty("--bg-opacity", String(windowOpacity)); - root.style.setProperty("--bg-solid", `rgb(${theme.tokens.bgRgb})`); - // Form controls (text inputs, closed selects). Opaque on regular themes; - // on glass a themed translucent fill (tracks bgRgb, so it follows the - // colorScheme and keeps the typed text readable) instead of the opaque - // slab that read as a hard black box on the glass surfaces. - root.style.setProperty( - "--bg-input", - theme.glass ? `rgb(${theme.tokens.bgRgb} / 0.55)` : `rgb(${theme.tokens.bgRgb})`, - ); - root.style.setProperty("--bg-card", `rgb(${bgCardRgb} / ${cardOpacity})`); - root.style.setProperty("--bg-card-hover", `rgb(${bgCardHoverRgb} / ${hoverOpacity})`); - root.style.setProperty("--bg-muted", `rgb(${bgMutedRgb} / ${mutedOpacity})`); - root.style.setProperty("--bg-elevated", `rgb(${bgElevatedRgb} / ${elevatedOpacity})`); - // Liquid glass: overlays (context menus, dialogs) stay dark and near-opaque - // for readability; a white 0.86 sheet would drown the white foreground text. - root.style.setProperty( - "--bg-overlay", - isLiquid ? `rgb(${theme.tokens.bgRgb} / 0.94)` : `rgb(${bgCardRgb} / ${overlayOpacity})`, - ); - root.style.setProperty("--fg", theme.tokens.fg); - root.style.setProperty("--fg-muted", theme.tokens.fgMuted); - root.style.setProperty("--fg-subtle", theme.tokens.fgSubtle); - root.style.setProperty("--border", theme.tokens.border); - root.style.setProperty("--danger", theme.tokens.danger); - root.style.setProperty("--afk-text", theme.tokens.afkText); - root.style.setProperty("--accent", theme.tokens.accent); - root.style.setProperty("--accent-fg", theme.tokens.accentFg); - root.style.setProperty("--success", theme.tokens.success); - root.style.setProperty("--warning", theme.tokens.warning); - root.style.setProperty("--radius-sm", theme.tokens.radiusSm); - root.style.setProperty("--radius-md", theme.tokens.radiusMd); - root.style.setProperty("--radius-lg", theme.tokens.radiusLg); - root.style.setProperty("--elevation-low", theme.tokens.elevationLow); - root.style.setProperty("--elevation-medium", theme.tokens.elevationMedium); - root.style.setProperty("--elevation-high", theme.tokens.elevationHigh); - root.style.setProperty("--density-scale", String(DENSITY_SCALE[theme.tokens.density] ?? 1)); - // The theme font goes in front of the base stack rather than replacing it: - // the tail of that stack is what covers Cyrillic and Han, and a theme is - // never asked to think about the Chinese UI. - root.style.setProperty("--font-ui", `${theme.tokens.fontUi}, var(--font-stack-base)`); - root.style.setProperty("--font-display", `${theme.tokens.fontDisplay}, var(--font-ui)`); - - // Structure. A glass theme paints its surfaces from the backdrop, so a - // gradient laid over them would fill in the very transparency the material - // is made of: those two stay off there whatever the theme asks for. - const surfaceImagesAllowed = !theme.glass; - root.style.setProperty("--bg-image", surfaceImagesAllowed ? theme.tokens.bgImage : "none"); - root.style.setProperty( - "--card-bg-image", - surfaceImagesAllowed ? theme.tokens.cardBgImage : "none", - ); - root.style.setProperty("--border-width", theme.tokens.borderWidth); - root.style.setProperty("--border-style", theme.tokens.borderStyle); - root.style.setProperty("--avatar-radius", AVATAR_RADIUS[theme.tokens.avatarShape] ?? "50%"); - root.style.setProperty("--focus-ring-style", theme.tokens.focusRing); - root.style.setProperty("--overlay-blur", theme.tokens.overlayBlur); - root.style.setProperty("--line-height", theme.tokens.lineHeight); - root.style.setProperty("--letter-spacing", theme.tokens.letterSpacing); - root.style.setProperty("--label-transform", theme.tokens.labelCase); - root.style.setProperty( - "--font-smoothing", - FONT_SMOOTHING_VALUE[theme.tokens.fontSmoothing] ?? "antialiased", - ); - root.style.setProperty( - "--font-smoothing-mac", - FONT_SMOOTHING_MAC_VALUE[theme.tokens.fontSmoothing] ?? "grayscale", - ); - // The animations setting still wins: a theme can slow the interface down or - // stop it, it cannot start it again for someone who turned motion off. - root.style.setProperty("--motion-scale", theme.tokens.motionScale); - applyThemeCss(theme.css, doc); - - if (memoizable) { - lastApplied = { - theme, - tokens: theme.tokens, - opacity: backgroundOpacityPercent, - backdropAvailable: opts.backdropAvailable, - }; - } -} - -/** - * Live preview for the editor. - * - * The saved theme is applied by an effect in App.svelte that also knows - * whether a backdrop is available, which the editor does not. Rather than - * thread that down, the preview reuses the inputs of the last real apply and - * restores them on the way out, so cancelling puts back exactly what was on - * screen, Linux and failed-wallpaper cases included. - */ -let previewSnapshot: AppliedTheme | null = null; - -export function beginThemePreview(): void { - previewSnapshot = lastApplied; -} - -export function previewThemeDocument(document: ThemeDocument): AppThemeDefinition { - const theme = themeFromDocument(document); - applyThemeToDocument(theme, previewSnapshot?.opacity ?? 100, globalThis.document, { - backdropAvailable: previewSnapshot?.backdropAvailable, - preview: true, - }); - void applyWindowBackdrop(Boolean(theme.glass), theme.id, themeUsesLiquidGlass(theme)); - return theme; -} - -export function endThemePreview(): void { - const snapshot = previewSnapshot; - previewSnapshot = null; - resetAppliedThemeMemo(); - if (!snapshot) return; - applyThemeToDocument(snapshot.theme, snapshot.opacity, globalThis.document, { - backdropAvailable: snapshot.backdropAvailable, - }); - void applyWindowBackdrop( - Boolean(snapshot.theme.glass), - snapshot.theme.id, - themeUsesLiquidGlass(snapshot.theme), - ); -} - -/** - * Ends a preview by keeping it. Saving a theme that is already the selected - * one changes no setting, so nothing downstream re-applies: the editor hands - * the saved document back here instead, and what the preview painted becomes - * what the app is running. - */ -export function commitThemePreview(document: ThemeDocument): void { - const snapshot = previewSnapshot; - previewSnapshot = null; - resetAppliedThemeMemo(); - const theme = getThemeDefinition(document.id); - applyThemeToDocument(theme, snapshot?.opacity ?? 100, globalThis.document, { - backdropAvailable: snapshot?.backdropAvailable, - }); - void applyWindowBackdrop(Boolean(theme.glass), theme.id, themeUsesLiquidGlass(theme)); -} - -/** Token specs re-exported so consumers only ever import from one module. */ -export { THEME_TOKEN_SPECS }; diff --git a/src/lib/theme/tokens.ts b/src/lib/theme/tokens.ts deleted file mode 100644 index f7d08bb..0000000 --- a/src/lib/theme/tokens.ts +++ /dev/null @@ -1,582 +0,0 @@ -import type { MessageKey } from "$lib/i18n"; - -/** - * Version of the token contract, not of a theme file. It is bumped when a - * token is added, removed or changes meaning, and a theme document declares - * the version it was written for. A document written for an older version is - * migrated on load (see schema.ts); one written for a newer version is refused - * rather than half-applied, because we cannot know what its tokens mean. - * - * 1: the eleven original colour tokens. - * 2: accent and semantic colours, radii, elevations, density, UI font. - * 3: everything that is not colour: surface gradients, border weight and - * style, avatar shape, focus ring, overlay blur, display font, line height, - * letter spacing, label case, font smoothing, motion scale. Version 2 said - * what a theme was painted with; version 3 says what it is made of. - */ -export const THEME_CONTRACT_VERSION = 3; - -/** - * Every value a theme can set. All values are strings so a theme file stays a - * flat map, which is what the Rust side stores and what the editor edits. - */ -export interface ThemeTokens { - /** Window fill, as an "r g b" triplet: the alpha comes from the opacity slider. */ - bgRgb: string; - bgCard: string; - bgCardHover: string; - bgMuted: string; - bgElevated: string; - fg: string; - fgMuted: string; - fgSubtle: string; - border: string; - danger: string; - /** Text drawn over an account avatar marked away, where the surface is the avatar. */ - afkText: string; - accent: string; - /** Text and icons drawn on top of `accent`. */ - accentFg: string; - success: string; - warning: string; - radiusSm: string; - radiusMd: string; - radiusLg: string; - elevationLow: string; - elevationMedium: string; - elevationHigh: string; - density: string; - fontUi: string; - /** Gradient painted over the window fill, or `none`. */ - bgImage: string; - /** Gradient painted over every card and panel surface, or `none`. */ - cardBgImage: string; - borderWidth: string; - /** `solid`, `ridge`, `dotted`... the whole point of a bevelled theme. */ - borderStyle: string; - /** `circle`, `rounded` or `square`: resolved to a radius when applied. */ - avatarShape: string; - /** Line style of the keyboard focus ring. */ - focusRing: string; - /** Blur applied behind dialogs and the command palette. */ - overlayBlur: string; - /** Font of headings and window titles, distinct from the interface font. */ - fontDisplay: string; - lineHeight: string; - letterSpacing: string; - /** `none` or `uppercase`, applied to headings, buttons and field labels. */ - labelCase: string; - /** `auto` for antialiased text, `none` for the unsmoothed pixel look. */ - fontSmoothing: string; - /** Multiplies every animation duration. `0` freezes the interface. */ - motionScale: string; -} - -export type ThemeTokenKey = keyof ThemeTokens; - -/** - * How a value is validated and what the editor shows for it. The kind also - * decides how the value reaches CSS: `hexColor` tokens are split into an RGB - * triplet so a surface opacity can be applied to them, the others are written - * out as they are. - */ -export type ThemeTokenKind = - | "rgbTriplet" - | "hexColor" - | "color" - | "length" - | "signedLength" - | "shadow" - | "gradient" - | "number" - | "choice" - | "fontStack"; - -export type ThemeTokenGroup = "surface" | "text" | "semantic" | "shape" | "typography" | "motion"; - -export interface ThemeTokenSpec { - key: ThemeTokenKey; - kind: ThemeTokenKind; - group: ThemeTokenGroup; - /** Custom property the token feeds on ``. */ - cssVar: string; - /** Contract version that introduced the token. */ - since: number; - choices?: readonly string[]; - /** Bounds of a `number` token, inclusive. */ - min?: number; - max?: number; - labelKey: MessageKey; -} - -export const DENSITY_CHOICES = ["compact", "cozy", "comfortable"] as const; -export const BORDER_STYLE_CHOICES = [ - "solid", - "dashed", - "dotted", - "double", - "groove", - "ridge", - "inset", - "outset", - "none", -] as const; -export const AVATAR_SHAPE_CHOICES = ["circle", "rounded", "square"] as const; -export const FOCUS_RING_CHOICES = ["solid", "dashed", "dotted", "double"] as const; -export const LABEL_CASE_CHOICES = ["none", "uppercase"] as const; -export const FONT_SMOOTHING_CHOICES = ["auto", "none"] as const; - -/** Multiplier applied to the card grid metrics. */ -export const DENSITY_SCALE: Record = { - compact: 0.88, - cozy: 1, - comfortable: 1.12, -}; - -/** - * Corner radius per avatar shape. `rounded` defers to the rounding each avatar - * already had, which is not one value: a 32px row avatar and a 120px preview - * are rounded by different amounts to read the same. Every avatar rule sets - * its own `--avatar-radius-default`, and this token only decides whether that - * rounding is used, dropped or turned into a full circle. - */ -export const AVATAR_RADIUS: Record = { - circle: "50%", - rounded: "var(--avatar-radius-default, 6px)", - square: "0", -}; - -/** - * What `fontSmoothing` writes into the two smoothing properties. They take - * different keywords, and macOS needs its own turned off as well or a pixel - * theme stays smoothed there. - */ -export const FONT_SMOOTHING_VALUE: Record = { - auto: "antialiased", - none: "none", -}; - -export const FONT_SMOOTHING_MAC_VALUE: Record = { - auto: "grayscale", - none: "auto", -}; - -export const THEME_TOKEN_SPECS: readonly ThemeTokenSpec[] = [ - { - key: "bgRgb", - kind: "rgbTriplet", - group: "surface", - cssVar: "--bg-rgb", - since: 1, - labelKey: "themeToken.bgRgb", - }, - { - key: "bgCard", - kind: "hexColor", - group: "surface", - cssVar: "--bg-card", - since: 1, - labelKey: "themeToken.bgCard", - }, - { - key: "bgCardHover", - kind: "hexColor", - group: "surface", - cssVar: "--bg-card-hover", - since: 1, - labelKey: "themeToken.bgCardHover", - }, - { - key: "bgMuted", - kind: "hexColor", - group: "surface", - cssVar: "--bg-muted", - since: 1, - labelKey: "themeToken.bgMuted", - }, - { - key: "bgElevated", - kind: "hexColor", - group: "surface", - cssVar: "--bg-elevated", - since: 1, - labelKey: "themeToken.bgElevated", - }, - { - key: "border", - kind: "color", - group: "surface", - cssVar: "--border", - since: 1, - labelKey: "themeToken.border", - }, - { key: "fg", kind: "color", group: "text", cssVar: "--fg", since: 1, labelKey: "themeToken.fg" }, - { - key: "fgMuted", - kind: "color", - group: "text", - cssVar: "--fg-muted", - since: 1, - labelKey: "themeToken.fgMuted", - }, - { - key: "fgSubtle", - kind: "color", - group: "text", - cssVar: "--fg-subtle", - since: 1, - labelKey: "themeToken.fgSubtle", - }, - { - key: "afkText", - kind: "color", - group: "text", - cssVar: "--afk-text", - since: 1, - labelKey: "themeToken.afkText", - }, - { - key: "accent", - kind: "color", - group: "semantic", - cssVar: "--accent", - since: 2, - labelKey: "themeToken.accent", - }, - { - key: "accentFg", - kind: "color", - group: "semantic", - cssVar: "--accent-fg", - since: 2, - labelKey: "themeToken.accentFg", - }, - { - key: "success", - kind: "color", - group: "semantic", - cssVar: "--success", - since: 2, - labelKey: "themeToken.success", - }, - { - key: "warning", - kind: "color", - group: "semantic", - cssVar: "--warning", - since: 2, - labelKey: "themeToken.warning", - }, - { - key: "danger", - kind: "color", - group: "semantic", - cssVar: "--danger", - since: 1, - labelKey: "themeToken.danger", - }, - { - key: "radiusSm", - kind: "length", - group: "shape", - cssVar: "--radius-sm", - since: 2, - labelKey: "themeToken.radiusSm", - }, - { - key: "radiusMd", - kind: "length", - group: "shape", - cssVar: "--radius-md", - since: 2, - labelKey: "themeToken.radiusMd", - }, - { - key: "radiusLg", - kind: "length", - group: "shape", - cssVar: "--radius-lg", - since: 2, - labelKey: "themeToken.radiusLg", - }, - { - key: "elevationLow", - kind: "shadow", - group: "shape", - cssVar: "--elevation-low", - since: 2, - labelKey: "themeToken.elevationLow", - }, - { - key: "elevationMedium", - kind: "shadow", - group: "shape", - cssVar: "--elevation-medium", - since: 2, - labelKey: "themeToken.elevationMedium", - }, - { - key: "elevationHigh", - kind: "shadow", - group: "shape", - cssVar: "--elevation-high", - since: 2, - labelKey: "themeToken.elevationHigh", - }, - { - key: "density", - kind: "choice", - group: "shape", - cssVar: "--density-scale", - since: 2, - choices: DENSITY_CHOICES, - labelKey: "themeToken.density", - }, - { - key: "fontUi", - kind: "fontStack", - group: "typography", - cssVar: "--font-ui", - since: 2, - labelKey: "themeToken.fontUi", - }, - { - key: "bgImage", - kind: "gradient", - group: "surface", - cssVar: "--bg-image", - since: 3, - labelKey: "themeToken.bgImage", - }, - { - key: "cardBgImage", - kind: "gradient", - group: "surface", - cssVar: "--card-bg-image", - since: 3, - labelKey: "themeToken.cardBgImage", - }, - { - key: "borderWidth", - kind: "length", - group: "shape", - cssVar: "--border-width", - since: 3, - labelKey: "themeToken.borderWidth", - }, - { - key: "borderStyle", - kind: "choice", - group: "shape", - cssVar: "--border-style", - since: 3, - choices: BORDER_STYLE_CHOICES, - labelKey: "themeToken.borderStyle", - }, - { - key: "avatarShape", - kind: "choice", - group: "shape", - cssVar: "--avatar-radius", - since: 3, - choices: AVATAR_SHAPE_CHOICES, - labelKey: "themeToken.avatarShape", - }, - { - key: "focusRing", - kind: "choice", - group: "shape", - cssVar: "--focus-ring-style", - since: 3, - choices: FOCUS_RING_CHOICES, - labelKey: "themeToken.focusRing", - }, - { - key: "overlayBlur", - kind: "length", - group: "shape", - cssVar: "--overlay-blur", - since: 3, - labelKey: "themeToken.overlayBlur", - }, - { - key: "fontDisplay", - kind: "fontStack", - group: "typography", - cssVar: "--font-display", - since: 3, - labelKey: "themeToken.fontDisplay", - }, - { - key: "lineHeight", - kind: "number", - group: "typography", - cssVar: "--line-height", - since: 3, - min: 1, - max: 2.5, - labelKey: "themeToken.lineHeight", - }, - { - key: "letterSpacing", - kind: "signedLength", - group: "typography", - cssVar: "--letter-spacing", - since: 3, - labelKey: "themeToken.letterSpacing", - }, - { - key: "labelCase", - kind: "choice", - group: "typography", - cssVar: "--label-transform", - since: 3, - choices: LABEL_CASE_CHOICES, - labelKey: "themeToken.labelCase", - }, - { - key: "fontSmoothing", - kind: "choice", - group: "typography", - cssVar: "--font-smoothing", - since: 3, - choices: FONT_SMOOTHING_CHOICES, - labelKey: "themeToken.fontSmoothing", - }, - { - key: "motionScale", - kind: "number", - group: "motion", - cssVar: "--motion-scale", - since: 3, - min: 0, - max: 3, - labelKey: "themeToken.motionScale", - }, -] as const; - -export const THEME_TOKEN_KEYS: readonly ThemeTokenKey[] = THEME_TOKEN_SPECS.map((spec) => spec.key); - -export const THEME_TOKEN_GROUPS: readonly ThemeTokenGroup[] = [ - "surface", - "text", - "semantic", - "shape", - "typography", - "motion", -]; - -/** - * Kinds a theme is expected to fill in itself. A theme that leaves out a - * colour has a hole its author should hear about; one that leaves out a - * letter spacing or a border style has simply not asked for anything there, - * which is what most themes do and what the roots are for. - */ -const ESSENTIAL_TOKEN_KINDS: ReadonlySet = new Set([ - "rgbTriplet", - "hexColor", - "color", -]); - -const SPEC_BY_KEY = new Map( - THEME_TOKEN_SPECS.map((spec) => [spec.key, spec]), -); - -export function getTokenSpec(key: string): ThemeTokenSpec | undefined { - return SPEC_BY_KEY.get(key); -} - -export function isThemeTokenKey(key: string): key is ThemeTokenKey { - return SPEC_BY_KEY.has(key); -} - -/** Whether leaving this token unset is worth telling the author about. */ -export function isEssentialToken(key: string): boolean { - const spec = SPEC_BY_KEY.get(key); - return spec ? ESSENTIAL_TOKEN_KINDS.has(spec.kind) : false; -} - -const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; -const RGB_TRIPLET_RE = /^\d{1,3} \d{1,3} \d{1,3}$/; -const RGB_FUNCTION_RE = /^rgba?\([\d\s.,%/]+\)$/; -const COLOR_KEYWORD_RE = /^[a-zA-Z]{3,20}$/; -const LENGTH_RE = /^(?:0|\d{1,3}(?:\.\d+)?(?:px|rem|em))$/; -// Numbers, units, colour functions and separators. Everything a box-shadow -// needs and nothing that could smuggle another declaration or a network fetch -// into the stylesheet: theme files travel between users, and their values are -// written straight into custom properties. -const SHADOW_RE = /^[0-9a-zA-Z\s.,%#()/-]+$/; -const FONT_STACK_RE = /^[\w\s'",-]+$/; -// A length that may carry a sign, for letter spacing: tightening the tracking -// is as legitimate a choice as opening it up. -const SIGNED_LENGTH_RE = /^-?(?:0|\d{1,3}(?:\.\d+)?(?:px|rem|em))$/; -const NUMBER_RE = /^\d{1,3}(?:\.\d+)?$/; -// Only the gradient functions, and only over the same safe charset as a -// shadow. `url()` is what a gradient could otherwise smuggle in, and a theme -// file must not be able to fetch anything when it is applied. -const GRADIENT_RE = /^(?:repeating-)?(?:linear|radial|conic)-gradient\([0-9a-zA-Z\s.,%#()/-]*\)$/; -const MAX_VALUE_LENGTH = 200; -/** Gradients carry colour stops, so they get more room than a plain value. */ -const MAX_GRADIENT_LENGTH = 400; - -function isRgbTriplet(value: string): boolean { - if (!RGB_TRIPLET_RE.test(value)) return false; - return value.split(" ").every((part) => Number(part) <= 255); -} - -/** - * Whether a raw value is usable for that token. Anything rejected here is - * dropped at resolution time and the inherited value is used instead, so a - * malformed or hostile theme file degrades to the theme it extends rather than - * breaking the interface. - */ -export function isValidTokenValue(key: string, rawValue: unknown): boolean { - const spec = SPEC_BY_KEY.get(key); - if (!spec || typeof rawValue !== "string") return false; - const value = rawValue.trim(); - const maxLength = spec.kind === "gradient" ? MAX_GRADIENT_LENGTH : MAX_VALUE_LENGTH; - if (!value || value.length > maxLength) return false; - switch (spec.kind) { - case "rgbTriplet": - return isRgbTriplet(value); - case "hexColor": - return HEX_RE.test(value); - case "color": - return HEX_RE.test(value) || RGB_FUNCTION_RE.test(value) || COLOR_KEYWORD_RE.test(value); - case "length": - return LENGTH_RE.test(value); - case "signedLength": - return SIGNED_LENGTH_RE.test(value); - case "shadow": - return ( - value === "none" || (SHADOW_RE.test(value) && !/url\(|var\(|expression\(/i.test(value)) - ); - case "gradient": - return ( - value === "none" || (GRADIENT_RE.test(value) && !/url\(|var\(|expression\(/i.test(value)) - ); - case "number": { - if (!NUMBER_RE.test(value)) return false; - const parsed = Number(value); - return parsed >= (spec.min ?? 0) && parsed <= (spec.max ?? 10); - } - case "choice": - return (spec.choices ?? []).includes(value); - case "fontStack": - return FONT_STACK_RE.test(value); - } -} - -/** The example the editor shows when a value is refused. */ -export const TOKEN_KIND_EXAMPLE: Record = { - rgbTriplet: "9 9 11", - hexColor: "#1c1c1f", - color: "#fafafa", - length: "8px", - signedLength: "-0.02em", - shadow: "0 2px 8px rgb(0 0 0 / 0.18)", - gradient: "linear-gradient(180deg, #ffffff, #c0c0c0)", - number: "1.5", - choice: DENSITY_CHOICES.join(" | "), - fontStack: '"Inter", sans-serif', -}; diff --git a/src/main.ts b/src/main.ts deleted file mode 100644 index 10c2254..0000000 --- a/src/main.ts +++ /dev/null @@ -1,267 +0,0 @@ -import "./app.css"; -import App from "./App.svelte"; -import { invoke } from "@tauri-apps/api/core"; -import { mount } from "svelte"; -import { fetchBootPayload } from "$lib/app/bootPayload"; -import { initializeClientStorage } from "$lib/storage/clientStorage"; -import { loadLocaleMessages } from "$lib/i18n"; -import { getSettings } from "$lib/features/settings/store"; -import { bootMarks, markBoot } from "$lib/app/bootMarks"; -import { getBootPayload } from "$lib/app/bootPayload"; -import { getInitialActiveTab } from "$lib/app/platformShell.svelte"; -import { preloadPlatformModule } from "$lib/platforms/registry"; -import { toRuntimeOs } from "$lib/shared/platform"; -import { recordFontWarmup } from "$lib/app/fontWarmup"; -import { rememberBootLocale } from "$lib/app/bootLocale"; - -type LogLevel = "info" | "warn" | "error"; -type LogPayload = { - level: LogLevel; - source: string; - message: string; - details: string | null; -}; - -const originalConsoleError = console.error.bind(console); -let bootFinished = false; -let logChain = Promise.resolve(); -let lastLoggingFailureAt = 0; -/** - * Info records written while the app boots wait here until the window is up. - * Each one is an IPC round trip whose reply is evaluated on the webview's main - * thread, in front of the mount; six of them were landing between the boot - * payload and `finish_boot`. Anything above info goes out immediately and - * takes the queue with it, so a failure still arrives with its context. - */ -let bootLogBuffer: LogPayload[] | null = []; - -function serializeLogValue(value: unknown, seen = new WeakSet()): string { - if (value instanceof Error) { - return JSON.stringify({ - name: value.name, - message: value.message, - stack: value.stack, - cause: value.cause ? serializeLogValue(value.cause, seen) : undefined, - }); - } - - if (typeof value === "string") return value; - if (typeof value === "number" || typeof value === "boolean" || value == null) - return String(value); - if (typeof value === "bigint") return value.toString(); - - if (typeof value === "object") { - if (seen.has(value)) return "[Circular]"; - seen.add(value); - try { - return JSON.stringify(value); - } catch { - return Object.prototype.toString.call(value); - } - } - - return String(value); -} - -function queueLog(level: LogLevel, source: string, message: string, details?: string | null) { - const payload: LogPayload = { - level, - source, - message: message.slice(0, 512), - details: details ? details.slice(0, 16_384) : null, - }; - - if (bootLogBuffer && level === "info") { - bootLogBuffer.push(payload); - return; - } - flushBootLogs(); - sendLog(payload); -} - -function flushBootLogs() { - const pending = bootLogBuffer; - if (!pending) return; - bootLogBuffer = null; - for (const payload of pending) sendLog(payload); -} - -function sendLog(payload: LogPayload) { - logChain = logChain - .catch(() => {}) - .then(async () => { - try { - await invoke("log_app_event", payload); - } catch (reason) { - const now = Date.now(); - if (now - lastLoggingFailureAt > 5000) { - lastLoggingFailureAt = now; - originalConsoleError("App logging unavailable:", reason); - } - } - }); -} - -/** - * When the webview finished its first frame, which `finish_boot` cannot say: - * it lands before the compositor has taken a single one. The animation frame - * callback runs at the start of that frame; the task queued from it runs once - * style, layout and paint are done. Unlike the Paint Timing API, this also - * fires in a window that is not on screen yet. - */ -function reportFirstFrame() { - requestAnimationFrame(() => { - setTimeout(() => { - queueLog( - "info", - "frontend.paint", - "First frame", - String(Math.round(performance.now() * 10) / 10), - ); - }, 0); - }); -} - -async function finishBoot(source: string) { - if (bootFinished) return; - bootFinished = true; - markBoot("finishBoot"); - const completed = invoke("finish_boot", { source, marks: bootMarks() }); - reportFirstFrame(); - if (getBootPayload()?.runtimeOs === "windows") { - // Once the first frame is out and the page has nothing left to do. - requestAnimationFrame(() => - requestIdleCallback(() => recordFontWarmup(document.body), { timeout: 2000 }), - ); - } - try { - await completed; - flushBootLogs(); - } catch (reason) { - bootFinished = false; - queueLog("error", "frontend.finish_boot", "Failed to finish boot", serializeLogValue(reason)); - originalConsoleError("Failed to finish boot:", reason); - } -} - -window.addEventListener( - "accshift:boot-ready", - () => { - queueLog("info", "frontend.boot", "Received boot-ready signal"); - void finishBoot("frontend.boot-ready"); - }, - { once: true }, -); - -window.addEventListener( - "load", - () => { - queueLog("info", "frontend.boot", "Window load event fired"); - window.setTimeout(() => { - if (bootFinished) return; - queueLog("warn", "frontend.boot", "1500ms fallback elapsed before boot-ready"); - void finishBoot("frontend.load-fallback-1500ms"); - }, 1500); - }, - { once: true }, -); - -window.addEventListener("error", (event) => { - const message = event.message || "Unhandled window error"; - const details = serializeLogValue({ - filename: event.filename, - lineno: event.lineno, - colno: event.colno, - error: event.error, - }); - queueLog("error", "frontend.window.error", message, details); -}); - -window.addEventListener("unhandledrejection", (event) => { - const details = serializeLogValue(event.reason); - queueLog("error", "frontend.unhandledrejection", "Unhandled promise rejection", details); -}); - -console.error = (...args: unknown[]) => { - originalConsoleError(...args); - queueLog( - "error", - "frontend.console.error", - serializeLogValue(args[0] ?? "console.error"), - args.length > 1 ? serializeLogValue(args.slice(1)) : null, - ); -}; - -markBoot("mainTs"); -queueLog("info", "frontend.boot", "main.ts initialized"); -// A boot that never completes, and never errors either, would keep its info -// records in memory: the log has to show what the last session did. -window.setTimeout(flushBootLogs, 5000); - -let app; - -async function bootstrap() { - try { - // One round trip for everything boot needs (storage, themes, runtime OS, - // migration result). On failure the legacy per-command path below covers. - await fetchBootPayload(); - markBoot("bootPayload"); - queueLog("info", "frontend.boot", "Boot payload loaded"); - } catch (reason) { - queueLog("error", "frontend.boot", "Failed to load boot payload", serializeLogValue(reason)); - } - - try { - await initializeClientStorage(); - markBoot("clientStorage"); - queueLog("info", "frontend.storage", "Client storage initialized"); - } catch (reason) { - queueLog( - "error", - "frontend.storage", - "Failed to initialize client storage", - serializeLogValue(reason), - ); - } - - // The first thing the window shows is the active platform's accounts, and - // their adapter is a chunk of its own that App only asks for once it has - // mounted. Asked for here, it downloads while the locale loads and the app - // mounts. Same choice as the shell's, which also knows the OS by then. - preloadPlatformModule( - getInitialActiveTab(getSettings(), toRuntimeOs(getBootPayload()?.runtimeOs)), - ); - - try { - // Non-EN dictionaries live in their own lazy chunk. Await the persisted - // locale BEFORE the first render so a French user never sees an English - // flash; for "en" this resolves synchronously. - const language = getSettings().language; - rememberBootLocale(language); - await loadLocaleMessages(language); - markBoot("locale"); - } catch (reason) { - // Non-fatal: translate() falls back to English and retries the load. - queueLog( - "error", - "frontend.i18n", - "Failed to preload locale messages", - serializeLogValue(reason), - ); - } - - try { - app = mount(App, { - target: document.getElementById("app")!, - }); - markBoot("mounted"); - queueLog("info", "frontend.boot", "App mounted"); - } catch (reason) { - queueLog("error", "frontend.mount", "Failed to mount App", serializeLogValue(reason)); - throw reason; - } -} - -void bootstrap(); - -export default app; diff --git a/svelte.config.js b/svelte.config.js deleted file mode 100644 index d0e6448..0000000 --- a/svelte.config.js +++ /dev/null @@ -1,5 +0,0 @@ -import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; - -export default { - preprocess: vitePreprocess(), -}; diff --git a/tailwind.config.js b/tailwind.config.js deleted file mode 100644 index 25a8eac..0000000 --- a/tailwind.config.js +++ /dev/null @@ -1,19 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - content: ["./src/**/*.{html,js,svelte,ts}", "./index.html"], - theme: { - extend: { - colors: { - // shadcn-svelte zinc dark palette (adjusted for better card contrast) - background: "#09090b", - foreground: "#fafafa", - card: "#1a1a1d", - "card-hover": "#222225", - muted: "#27272a", - "muted-foreground": "#a1a1aa", - border: "#27272a", - }, - }, - }, - plugins: [], -}; diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 06599cf..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "extends": "@tsconfig/svelte/tsconfig.json", - "compilerOptions": { - "target": "ESNext", - "useDefineForClassFields": true, - "module": "ESNext", - "resolveJsonModule": true, - "allowJs": true, - "checkJs": true, - "isolatedModules": true, - "moduleDetection": "force", - "noEmit": true, - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "types": ["vite/client"], - "baseUrl": ".", - "paths": { - "$lib/*": ["src/lib/*"] - } - }, - "include": ["src/**/*.ts", "src/**/*.svelte"], - "references": [{ "path": "./tsconfig.node.json" }] -} diff --git a/tsconfig.node.json b/tsconfig.node.json deleted file mode 100644 index 3a53a58..0000000 --- a/tsconfig.node.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "skipLibCheck": true, - "allowJs": true, - "checkJs": true, - "module": "ESNext", - "moduleResolution": "bundler", - "strict": true - }, - "include": ["vite.config.js"] -} diff --git a/vendor/wry/ACCSHIFT.md b/vendor/wry/ACCSHIFT.md deleted file mode 100644 index cbb8cd3..0000000 --- a/vendor/wry/ACCSHIFT.md +++ /dev/null @@ -1,13 +0,0 @@ -# wry 0.55.1, patched - -Upstream wry 0.55.1 from crates.io (Apache-2.0 OR MIT, licenses alongside), wired in through `[patch.crates-io]` in the root `Cargo.toml`. Examples, changelog and upstream docs are left out. Only the Windows backend changes, and every change carries an `Accshift patch` comment: - -- `src/webview2/prestart.rs`, new: starts WebView2 on a hidden message-only window before the app builds its own. The Windows launcher (`src-tauri/src/launcher.rs`) calls it first thing, then loads the app DLL, whose first webview takes the controller over if its environment options match. -- `src/webview2/mod.rs`: `new_in_hwnd` takes the prestarted controller; the browser arguments, environment options and controller request are split into helpers that the prestart reuses; `add_script_to_execute_on_document_created` no longer waits for each completion. -- `src/lib.rs`: re-exports the prestart API. - -## Updating - -Tauri pins wry through `tauri-runtime-wry`. When it moves to another wry version, this patch stops applying (cargo warns `patch ... was not used`) and the launcher fails to build, since upstream has no `webview2_prestart`. To move along: copy the new upstream crate over this directory, drop `examples/`, the `[[example]]` and dev-dependency sections of `Cargo.toml`, and replay the three changes above. - -Dependabot leaves tauri minors alone for that reason (`.github/dependabot.yml`). The `@tauri-apps/api` and `@tauri-apps/plugin-*` npm packages move in the same change: `tauri build` stops on any whose major.minor differs from its Rust crate. diff --git a/vendor/wry/Cargo.toml b/vendor/wry/Cargo.toml deleted file mode 100644 index 9cbb265..0000000 --- a/vendor/wry/Cargo.toml +++ /dev/null @@ -1,363 +0,0 @@ -# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO -# -# When uploading crates to the registry Cargo will automatically -# "normalize" Cargo.toml files for maximal compatibility -# with all versions of Cargo and also rewrite `path` dependencies -# to registry (e.g., crates.io) dependencies. -# -# If you are reading this file be aware that the original Cargo.toml -# will likely look very different (and much more reasonable). -# See Cargo.toml.orig for the original contents. - -[package] -edition = "2021" -rust-version = "1.77" -name = "wry" -version = "0.55.1" -authors = ["Tauri Programme within The Commons Conservancy"] -build = "build.rs" -exclude = [ - "/.changes", - "/.github", - "/audits", - "/wry-logo.svg", -] -autolib = false -autobins = false -autoexamples = false -autotests = false -autobenches = false -description = "Cross-platform WebView rendering library" -documentation = "https://docs.rs/wry" -categories = ["gui"] -license = "Apache-2.0 OR MIT" -repository = "https://github.com/tauri-apps/wry" - -[package.metadata.docs.rs] -no-default-features = true -features = [ - "protocol", - "os-webview", -] -targets = [ - "x86_64-unknown-linux-gnu", - "x86_64-pc-windows-msvc", - "x86_64-apple-darwin", -] - -[features] -default = [ - "protocol", - "os-webview", - "x11", -] -devtools = [] -fullscreen = [] -linux-body = [ - "webkit2gtk/v2_40", - "os-webview", -] -mac-proxy = [] -os-webview = [ - "javascriptcore-rs", - "webkit2gtk", - "webkit2gtk-sys", - "dep:gtk", - "soup3", -] -protocol = [] -serde = ["dpi/serde"] -tracing = ["dep:tracing"] -transparent = [] -x11 = [ - "x11-dl", - "gdkx11", - "tao/x11", -] - -[lib] -name = "wry" -path = "src/lib.rs" - -[dependencies.cookie] -version = "0.18" - -[dependencies.dpi] -version = "0.1" - -[dependencies.http] -version = "1.1" - -[dependencies.once_cell] -version = "1" - -[dependencies.raw-window-handle] -version = "0.6" -features = ["std"] - -[dependencies.thiserror] -version = "2.0" - -[dependencies.tracing] -version = "0.1" -optional = true - -[dev-dependencies.base64] -version = "0.22" - -[dev-dependencies.dom_query] -version = "0.27.0" -default-features = false - -[dev-dependencies.getrandom] -version = "0.3" - -[dev-dependencies.glow] -version = "0.16.0" - -[dev-dependencies.http-range] -version = "0.1" - -[dev-dependencies.libloading] -version = "0.8.9" - -[dev-dependencies.percent-encoding] -version = "2.3" - -[dev-dependencies.pollster] -version = "0.4.0" - -[dev-dependencies.sha2] -version = "0.10" - -[dev-dependencies.tao] -version = "0.35" - -[dev-dependencies.wgpu] -version = "23" - -[dev-dependencies.winit] -version = "0.30" - -[target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dependencies.dirs] -version = "6" - -[target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dependencies.gdkx11] -version = "0.18" -optional = true - -[target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dependencies.gtk] -version = "0.18" -optional = true - -[target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dependencies.javascriptcore-rs] -version = "=1.1.2" -features = ["v2_28"] -optional = true - -[target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dependencies.percent-encoding] -version = "2.3" - -[target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dependencies.soup3] -version = "0.5" -optional = true - -[target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dependencies.webkit2gtk] -version = "=2.0.2" -features = ["v2_38"] -optional = true - -[target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dependencies.webkit2gtk-sys] -version = "=2.0.2" -optional = true - -[target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dependencies.x11-dl] -version = "2.21" -optional = true - -[target.'cfg(any(target_os = "linux", target_os = "dragonfly", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dev-dependencies.x11-dl] -version = "2.21" - -[target.'cfg(target_os = "android")'.dependencies.base64] -version = "0.22" - -[target.'cfg(target_os = "android")'.dependencies.crossbeam-channel] -version = "0.5" - -[target.'cfg(target_os = "android")'.dependencies.dom_query] -version = "0.27.0" -default-features = false - -[target.'cfg(target_os = "android")'.dependencies.jni] -version = "0.21" - -[target.'cfg(target_os = "android")'.dependencies.libc] -version = "0.2" - -[target.'cfg(target_os = "android")'.dependencies.ndk] -version = "0.9" - -[target.'cfg(target_os = "android")'.dependencies.sha2] -version = "0.10" - -[target.'cfg(target_os = "android")'.dependencies.tao-macros] -version = "0.1" - -[target.'cfg(target_os = "ios")'.dependencies.objc2-ui-kit] -version = "0.3.0" -features = [ - "std", - "objc2-core-foundation", - "UIResponder", - "UIScrollView", - "UIView", - "UIWindow", - "UIApplication", - "UIEvent", - "UIColor", -] -default-features = false - -[target.'cfg(target_os = "macos")'.dependencies.objc2-app-kit] -version = "0.3.0" -features = [ - "std", - "objc2-core-foundation", - "NSApplication", - "NSButton", - "NSControl", - "NSEvent", - "NSWindow", - "NSView", - "NSPasteboard", - "NSPanel", - "NSResponder", - "NSOpenPanel", - "NSSavePanel", - "NSMenu", - "NSGraphics", - "NSScreen", -] -default-features = false - -[target.'cfg(target_os = "windows")'.dependencies.dunce] -version = "1" - -[target.'cfg(target_os = "windows")'.dependencies.webview2-com] -version = "0.38" - -[target.'cfg(target_os = "windows")'.dependencies.windows] -version = "0.61" -features = [ - "Win32_Foundation", - "Win32_Graphics_Gdi", - "Win32_System_Com", - "Win32_System_Com_StructuredStorage", - "Win32_System_LibraryLoader", - "Win32_System_Ole", - "Win32_System_SystemInformation", - "Win32_System_SystemServices", - "Win32_UI_Shell", - "Win32_UI_WindowsAndMessaging", - "Win32_Globalization", - "Win32_UI_HiDpi", - "Win32_UI_Input", - "Win32_UI_Input_KeyboardAndMouse", -] - -[target.'cfg(target_os = "windows")'.dependencies.windows-core] -version = "0.61" - -[target.'cfg(target_os = "windows")'.dependencies.windows-version] -version = "0.1" - -[target.'cfg(target_vendor = "apple")'.dependencies.block2] -version = "0.6" - -[target.'cfg(target_vendor = "apple")'.dependencies.dirs] -version = "6" - -[target.'cfg(target_vendor = "apple")'.dependencies.objc2] -version = "0.6.4" -features = [ - "exception", - "disable-encoding-assertions", -] - -[target.'cfg(target_vendor = "apple")'.dependencies.objc2-core-foundation] -version = "0.3.0" -features = [ - "std", - "CFCGTypes", -] -default-features = false - -[target.'cfg(target_vendor = "apple")'.dependencies.objc2-foundation] -version = "0.3.0" -features = [ - "std", - "objc2-core-foundation", - "NSURLRequest", - "NSURL", - "NSString", - "NSKeyValueCoding", - "NSStream", - "NSDictionary", - "NSObject", - "NSData", - "NSEnumerator", - "NSKeyValueObserving", - "NSThread", - "NSJSONSerialization", - "NSDate", - "NSBundle", - "NSProcessInfo", - "NSValue", - "NSRange", - "NSRunLoop", -] -default-features = false - -[target.'cfg(target_vendor = "apple")'.dependencies.objc2-web-kit] -version = "0.3.0" -features = [ - "std", - "objc2-core-foundation", - "objc2-app-kit", - "block2", - "WKWebView", - "WKWebViewConfiguration", - "WKWebsiteDataStore", - "WKDownload", - "WKDownloadDelegate", - "WKNavigation", - "WKNavigationDelegate", - "WKUserContentController", - "WKURLSchemeHandler", - "WKPreferences", - "WKURLSchemeTask", - "WKScriptMessageHandler", - "WKUIDelegate", - "WKOpenPanelParameters", - "WKFrameInfo", - "WKSecurityOrigin", - "WKScriptMessage", - "WKNavigationAction", - "WKWebpagePreferences", - "WKNavigationResponse", - "WKUserScript", - "WKHTTPCookieStore", - "WKWindowFeatures", -] -default-features = false - -[target.'cfg(target_vendor = "apple")'.dependencies.url] -version = "2.5" - -[lints.rust.unexpected_cfgs] -level = "warn" -priority = 0 -check-cfg = [ - "cfg(linux)", - "cfg(gtk)", -] diff --git a/vendor/wry/LICENSE-APACHE b/vendor/wry/LICENSE-APACHE deleted file mode 100644 index 16fe87b..0000000 --- a/vendor/wry/LICENSE-APACHE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - -Copyright [yyyy] [name of copyright owner] - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. diff --git a/vendor/wry/LICENSE-MIT b/vendor/wry/LICENSE-MIT deleted file mode 100644 index 94ef8bf..0000000 --- a/vendor/wry/LICENSE-MIT +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2020-2023 Ngo Iok Ui & Tauri Programme within The Commons Conservancy - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/vendor/wry/build.rs b/vendor/wry/build.rs deleted file mode 100644 index 13ff4cf..0000000 --- a/vendor/wry/build.rs +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright 2020-2023 Tauri Programme within The Commons Conservancy -// SPDX-License-Identifier: Apache-2.0 -// SPDX-License-Identifier: MIT - -fn main() { - let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); - if target_os == "macos" || target_os == "ios" { - println!("cargo:rustc-link-lib=framework=WebKit"); - } - - if target_os == "android" { - use std::{fs, path::PathBuf}; - - fn env_var(var: &str) -> String { - std::env::var(var).unwrap_or_else(|_| { - panic!("`{var}` is not set, which is needed to generate the kotlin files for android.") - }) - } - - println!("cargo:rerun-if-env-changed=WRY_ANDROID_PACKAGE"); - println!("cargo:rerun-if-env-changed=WRY_ANDROID_LIBRARY"); - println!("cargo:rerun-if-env-changed=WRY_ANDROID_KOTLIN_FILES_OUT_DIR"); - - if let Ok(kotlin_out_dir) = std::env::var("WRY_ANDROID_KOTLIN_FILES_OUT_DIR") { - let package = env_var("WRY_ANDROID_PACKAGE"); - let library = env_var("WRY_ANDROID_LIBRARY"); - - let kotlin_out_dir = PathBuf::from(&kotlin_out_dir) - .canonicalize() - .unwrap_or_else(move |_| { - panic!("Failed to canonicalize `WRY_ANDROID_KOTLIN_FILES_OUT_DIR` path {kotlin_out_dir}") - }); - - let kotlin_files_path = - PathBuf::from(env_var("CARGO_MANIFEST_DIR")).join("src/android/kotlin"); - println!("cargo:rerun-if-changed={}", kotlin_files_path.display()); - let kotlin_files = fs::read_dir(kotlin_files_path).expect("failed to read kotlin directory"); - - for file in kotlin_files { - let file = file.unwrap(); - - let class_extension_env = format!( - "WRY_{}_CLASS_EXTENSION", - file - .path() - .file_stem() - .unwrap() - .to_string_lossy() - .to_uppercase() - ); - let class_init_env = format!( - "WRY_{}_CLASS_INIT", - file - .path() - .file_stem() - .unwrap() - .to_string_lossy() - .to_uppercase() - ); - - println!("cargo:rerun-if-env-changed={class_extension_env}"); - println!("cargo:rerun-if-env-changed={class_init_env}"); - - let content = fs::read_to_string(file.path()) - .expect("failed to read kotlin file as string") - .replace("{{package}}", &package) - .replace("{{package-unescaped}}", &package.replace('`', "")) - .replace("{{library}}", &library) - .replace( - "{{class-extension}}", - &std::env::var(&class_extension_env).unwrap_or_default(), - ) - .replace( - "{{class-init}}", - &std::env::var(&class_init_env).unwrap_or_default(), - ); - - let auto_generated_comment = match file - .path() - .extension() - .unwrap_or_default() - .to_str() - .unwrap_or_default() - { - "pro" => "# THIS FILE IS AUTO-GENERATED. DO NOT MODIFY!!\n\n", - "kt" => "/* THIS FILE IS AUTO-GENERATED. DO NOT MODIFY!! */\n\n", - _ => "String::new()", - }; - let mut out = String::from(auto_generated_comment); - out.push_str(&content); - - let out_path = kotlin_out_dir.join(file.file_name()); - // Overwrite only if changed to not trigger rebuilds - if fs::read_to_string(&out_path).map_or(true, |o| o != out) { - fs::write(&out_path, out).expect("Failed to write kotlin file"); - } - println!("cargo:rerun-if-changed={}", out_path.display()); - } - } - } - - let target = std::env::var("TARGET").unwrap_or_default(); - let android = target.contains("android"); - let linux = !android - && (target.contains("linux") - || target.contains("freebsd") - || target.contains("dragonfly") - || target.contains("netbsd") - || target.contains("openbsd")); - alias("linux", linux); - alias("gtk", cfg!(feature = "os-webview") && linux); -} - -fn alias(alias: &str, condition: bool) { - if condition { - println!("cargo:rustc-cfg={alias}"); - } -} diff --git a/vendor/wry/rustfmt.toml b/vendor/wry/rustfmt.toml deleted file mode 100644 index a90653d..0000000 --- a/vendor/wry/rustfmt.toml +++ /dev/null @@ -1,15 +0,0 @@ -max_width = 100 -hard_tabs = false -tab_spaces = 2 -newline_style = "Unix" -use_small_heuristics = "Default" -reorder_imports = true -reorder_modules = true -remove_nested_parens = true -edition = "2018" -merge_derives = true -use_try_shorthand = false -use_field_init_shorthand = false -force_explicit_abi = true -imports_granularity = "Crate" -#license_template_path = ".license_template" diff --git a/vendor/wry/src/android/binding.rs b/vendor/wry/src/android/binding.rs deleted file mode 100644 index 031cd09..0000000 --- a/vendor/wry/src/android/binding.rs +++ /dev/null @@ -1,483 +0,0 @@ -// Copyright 2020-2023 Tauri Programme within The Commons Conservancy -// SPDX-License-Identifier: Apache-2.0 -// SPDX-License-Identifier: MIT - -use http::{ - header::{HeaderName, HeaderValue, CONTENT_LENGTH, CONTENT_TYPE}, - Request, -}; -use jni::errors::Result as JniResult; -pub use jni::{ - self, - objects::{GlobalRef, JClass, JMap, JObject, JString}, - sys::{jboolean, jint, jobject, jstring}, - JNIEnv, -}; -pub use ndk; -use ndk::looper::{FdEvent, ThreadLooper}; -use std::os::fd::{AsFd, AsRawFd}; - -use super::{ - main_pipe::{MainPipe, MAIN_PIPE}, - ASSET_LOADER_DOMAIN, EVAL_CALLBACKS, IPC, ON_LOAD_HANDLER, REQUEST_HANDLER, TITLE_CHANGE_HANDLER, - URL_LOADING_OVERRIDE, WITH_ASSET_LOADER, -}; - -use crate::PageLoadEvent; - -#[macro_export] -macro_rules! android_binding { - ($domain:ident, $package:ident) => { - ::wry::android_binding!($domain, $package, ::wry) - }; - // use imported `android_setup` just to force the import path to use `wry::{}` - // as the macro breaks without braces - ($domain:ident, $package:ident, $wry:path) => {{ - use $wry::{android_setup as _, prelude::*}; - - android_fn!($domain, $package, Rust, wryCreate, []); - android_fn!( - $domain, - $package, - Rust, - onWebviewDestroy, - [JObject, JString] - ); - - android_fn!( - $domain, - $package, - Rust, - handleRequest, - [JString, JObject, jboolean], - jobject - ); - android_fn!( - $domain, - $package, - Rust, - withAssetLoader, - [JString], - jboolean - ); - android_fn!( - $domain, - $package, - Rust, - assetLoaderDomain, - [JString], - jstring - ); - android_fn!( - $domain, - $package, - Rust, - shouldOverride, - [JString, JString], - jboolean - ); - android_fn!($domain, $package, Rust, onEval, [JString, jint, JString]); - android_fn!($domain, $package, Rust, onPageLoading, [JString, JString]); - android_fn!($domain, $package, Rust, onPageLoaded, [JString, JString]); - android_fn!($domain, $package, Rust, ipc, [JString, JString, JString]); - android_fn!( - $domain, - $package, - Rust, - handleReceivedTitle, - [JString, JString], - ); - }}; -} - -fn handle_request( - env: &mut JNIEnv, - webview_id: JString, - request: JObject, - is_document_start_script_enabled: jboolean, -) -> JniResult { - let webview_id = env.get_string(&webview_id)?; - let webview_id = webview_id.to_str().ok().unwrap_or_default(); - - if let Some(handler) = REQUEST_HANDLER.lock().unwrap().get(webview_id) { - #[cfg(feature = "tracing")] - let span = - tracing::info_span!(parent: None, "wry::custom_protocol::handle", uri = tracing::field::Empty).entered(); - - let mut request_builder = Request::builder(); - - let uri = env - .call_method(&request, "getUrl", "()Landroid/net/Uri;", &[])? - .l()?; - let url: JString = env - .call_method(&uri, "toString", "()Ljava/lang/String;", &[])? - .l()? - .into(); - let url = env.get_string(&url)?.to_string_lossy().to_string(); - - #[cfg(feature = "tracing")] - span.record("uri", &url); - - request_builder = request_builder.uri(&url); - - let method = env - .call_method(&request, "getMethod", "()Ljava/lang/String;", &[])? - .l() - .map(JString::from)?; - request_builder = request_builder.method( - env - .get_string(&method)? - .to_string_lossy() - .to_string() - .as_str(), - ); - - let request_headers = env - .call_method(request, "getRequestHeaders", "()Ljava/util/Map;", &[])? - .l()?; - let request_headers = JMap::from_env(env, &request_headers)?; - let mut iter = request_headers.iter(env)?; - while let Some((header, value)) = iter.next(env)? { - let header = JString::from(header); - let value = JString::from(value); - let header = env.get_string(&header)?; - let value = env.get_string(&value)?; - if let (Ok(header), Ok(value)) = ( - HeaderName::from_bytes(header.to_bytes()), - HeaderValue::from_bytes(value.to_bytes()), - ) { - request_builder = request_builder.header(header, value); - } - } - - let final_request = match request_builder.body(Vec::new()) { - Ok(req) => req, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::warn!("Failed to build response: {_e}"); - return Ok(*JObject::null()); - } - }; - - let response = { - #[cfg(feature = "tracing")] - let _span = tracing::info_span!("wry::custom_protocol::call_handler").entered(); - (handler.handler)( - webview_id, - final_request, - is_document_start_script_enabled != 0, - ) - }; - if let Some(response) = response { - let status = response.status(); - let status_code = status.as_u16() as i32; - let status_err = if status_code < 100 { - Some("Status code can't be less than 100") - } else if status_code > 599 { - Some("statusCode can't be greater than 599.") - } else if status_code > 299 && status_code < 400 { - Some("statusCode can't be in the [300, 399] range.") - } else { - None - }; - if let Some(_err) = status_err { - #[cfg(feature = "tracing")] - tracing::warn!("{_err}"); - return Ok(*JObject::null()); - } - - let reason_phrase = status.canonical_reason().unwrap_or("OK"); - let (mime_type, encoding) = if let Some(content_type) = response.headers().get(CONTENT_TYPE) { - let content_type = content_type.to_str().unwrap().trim(); - let mut s = content_type.split(';'); - let mime_type = s.next().unwrap().trim(); - let mut encoding = None; - for token in s { - let token = token.trim(); - if token.starts_with("charset=") { - encoding.replace(token.split('=').nth(1).unwrap()); - break; - } - } - ( - env.new_string(mime_type)?, - if let Some(encoding) = encoding { - env.new_string(encoding)? - } else { - JString::default() - }, - ) - } else { - (JString::default(), JString::default()) - }; - - let headers = response.headers(); - let obj = env.new_object("java/util/HashMap", "()V", &[])?; - let response_headers = { - let headers_map = JMap::from_env(env, &obj)?; - for (name, value) in headers.iter() { - // WebResourceResponse will automatically generate Content-Type and - // Content-Length headers so we should skip them to avoid duplication. - if name == CONTENT_TYPE || name == CONTENT_LENGTH { - continue; - } - let key = env.new_string(name)?; - let value = env.new_string(value.to_str().unwrap_or_default())?; - headers_map.put(env, &key, &value)?; - } - headers_map - }; - - let bytes = response.body(); - - let byte_array_input_stream = env.find_class("java/io/ByteArrayInputStream")?; - let byte_array = env.byte_array_from_slice(bytes)?; - let stream = env.new_object(byte_array_input_stream, "([B)V", &[(&byte_array).into()])?; - - let reason_phrase = env.new_string(reason_phrase)?; - - let web_resource_response_class = env.find_class("android/webkit/WebResourceResponse")?; - let web_resource_response = env.new_object( - web_resource_response_class, - "(Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;Ljava/util/Map;Ljava/io/InputStream;)V", - &[(&mime_type).into(), (&encoding).into(), status_code.into(), (&reason_phrase).into(), (&response_headers).into(), (&stream).into()], - )?; - - return Ok(*web_resource_response); - } - } - - Ok(*JObject::null()) -} - -#[allow(non_snake_case)] -pub unsafe fn wryCreate(env: JNIEnv, _: JClass) { - let mut main_pipe = MainPipe { env }; - - let looper = ThreadLooper::for_thread().unwrap(); - - looper - .add_fd_with_callback(MAIN_PIPE[0].as_fd(), FdEvent::INPUT, move |fd, _event| { - let size = std::mem::size_of::(); - let mut wake = false; - if libc::read(fd.as_raw_fd(), &mut wake as *mut _ as *mut _, size) == size as libc::ssize_t { - // unregister itself on errors - main_pipe.recv().is_ok() - } else { - // unregister itself - false - } - }) - .unwrap(); -} - -#[allow(non_snake_case)] -pub unsafe fn onWebviewDestroy(mut env: JNIEnv, _: JClass, activity: JObject, webview_id: JString) { - let activity_id = env - .call_method(&activity, "getId", "()I", &[]) - .unwrap() - .i() - .unwrap(); - - let webview_id = env - .get_string(&webview_id) - .unwrap() - .to_string_lossy() - .to_string(); - - let is_changing_configurations = env - .call_method(&activity, "isChangingConfigurations", "()Z", &[]) - .unwrap() - .z() - .unwrap(); - - super::MainPipe::send( - activity_id, - super::WebViewMessage::OnDestroy { - activity_id, - webview_id, - is_changing_configurations, - }, - ); -} - -#[allow(non_snake_case)] -pub unsafe fn handleRequest( - mut env: JNIEnv, - _: JClass, - webview_id: JString, - request: JObject, - is_document_start_script_enabled: jboolean, -) -> jobject { - match handle_request( - &mut env, - webview_id, - request, - is_document_start_script_enabled, - ) { - Ok(response) => response, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::warn!("Failed to handle request: {_e}"); - JObject::null().as_raw() - } - } -} - -#[allow(non_snake_case)] -pub unsafe fn shouldOverride( - mut env: JNIEnv, - _: JClass, - webview_id: JString, - url: JString, -) -> jboolean { - match env.get_string(&url) { - Ok(url) => { - let url = url.to_string_lossy().to_string(); - - let Ok(webview_id) = env.get_string(&webview_id) else { - return false.into(); - }; - let webview_id = webview_id.to_str().ok().unwrap_or_default(); - - URL_LOADING_OVERRIDE - .lock() - .unwrap() - .get(webview_id) - // We negate the result of the function because the logic for the android - // client is different from how the navigation_handler is defined. - // - // https://developer.android.com/reference/android/webkit/WebViewClient#shouldOverrideUrlLoading(android.webkit.WebView,%20android.webkit.WebResourceRequest) - .map(|f| !(f.handler)(url)) - .unwrap_or_default() - } - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::warn!("Failed to parse JString: {_e}"); - false - } - } - .into() -} - -#[allow(non_snake_case)] -pub unsafe fn onEval(mut env: JNIEnv, _: JClass, _webview_id: JString, id: jint, result: JString) { - match env.get_string(&result) { - Ok(result) => { - if let Some(cb) = EVAL_CALLBACKS - .get_or_init(Default::default) - .lock() - .unwrap() - .get(&id) - { - cb(result.into()); - } - } - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::warn!("Failed to parse JString: {_e}"); - } - } -} - -pub unsafe fn ipc(mut env: JNIEnv, _: JClass, webview_id: JString, url: JString, body: JString) { - match ( - env.get_string(&url), - env.get_string(&body), - env.get_string(&webview_id), - ) { - (Ok(url), Ok(body), Ok(webview_id)) => { - #[cfg(feature = "tracing")] - let _span = tracing::info_span!(parent: None, "wry::ipc::handle").entered(); - - let url = url.to_string_lossy().to_string(); - let body = body.to_string_lossy().to_string(); - let webview_id = webview_id.to_string_lossy().to_string(); - if let Some(ipc) = IPC.lock().unwrap().get(&webview_id) { - (ipc.handler)(Request::builder().uri(url).body(body).unwrap()) - } - } - (Err(_e), _, _) | (_, Err(_e), _) | (_, _, Err(_e)) => { - #[cfg(feature = "tracing")] - tracing::warn!("Failed to parse JString: {_e}") - } - } -} - -#[allow(non_snake_case)] -pub unsafe fn handleReceivedTitle(mut env: JNIEnv, _: JClass, webview_id: JString, title: JString) { - match (env.get_string(&title), env.get_string(&webview_id)) { - (Ok(title), Ok(webview_id)) => { - let title = title.to_string_lossy().to_string(); - let webview_id = webview_id.to_string_lossy().to_string(); - if let Some(title_handler) = TITLE_CHANGE_HANDLER.lock().unwrap().get(&webview_id) { - (title_handler.handler)(title) - } - } - (Err(_e), _) | (_, Err(_e)) => { - #[cfg(feature = "tracing")] - tracing::warn!("Failed to parse JString: {_e}") - } - } -} - -#[allow(non_snake_case)] -pub unsafe fn withAssetLoader(mut env: JNIEnv, _: JClass, webview_id: JString) -> jboolean { - let Ok(webview_id) = env.get_string(&webview_id) else { - return false.into(); - }; - let webview_id = webview_id.to_str().ok().unwrap_or_default(); - (*WITH_ASSET_LOADER - .lock() - .unwrap() - .get(webview_id) - .unwrap_or(&false)) - .into() -} - -#[allow(non_snake_case)] -pub unsafe fn assetLoaderDomain(mut env: JNIEnv, _: JClass, webview_id: JString) -> jstring { - let Ok(webview_id) = env.get_string(&webview_id) else { - return env.new_string("wry.assets").unwrap().as_raw(); - }; - let webview_id = webview_id.to_str().ok().unwrap_or_default(); - if let Some(domain) = ASSET_LOADER_DOMAIN.lock().unwrap().get(webview_id) { - env.new_string(domain).unwrap().as_raw() - } else { - env.new_string("wry.assets").unwrap().as_raw() - } -} - -#[allow(non_snake_case)] -pub unsafe fn onPageLoading(mut env: JNIEnv, _: JClass, webview_id: JString, url: JString) { - match (env.get_string(&url), env.get_string(&webview_id)) { - (Ok(url), Ok(webview_id)) => { - let url = url.to_string_lossy().to_string(); - let webview_id = webview_id.to_string_lossy().to_string(); - if let Some(on_load) = ON_LOAD_HANDLER.lock().unwrap().get(&webview_id) { - (on_load.handler)(PageLoadEvent::Started, url) - } - } - (Err(_e), _) | (_, Err(_e)) => { - #[cfg(feature = "tracing")] - tracing::warn!("Failed to parse JString: {_e}") - } - } -} - -#[allow(non_snake_case)] -pub unsafe fn onPageLoaded(mut env: JNIEnv, _: JClass, webview_id: JString, url: JString) { - match (env.get_string(&url), env.get_string(&webview_id)) { - (Ok(url), Ok(webview_id)) => { - let url = url.to_string_lossy().to_string(); - let webview_id = webview_id.to_string_lossy().to_string(); - if let Some(on_load) = ON_LOAD_HANDLER.lock().unwrap().get(&webview_id) { - (on_load.handler)(PageLoadEvent::Finished, url) - } - } - (Err(_e), _) | (_, Err(_e)) => { - #[cfg(feature = "tracing")] - tracing::warn!("Failed to parse JString: {_e}") - } - } -} diff --git a/vendor/wry/src/android/kotlin/Ipc.kt b/vendor/wry/src/android/kotlin/Ipc.kt deleted file mode 100644 index c760375..0000000 --- a/vendor/wry/src/android/kotlin/Ipc.kt +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright 2020-2023 Tauri Programme within The Commons Conservancy -// SPDX-License-Identifier: Apache-2.0 -// SPDX-License-Identifier: MIT - -@file:Suppress("unused") - -package {{package}} - -import android.webkit.* - -class Ipc(val webView: RustWebView, val webViewClient: RustWebViewClient) { - @JavascriptInterface - fun postMessage(message: String?) { - message?.let {m -> - // we're not using WebView::getUrl() here because it needs to be executed on the main thread - // and it would slow down the Ipc - // so instead we track the current URL on the webview client - Rust.ipc(webView.id, webViewClient.currentUrl, m) - } - } - - {{class-extension}} -} diff --git a/vendor/wry/src/android/kotlin/Logger.kt b/vendor/wry/src/android/kotlin/Logger.kt deleted file mode 100644 index 3666ad2..0000000 --- a/vendor/wry/src/android/kotlin/Logger.kt +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright 2020-2023 Tauri Programme within The Commons Conservancy -// SPDX-License-Identifier: Apache-2.0 -// SPDX-License-Identifier: MIT - -@file:Suppress("unused", "MemberVisibilityCanBePrivate") - -package {{package}} - -// taken from https://github.com/ionic-team/capacitor/blob/6658bca41e78239347e458175b14ca8bd5c1d6e8/android/capacitor/src/main/java/com/getcapacitor/Logger.java - -import android.text.TextUtils -import android.util.Log - -class Logger { - companion object { - private const val LOG_TAG_CORE = "Tauri" - - fun tags(vararg subtags: String): String { - return if (subtags.isNotEmpty()) { - LOG_TAG_CORE + "/" + TextUtils.join("/", subtags) - } else LOG_TAG_CORE - } - - fun verbose(message: String) { - verbose(LOG_TAG_CORE, message) - } - - private fun verbose(tag: String, message: String) { - if (!shouldLog()) { - return - } - Log.v(tag, message) - } - - fun debug(message: String) { - debug(LOG_TAG_CORE, message) - } - - fun debug(tag: String, message: String) { - if (!shouldLog()) { - return - } - Log.d(tag, message) - } - - fun info(message: String) { - info(LOG_TAG_CORE, message) - } - - fun info(tag: String, message: String) { - if (!shouldLog()) { - return - } - Log.i(tag, message) - } - - fun warn(message: String) { - warn(LOG_TAG_CORE, message) - } - - fun warn(tag: String, message: String) { - if (!shouldLog()) { - return - } - Log.w(tag, message) - } - - fun error(message: String) { - error(LOG_TAG_CORE, message, null) - } - - fun error(message: String, e: Throwable?) { - error(LOG_TAG_CORE, message, e) - } - - fun error(tag: String, message: String, e: Throwable?) { - if (!shouldLog()) { - return - } - Log.e(tag, message, e) - } - - private fun shouldLog(): Boolean { - return BuildConfig.DEBUG - } - } -} diff --git a/vendor/wry/src/android/kotlin/PermissionHelper.kt b/vendor/wry/src/android/kotlin/PermissionHelper.kt deleted file mode 100644 index b2471e5..0000000 --- a/vendor/wry/src/android/kotlin/PermissionHelper.kt +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright 2020-2023 Tauri Programme within The Commons Conservancy -// SPDX-License-Identifier: Apache-2.0 -// SPDX-License-Identifier: MIT - -package {{package}} - -// taken from https://github.com/ionic-team/capacitor/blob/6658bca41e78239347e458175b14ca8bd5c1d6e8/android/capacitor/src/main/java/com/getcapacitor/PermissionHelper.java - -import android.content.Context -import android.content.pm.PackageManager -import android.os.Build -import androidx.core.app.ActivityCompat -import java.util.ArrayList - -object PermissionHelper { - /** - * Checks if a list of given permissions are all granted by the user - * - * @param permissions Permissions to check. - * @return True if all permissions are granted, false if at least one is not. - */ - fun hasPermissions(context: Context?, permissions: Array): Boolean { - for (perm in permissions) { - if (ActivityCompat.checkSelfPermission( - context!!, - perm - ) != PackageManager.PERMISSION_GRANTED - ) { - return false - } - } - return true - } - - /** - * Check whether the given permission has been defined in the AndroidManifest.xml - * - * @param permission A permission to check. - * @return True if the permission has been defined in the Manifest, false if not. - */ - fun hasDefinedPermission(context: Context, permission: String): Boolean { - var hasPermission = false - val requestedPermissions = getManifestPermissions(context) - if (!requestedPermissions.isNullOrEmpty()) { - val requestedPermissionsList = listOf(*requestedPermissions) - val requestedPermissionsArrayList = ArrayList(requestedPermissionsList) - if (requestedPermissionsArrayList.contains(permission)) { - hasPermission = true - } - } - return hasPermission - } - - /** - * Check whether all of the given permissions have been defined in the AndroidManifest.xml - * @param context the app context - * @param permissions a list of permissions - * @return true only if all permissions are defined in the AndroidManifest.xml - */ - fun hasDefinedPermissions(context: Context, permissions: Array): Boolean { - for (permission in permissions) { - if (!hasDefinedPermission(context, permission)) { - return false - } - } - return true - } - - /** - * Get the permissions defined in AndroidManifest.xml - * - * @return The permissions defined in AndroidManifest.xml - */ - private fun getManifestPermissions(context: Context): Array? { - var requestedPermissions: Array? = null - try { - val pm = context.packageManager - val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - pm.getPackageInfo(context.packageName, PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS.toLong())) - } else { - @Suppress("DEPRECATION") - pm.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS) - } - if (packageInfo != null) { - requestedPermissions = packageInfo.requestedPermissions - } - } catch (_: Exception) { - } - return requestedPermissions - } - - /** - * Given a list of permissions, return a new list with the ones not present in AndroidManifest.xml - * - * @param neededPermissions The permissions needed. - * @return The permissions not present in AndroidManifest.xml - */ - fun getUndefinedPermissions(context: Context, neededPermissions: Array): Array { - val undefinedPermissions = ArrayList() - val requestedPermissions = getManifestPermissions(context) - if (!requestedPermissions.isNullOrEmpty()) { - val requestedPermissionsList = listOf(*requestedPermissions) - val requestedPermissionsArrayList = ArrayList(requestedPermissionsList) - for (permission in neededPermissions) { - if (!requestedPermissionsArrayList.contains(permission)) { - undefinedPermissions.add(permission) - } - } - var undefinedPermissionArray = arrayOfNulls(undefinedPermissions.size) - undefinedPermissionArray = undefinedPermissions.toArray(undefinedPermissionArray) - return undefinedPermissionArray - } - return neededPermissions - } -} diff --git a/vendor/wry/src/android/kotlin/Rust.kt b/vendor/wry/src/android/kotlin/Rust.kt deleted file mode 100644 index b5b6bdf..0000000 --- a/vendor/wry/src/android/kotlin/Rust.kt +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright 2020-2023 Tauri Programme within The Commons Conservancy -// SPDX-License-Identifier: Apache-2.0 -// SPDX-License-Identifier: MIT - -@file:Suppress("unused") - -package {{package}} - -import android.content.Intent -import android.webkit.WebView -import android.webkit.WebResourceRequest -import android.webkit.WebResourceResponse - -object Rust { - init { - System.loadLibrary("{{library}}") - } - - @JvmStatic external fun onActivityCreate(activity: WryActivity) - @JvmStatic external fun onActivityDestroy(activity: WryActivity) - @JvmStatic external fun onActivitySaveInstanceState() - @JvmStatic external fun onActivityLowMemory() - @JvmStatic external fun onWindowFocusChanged(activity: WryActivity, focus: Boolean) - @JvmStatic external fun onNewIntent(intent: Intent) - - @JvmStatic external fun create() - @JvmStatic external fun start() - @JvmStatic external fun resume() - @JvmStatic external fun pause() - @JvmStatic external fun stop() - - @JvmStatic external fun wryCreate() - @JvmStatic external fun onWebviewDestroy(activity: WryActivity, webviewId: String) - - @JvmStatic external fun ipc(webviewId: String, url: String, message: String) - - @JvmStatic external fun assetLoaderDomain(webviewId: String): String - @JvmStatic external fun withAssetLoader(webviewId: String): Boolean - @JvmStatic external fun handleRequest(webviewId: String, request: WebResourceRequest, isDocumentStartScriptEnabled: Boolean): WebResourceResponse? - @JvmStatic external fun shouldOverride(webviewId: String, url: String): Boolean - @JvmStatic external fun onPageLoading(webviewId: String, url: String) - @JvmStatic external fun onPageLoaded(webviewId: String, url: String) - @JvmStatic external fun onEval(webviewId: String, id: Int, result: String) - - @JvmStatic external fun handleReceivedTitle(webviewId: String, title: String) -} \ No newline at end of file diff --git a/vendor/wry/src/android/kotlin/RustWebChromeClient.kt b/vendor/wry/src/android/kotlin/RustWebChromeClient.kt deleted file mode 100644 index bba26db..0000000 --- a/vendor/wry/src/android/kotlin/RustWebChromeClient.kt +++ /dev/null @@ -1,491 +0,0 @@ -// Copyright 2020-2023 Tauri Programme within The Commons Conservancy -// SPDX-License-Identifier: Apache-2.0 -// SPDX-License-Identifier: MIT - -@file:Suppress("ObsoleteSdkInt", "RedundantOverride", "QueryPermissionsNeeded", "SimpleDateFormat") - -package {{package}} - -// taken from https://github.com/ionic-team/capacitor/blob/6658bca41e78239347e458175b14ca8bd5c1d6e8/android/capacitor/src/main/java/com/getcapacitor/BridgeWebChromeClient.java - -import android.Manifest -import android.app.Activity -import android.app.AlertDialog -import android.content.ActivityNotFoundException -import android.content.DialogInterface -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.os.Environment -import android.provider.MediaStore -import android.view.View -import android.webkit.* -import android.widget.EditText -import androidx.activity.result.ActivityResult -import androidx.activity.result.ActivityResultCallback -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.content.FileProvider -import java.io.File -import java.io.IOException -import java.text.SimpleDateFormat -import java.util.* - -class RustWebChromeClient(appActivity: WryActivity) : WebChromeClient() { - private interface PermissionListener { - fun onPermissionSelect(isGranted: Boolean?) - } - - private interface ActivityResultListener { - fun onActivityResult(result: ActivityResult?) - } - - private val activity: WryActivity - private var permissionLauncher: ActivityResultLauncher> - private var activityLauncher: ActivityResultLauncher - private var permissionListener: PermissionListener? = null - private var activityListener: ActivityResultListener? = null - - init { - activity = appActivity - val permissionCallback = - ActivityResultCallback { isGranted: Map -> - if (permissionListener != null) { - var granted = true - for ((_, value) in isGranted) { - if (!value) granted = false - } - permissionListener!!.onPermissionSelect(granted) - } - } - permissionLauncher = - activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions(), permissionCallback) - activityLauncher = activity.registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { result -> - if (activityListener != null) { - activityListener!!.onActivityResult(result) - } - } - } - - /** - * Render web content in `view`. - * - * Both this method and [.onHideCustomView] are required for - * rendering web content in full screen. - * - * @see [](https://developer.android.com/reference/android/webkit/WebChromeClient.onShowCustomView - ) */ - override fun onShowCustomView(view: View, callback: CustomViewCallback) { - callback.onCustomViewHidden() - super.onShowCustomView(view, callback) - } - - /** - * Render web content in the original Web View again. - * - * Do not remove this method--@see #onShowCustomView(View, CustomViewCallback). - */ - override fun onHideCustomView() { - super.onHideCustomView() - } - - override fun onPermissionRequest(request: PermissionRequest) { - val isRequestPermissionRequired = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M - val permissionList: MutableList = ArrayList() - if (listOf(*request.resources).contains("android.webkit.resource.VIDEO_CAPTURE")) { - permissionList.add(Manifest.permission.CAMERA) - } - if (listOf(*request.resources).contains("android.webkit.resource.AUDIO_CAPTURE")) { - permissionList.add(Manifest.permission.MODIFY_AUDIO_SETTINGS) - permissionList.add(Manifest.permission.RECORD_AUDIO) - } - if (permissionList.isNotEmpty() && isRequestPermissionRequired) { - val permissions = permissionList.toTypedArray() - permissionListener = object : PermissionListener { - override fun onPermissionSelect(isGranted: Boolean?) { - if (isGranted == true) { - request.grant(request.resources) - } else { - request.deny() - } - } - } - permissionLauncher.launch(permissions) - } else { - request.grant(request.resources) - } - } - - /** - * Show the browser alert modal - * @param view - * @param url - * @param message - * @param result - * @return - */ - override fun onJsAlert(view: WebView, url: String, message: String, result: JsResult): Boolean { - if (activity.isFinishing) { - return true - } - val builder = AlertDialog.Builder(view.context) - builder - .setMessage(message) - .setPositiveButton( - "OK" - ) { dialog: DialogInterface, _: Int -> - dialog.dismiss() - result.confirm() - } - .setOnCancelListener { dialog: DialogInterface -> - dialog.dismiss() - result.cancel() - } - val dialog = builder.create() - dialog.show() - return true - } - - /** - * Show the browser confirm modal - * @param view - * @param url - * @param message - * @param result - * @return - */ - override fun onJsConfirm(view: WebView, url: String, message: String, result: JsResult): Boolean { - if (activity.isFinishing) { - return true - } - val builder = AlertDialog.Builder(view.context) - builder - .setMessage(message) - .setPositiveButton( - "OK" - ) { dialog: DialogInterface, _: Int -> - dialog.dismiss() - result.confirm() - } - .setNegativeButton( - "Cancel" - ) { dialog: DialogInterface, _: Int -> - dialog.dismiss() - result.cancel() - } - .setOnCancelListener { dialog: DialogInterface -> - dialog.dismiss() - result.cancel() - } - val dialog = builder.create() - dialog.show() - return true - } - - /** - * Show the browser prompt modal - * @param view - * @param url - * @param message - * @param defaultValue - * @param result - * @return - */ - override fun onJsPrompt( - view: WebView, - url: String, - message: String, - defaultValue: String, - result: JsPromptResult - ): Boolean { - if (activity.isFinishing) { - return true - } - val builder = AlertDialog.Builder(view.context) - val input = EditText(view.context) - builder - .setMessage(message) - .setView(input) - .setPositiveButton( - "OK" - ) { dialog: DialogInterface, _: Int -> - dialog.dismiss() - val inputText1 = input.text.toString().trim { it <= ' ' } - result.confirm(inputText1) - } - .setNegativeButton( - "Cancel" - ) { dialog: DialogInterface, _: Int -> - dialog.dismiss() - result.cancel() - } - .setOnCancelListener { dialog: DialogInterface -> - dialog.dismiss() - result.cancel() - } - val dialog = builder.create() - dialog.show() - return true - } - - /** - * Handle the browser geolocation permission prompt - * @param origin - * @param callback - */ - override fun onGeolocationPermissionsShowPrompt( - origin: String, - callback: GeolocationPermissions.Callback - ) { - super.onGeolocationPermissionsShowPrompt(origin, callback) - Logger.debug("onGeolocationPermissionsShowPrompt: DOING IT HERE FOR ORIGIN: $origin") - val geoPermissions = - arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION) - if (!PermissionHelper.hasPermissions(activity, geoPermissions)) { - permissionListener = object : PermissionListener { - override fun onPermissionSelect(isGranted: Boolean?) { - if (isGranted == true) { - callback.invoke(origin, true, false) - } else { - val coarsePermission = - arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && - PermissionHelper.hasPermissions(activity, coarsePermission) - ) { - callback.invoke(origin, true, false) - } else { - callback.invoke(origin, false, false) - } - } - } - } - permissionLauncher.launch(geoPermissions) - } else { - // permission is already granted - callback.invoke(origin, true, false) - Logger.debug("onGeolocationPermissionsShowPrompt: has required permission") - } - } - - override fun onShowFileChooser( - webView: WebView, - filePathCallback: ValueCallback?>, - fileChooserParams: FileChooserParams - ): Boolean { - val acceptTypes = listOf(*fileChooserParams.acceptTypes) - val captureEnabled = fileChooserParams.isCaptureEnabled - val capturePhoto = captureEnabled && acceptTypes.contains("image/*") - val captureVideo = captureEnabled && acceptTypes.contains("video/*") - if (capturePhoto || captureVideo) { - if (isMediaCaptureSupported) { - showMediaCaptureOrFilePicker(filePathCallback, fileChooserParams, captureVideo) - } else { - permissionListener = object : PermissionListener { - override fun onPermissionSelect(isGranted: Boolean?) { - if (isGranted == true) { - showMediaCaptureOrFilePicker(filePathCallback, fileChooserParams, captureVideo) - } else { - Logger.warn(Logger.tags("FileChooser"), "Camera permission not granted") - filePathCallback.onReceiveValue(null) - } - } - } - val camPermission = arrayOf(Manifest.permission.CAMERA) - permissionLauncher.launch(camPermission) - } - } else { - showFilePicker(filePathCallback, fileChooserParams) - } - return true - } - - private val isMediaCaptureSupported: Boolean - get() { - val permissions = arrayOf(Manifest.permission.CAMERA) - return PermissionHelper.hasPermissions(activity, permissions) || - !PermissionHelper.hasDefinedPermission(activity, Manifest.permission.CAMERA) - } - - private fun showMediaCaptureOrFilePicker( - filePathCallback: ValueCallback?>, - fileChooserParams: FileChooserParams, - isVideo: Boolean - ) { - val isVideoCaptureSupported = true - val shown = if (isVideo && isVideoCaptureSupported) { - showVideoCapturePicker(filePathCallback) - } else { - showImageCapturePicker(filePathCallback) - } - if (!shown) { - Logger.warn( - Logger.tags("FileChooser"), - "Media capture intent could not be launched. Falling back to default file picker." - ) - showFilePicker(filePathCallback, fileChooserParams) - } - } - - private fun showImageCapturePicker(filePathCallback: ValueCallback?>): Boolean { - val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) - if (takePictureIntent.resolveActivity(activity.packageManager) == null) { - return false - } - val imageFileUri: Uri = try { - createImageFileUri() - } catch (ex: Exception) { - Logger.error("Unable to create temporary media capture file: " + ex.message) - return false - } - takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, imageFileUri) - activityListener = object : ActivityResultListener { - override fun onActivityResult(result: ActivityResult?) { - var res: Array? = null - if (result?.resultCode == Activity.RESULT_OK) { - res = arrayOf(imageFileUri) - } - filePathCallback.onReceiveValue(res) - } - } - activityLauncher.launch(takePictureIntent) - return true - } - - private fun showVideoCapturePicker(filePathCallback: ValueCallback?>): Boolean { - val takeVideoIntent = Intent(MediaStore.ACTION_VIDEO_CAPTURE) - if (takeVideoIntent.resolveActivity(activity.packageManager) == null) { - return false - } - activityListener = object : ActivityResultListener { - override fun onActivityResult(result: ActivityResult?) { - var res: Array? = null - if (result?.resultCode == Activity.RESULT_OK) { - res = arrayOf(result.data!!.data) - } - filePathCallback.onReceiveValue(res) - } - } - activityLauncher.launch(takeVideoIntent) - return true - } - - private fun showFilePicker( - filePathCallback: ValueCallback?>, - fileChooserParams: FileChooserParams - ) { - val intent = fileChooserParams.createIntent() - if (fileChooserParams.mode == FileChooserParams.MODE_OPEN_MULTIPLE) { - intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) - } - if (fileChooserParams.acceptTypes.size > 1 || intent.type!!.startsWith(".")) { - val validTypes = getValidTypes(fileChooserParams.acceptTypes) - intent.putExtra(Intent.EXTRA_MIME_TYPES, validTypes) - if (intent.type!!.startsWith(".")) { - intent.type = validTypes[0] - } - } - try { - activityListener = object : ActivityResultListener { - override fun onActivityResult(result: ActivityResult?) { - val res: Array? - val resultIntent = result?.data - if (result?.resultCode == Activity.RESULT_OK && resultIntent!!.clipData != null) { - val numFiles = resultIntent.clipData!!.itemCount - res = arrayOfNulls(numFiles) - for (i in 0 until numFiles) { - res[i] = resultIntent.clipData!!.getItemAt(i).uri - } - } else { - res = FileChooserParams.parseResult( - result?.resultCode ?: 0, - resultIntent - ) - } - filePathCallback.onReceiveValue(res) - } - } - activityLauncher.launch(intent) - } catch (e: ActivityNotFoundException) { - filePathCallback.onReceiveValue(null) - } - } - - private fun getValidTypes(currentTypes: Array): Array { - val validTypes: MutableList = ArrayList() - val mtm = MimeTypeMap.getSingleton() - for (mime in currentTypes) { - if (mime.startsWith(".")) { - val extension = mime.substring(1) - val extensionMime = mtm.getMimeTypeFromExtension(extension) - if (extensionMime != null && !validTypes.contains(extensionMime)) { - validTypes.add(extensionMime) - } - } else if (!validTypes.contains(mime)) { - validTypes.add(mime) - } - } - val validObj: Array = validTypes.toTypedArray() - return Arrays.copyOf( - validObj, validObj.size, - Array::class.java - ) - } - - override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { - val tag: String = Logger.tags("Console") - if (consoleMessage.message() != null && isValidMsg(consoleMessage.message())) { - val msg = String.format( - "File: %s - Line %d - Msg: %s", - consoleMessage.sourceId(), - consoleMessage.lineNumber(), - consoleMessage.message() - ) - val level = consoleMessage.messageLevel().name - if ("ERROR".equals(level, ignoreCase = true)) { - Logger.error(tag, msg, null) - } else if ("WARNING".equals(level, ignoreCase = true)) { - Logger.warn(tag, msg) - } else if ("TIP".equals(level, ignoreCase = true)) { - Logger.debug(tag, msg) - } else { - Logger.info(tag, msg) - } - } - return true - } - - private fun isValidMsg(msg: String): Boolean { - return !(msg.contains("%cresult %c") || - msg.contains("%cnative %c") || - msg.equals("[object Object]", ignoreCase = true) || - msg.equals("console.groupEnd", ignoreCase = true)) - } - - @Throws(IOException::class) - private fun createImageFileUri(): Uri { - val photoFile = createImageFile(activity) - return FileProvider.getUriForFile( - activity, - activity.packageName.toString() + ".fileprovider", - photoFile - ) - } - - @Throws(IOException::class) - private fun createImageFile(activity: Activity): File { - // Create an image file name - val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss").format(Date()) - val imageFileName = "JPEG_" + timeStamp + "_" - val storageDir = activity.getExternalFilesDir(Environment.DIRECTORY_PICTURES) - return File.createTempFile(imageFileName, ".jpg", storageDir) - } - - override fun onReceivedTitle( - view: WebView, - title: String - ) { - Rust.handleReceivedTitle((view as RustWebView).id, title) - } -} diff --git a/vendor/wry/src/android/kotlin/RustWebView.kt b/vendor/wry/src/android/kotlin/RustWebView.kt deleted file mode 100644 index 31356c3..0000000 --- a/vendor/wry/src/android/kotlin/RustWebView.kt +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright 2020-2023 Tauri Programme within The Commons Conservancy -// SPDX-License-Identifier: Apache-2.0 -// SPDX-License-Identifier: MIT - -@file:Suppress("unused", "SetJavaScriptEnabled") - -package {{package}} - -import android.annotation.SuppressLint -import android.webkit.* -import android.content.Context -import androidx.webkit.WebViewCompat -import androidx.webkit.WebViewFeature -import kotlin.collections.Map - -@SuppressLint("RestrictedApi") -class RustWebView(context: Context, val initScripts: Array, val id: String): WebView(context) { - val isDocumentStartScriptEnabled: Boolean - - init { - settings.javaScriptEnabled = true - settings.domStorageEnabled = true - settings.setGeolocationEnabled(true) - settings.databaseEnabled = true - settings.mediaPlaybackRequiresUserGesture = false - settings.javaScriptCanOpenWindowsAutomatically = true - - if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) { - isDocumentStartScriptEnabled = true - for (script in initScripts) { - WebViewCompat.addDocumentStartJavaScript(this, script, setOf("*")); - } - } else { - isDocumentStartScriptEnabled = false - } - - {{class-init}} - } - - fun loadUrlMainThread(url: String) { - post { - loadUrl(url) - } - } - - fun loadUrlMainThread(url: String, additionalHttpHeaders: Map) { - post { - loadUrl(url, additionalHttpHeaders) - } - } - - override fun loadUrl(url: String) { - if (!Rust.shouldOverride(id, url)) { - super.loadUrl(url); - } - } - - override fun loadUrl(url: String, additionalHttpHeaders: Map) { - if (!Rust.shouldOverride(id, url)) { - super.loadUrl(url, additionalHttpHeaders); - } - } - - fun loadHTMLMainThread(html: String) { - post { - super.loadData(html, "text/html", null) - } - } - - fun evalScript(id: Int, script: String) { - post { - super.evaluateJavascript(script) { result -> - Rust.onEval(this.id, id, result) - } - } - } - - fun clearAllBrowsingData() { - try { - super.getContext().deleteDatabase("webviewCache.db") - super.getContext().deleteDatabase("webview.db") - super.clearCache(true) - super.clearHistory() - super.clearFormData() - } catch (ex: Exception) { - Logger.error("Unable to create temporary media capture file: " + ex.message) - } - } - - fun getCookies(url: String): String { - val cookieManager = CookieManager.getInstance() - return cookieManager.getCookie(url) - } - - {{class-extension}} -} diff --git a/vendor/wry/src/android/kotlin/RustWebViewClient.kt b/vendor/wry/src/android/kotlin/RustWebViewClient.kt deleted file mode 100644 index 3dae460..0000000 --- a/vendor/wry/src/android/kotlin/RustWebViewClient.kt +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright 2020-2023 Tauri Programme within The Commons Conservancy -// SPDX-License-Identifier: Apache-2.0 -// SPDX-License-Identifier: MIT - -package {{package}} - -import android.net.Uri -import android.webkit.* -import android.content.Context -import android.graphics.Bitmap -import android.os.Handler -import android.os.Looper -import androidx.webkit.WebViewAssetLoader - -class RustWebViewClient(webView: RustWebView, context: Context): WebViewClient() { - private val interceptedState = mutableMapOf() - var currentUrl: String = "about:blank" - private var lastInterceptedUrl: Uri? = null - private var pendingUrlRedirect: String? = null - - private val assetLoader = WebViewAssetLoader.Builder() - .setDomain(Rust.assetLoaderDomain(webView.id)) - .addPathHandler("/", WebViewAssetLoader.AssetsPathHandler(context)) - .build() - - override fun shouldInterceptRequest( - view: WebView, - request: WebResourceRequest - ): WebResourceResponse? { - pendingUrlRedirect?.let { - Handler(Looper.getMainLooper()).post { - view.loadUrl(it) - } - pendingUrlRedirect = null - return null - } - - lastInterceptedUrl = request.url - return if (Rust.withAssetLoader((view as RustWebView).id)) { - assetLoader.shouldInterceptRequest(request.url) - } else { - val response = Rust.handleRequest(view.id, request, view.isDocumentStartScriptEnabled) - if (response != null) { - if (response.responseHeaders != null) { - response.responseHeaders["Cache-Control"] = "no-store" - } else { - response.responseHeaders = mapOf("Cache-Control" to "no-store") - } - } - interceptedState[request.url.toString()] = response != null - return response - } - } - - override fun shouldOverrideUrlLoading( - view: WebView, - request: WebResourceRequest - ): Boolean { - return Rust.shouldOverride((view as RustWebView).id, request.url.toString()) - } - - override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) { - currentUrl = url - if (interceptedState[url] == false) { - val webView = view as RustWebView - for (script in webView.initScripts) { - view.evaluateJavascript(script, null) - } - } - return Rust.onPageLoading((view as RustWebView).id, url) - } - - override fun onPageFinished(view: WebView, url: String) { - Rust.onPageLoaded((view as RustWebView).id, url) - } - - override fun onReceivedError( - view: WebView, - request: WebResourceRequest, - error: WebResourceError - ) { - // we get a net::ERR_CONNECTION_REFUSED when an external URL redirects to a custom protocol - // e.g. oauth flow, because shouldInterceptRequest is not called on redirects - // so we must force retry here with loadUrl() to get a chance of the custom protocol to kick in - if (error.errorCode == ERROR_CONNECT && request.isForMainFrame && request.url != lastInterceptedUrl) { - // prevent the default error page from showing - view.stopLoading() - // without this initial loadUrl the app is stuck - view.loadUrl(request.url.toString()) - // ensure the URL is actually loaded - for some reason there's a race condition and we need to call loadUrl() again later - pendingUrlRedirect = request.url.toString() - } else { - super.onReceivedError(view, request, error) - } - } - - {{class-extension}} -} diff --git a/vendor/wry/src/android/kotlin/WryActivity.kt b/vendor/wry/src/android/kotlin/WryActivity.kt deleted file mode 100644 index 7152e35..0000000 --- a/vendor/wry/src/android/kotlin/WryActivity.kt +++ /dev/null @@ -1,173 +0,0 @@ -// Copyright 2020-2023 Tauri Programme within The Commons Conservancy -// SPDX-License-Identifier: Apache-2.0 -// SPDX-License-Identifier: MIT - -package {{package}} - -import android.annotation.SuppressLint -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.webkit.WebView -import android.view.KeyEvent -import androidx.activity.OnBackPressedCallback -import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.ProcessLifecycleOwner - -private val ACTIVITY_ID_KEY = "__wryActivityId" - -object WryLifecycleObserver : DefaultLifecycleObserver { - override fun onCreate(owner: LifecycleOwner) { - super.onCreate(owner) - Rust.create() - Rust.wryCreate() - } - - override fun onStart(owner: LifecycleOwner) { - super.onStart(owner) - Rust.start() - } - - override fun onResume(owner: LifecycleOwner) { - super.onResume(owner) - Rust.resume() - } - - override fun onPause(owner: LifecycleOwner) { - super.onPause(owner) - Rust.pause() - } - - override fun onStop(owner: LifecycleOwner) { - super.onStop(owner) - Rust.stop() - } -} - -abstract class WryActivity : AppCompatActivity() { - private lateinit var mWebView: RustWebView - var id: Int = 0 - open val handleBackNavigation: Boolean = true - - open fun onWebViewCreate(webView: WebView) { } - - fun setWebView(webView: RustWebView) { - mWebView = webView - - if (handleBackNavigation) { - val callback = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - if (this@WryActivity::mWebView.isInitialized) { - if (this@WryActivity.mWebView.canGoBack()) { - this@WryActivity.mWebView.goBack() - } else { - this.isEnabled = false - this@WryActivity.onBackPressed() - this.isEnabled = true - } - } - } - } - onBackPressedDispatcher.addCallback(this, callback) - } - - onWebViewCreate(webView) - } - - val version: String - @SuppressLint("WebViewApiAvailability", "ObsoleteSdkInt") - get() { - // Check getCurrentWebViewPackage() directly if above Android 8 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - return WebView.getCurrentWebViewPackage()?.versionName ?: "" - } - - // Otherwise manually check WebView versions - var webViewPackage = "com.google.android.webview" - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - webViewPackage = "com.android.chrome" - } - try { - @Suppress("DEPRECATION") - val info = packageManager.getPackageInfo(webViewPackage, 0) - return info.versionName.toString() - } catch (ex: Exception) { - Logger.warn("Unable to get package info for '$webViewPackage'$ex") - } - - try { - @Suppress("DEPRECATION") - val info = packageManager.getPackageInfo("com.android.webview", 0) - return info.versionName.toString() - } catch (ex: Exception) { - Logger.warn("Unable to get package info for 'com.android.webview'$ex") - } - - // Could not detect any webview, return empty string - return "" - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - id = savedInstanceState?.getInt(ACTIVITY_ID_KEY) ?: intent.extras?.getInt(ACTIVITY_ID_KEY) ?: hashCode() - ProcessLifecycleOwner.get().lifecycle.addObserver(WryLifecycleObserver) - Rust.onActivityCreate(this) - } - - override fun onWindowFocusChanged(hasFocus: Boolean) { - super.onWindowFocusChanged(hasFocus) - Rust.onWindowFocusChanged(this, hasFocus) - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putInt(ACTIVITY_ID_KEY, id) - Rust.onActivitySaveInstanceState() - } - - override fun onPause() { - super.onPause() - if (::mWebView.isInitialized) { - mWebView.onPause() - } - } - - override fun onResume() { - super.onResume() - if (::mWebView.isInitialized) { - mWebView.onResume() - } - } - - override fun onDestroy() { - super.onDestroy() - Rust.onActivityDestroy(this) - Rust.onWebviewDestroy(this, if (::mWebView.isInitialized) { mWebView.id } else { "" }) - } - - override fun onLowMemory() { - super.onLowMemory() - Rust.onActivityLowMemory() - } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - Rust.onNewIntent(intent) - } - - fun getAppClass(name: String): Class<*> { - return Class.forName(name) - } - - fun startActivity(cls: Class<*>): Int { - val intent = Intent(this, cls) - val id = kotlin.random.Random.nextInt() - intent.putExtra(ACTIVITY_ID_KEY, id) - startActivity(intent) - return id - } - - {{class-extension}} -} diff --git a/vendor/wry/src/android/kotlin/proguard-wry.pro b/vendor/wry/src/android/kotlin/proguard-wry.pro deleted file mode 100644 index a0f19c6..0000000 --- a/vendor/wry/src/android/kotlin/proguard-wry.pro +++ /dev/null @@ -1,35 +0,0 @@ -# Copyright 2020-2023 Tauri Programme within The Commons Conservancy -# SPDX-License-Identifier: Apache-2.0 -# SPDX-License-Identifier: MIT - --keep class {{package-unescaped}}.* { - native ; -} - --keep class {{package-unescaped}}.WryActivity { - public (...); - - void setWebView({{package-unescaped}}.RustWebView); - java.lang.Class getAppClass(...); - int getId(); - java.lang.String getVersion(); - int startActivity(...); -} - --keep class {{package-unescaped}}.Ipc { - public (...); - - @android.webkit.JavascriptInterface public ; -} - --keep class {{package-unescaped}}.RustWebView { - public (...); - - void loadUrlMainThread(...); - void loadHTMLMainThread(...); - void evalScript(...); -} - --keep class {{package-unescaped}}.RustWebChromeClient,{{package-unescaped}}.RustWebViewClient { - public (...); -} diff --git a/vendor/wry/src/android/main_pipe.rs b/vendor/wry/src/android/main_pipe.rs deleted file mode 100644 index 52308fd..0000000 --- a/vendor/wry/src/android/main_pipe.rs +++ /dev/null @@ -1,607 +0,0 @@ -// Copyright 2020-2023 Tauri Programme within The Commons Conservancy -// SPDX-License-Identifier: Apache-2.0 -// SPDX-License-Identifier: MIT - -use crate::{Error, InitializationScript, RGBA}; -use crossbeam_channel::*; -use jni::{ - errors::Result as JniResult, - objects::{GlobalRef, JMap, JObject, JString}, - JNIEnv, JavaVM, -}; -use once_cell::sync::Lazy; -use std::{ - collections::BTreeMap, - ffi::c_void, - os::unix::prelude::*, - sync::{Arc, Mutex}, -}; - -use super::{find_class, EvalCallback, WebviewId, EVAL_CALLBACKS, EVAL_ID_GENERATOR, PACKAGE}; - -pub type ActivityId = i32; - -static CHANNEL: Lazy<( - Sender<(ActivityId, WebViewMessage)>, - Receiver<(ActivityId, WebViewMessage)>, -)> = Lazy::new(|| bounded(8)); -pub static MAIN_PIPE: Lazy<[OwnedFd; 2]> = Lazy::new(|| { - let mut pipe: [RawFd; 2] = Default::default(); - unsafe { libc::pipe(pipe.as_mut_ptr()) }; - unsafe { pipe.map(|fd| OwnedFd::from_raw_fd(fd)) } -}); - -#[derive(Clone)] -pub struct ActivityProxy { - pub activity: GlobalRef, - pub window_manager: GlobalRef, - pub webview: Option, - pub webchrome_client: GlobalRef, - pub java_vm: *mut c_void, -} - -unsafe impl Send for ActivityProxy {} - -impl ActivityProxy { - pub fn new( - vm: JavaVM, - activity: GlobalRef, - window_manager: GlobalRef, - webchrome_client: GlobalRef, - ) -> Self { - Self { - activity, - window_manager, - webview: None, - webchrome_client, - java_vm: vm.get_java_vm_pointer() as *mut _, - } - } -} - -static ACTIVITY_PROXY: once_cell::sync::Lazy>> = - Lazy::new(|| Mutex::new(BTreeMap::new())); - -pub fn activity_proxy(id: ActivityId) -> Option { - ACTIVITY_PROXY.lock().unwrap().get(&id).cloned() -} - -fn remove_activity_proxy(id: ActivityId) { - ACTIVITY_PROXY.lock().unwrap().remove(&id); -} - -pub fn register_activity_proxy( - vm: JavaVM, - id: ActivityId, - activity: GlobalRef, - window_manager: GlobalRef, - webchrome_client: GlobalRef, -) { - let mut activity_proxy = ACTIVITY_PROXY.lock().unwrap(); - if let Some(proxy) = activity_proxy.get_mut(&id) { - proxy.activity = activity; - proxy.window_manager = window_manager; - proxy.webchrome_client = webchrome_client; - proxy.java_vm = vm.get_java_vm_pointer() as *mut _; - } else { - let proxy = ActivityProxy::new(vm, activity, window_manager, webchrome_client); - activity_proxy.insert(id, proxy.clone()); - } -} - -pub fn activity_id_for_window_manager(window_manager: JObject) -> Option { - for (activity_id, proxy) in ACTIVITY_PROXY.lock().unwrap().iter() { - let vm = unsafe { JavaVM::from_raw(proxy.java_vm.cast()) }.unwrap(); - let mut env = vm.attach_current_thread_as_daemon().unwrap(); - let equals = env - .call_method( - proxy.window_manager.as_obj(), - "equals", - "(Ljava/lang/Object;)Z", - &[(&window_manager).into()], - ) - .and_then(|v| v.z()) - .unwrap_or_default(); - if equals { - return Some(*activity_id); - } - } - None -} - -pub fn first_activity_id() -> Option { - ACTIVITY_PROXY.lock().unwrap().keys().next().cloned() -} - -pub fn get_webview(activity_id: ActivityId) -> Option { - ACTIVITY_PROXY - .lock() - .unwrap() - .get(&activity_id) - .unwrap() - .webview - .as_ref() - .cloned() -} - -pub struct MainPipe<'a> { - pub env: JNIEnv<'a>, -} - -impl<'a> MainPipe<'a> { - pub(crate) fn send(activity_id: ActivityId, message: WebViewMessage) { - let size = std::mem::size_of::(); - if CHANNEL.0.send((activity_id, message)).is_ok() { - unsafe { - libc::write( - MAIN_PIPE[1].as_raw_fd(), - &true as *const _ as *const _, - size, - ) - }; - } - } - - pub fn recv(&mut self) -> JniResult<()> { - if let Ok((activity_id, message)) = CHANNEL.1.recv() { - match message { - WebViewMessage::CreateWebView(attrs) => { - let Some((activity, web_chrome_client)) = - activity_proxy(activity_id).map(|p| (p.activity.clone(), p.webchrome_client.clone())) - else { - #[cfg(debug_assertions)] - eprintln!("no activity found for activity id: {}", activity_id); - return Ok(()); - }; - let CreateWebViewAttributes { - url, - html, - #[cfg(any(debug_assertions, feature = "devtools"))] - devtools, - transparent, - background_color, - headers, - on_webview_created, - autoplay, - user_agent, - initialization_scripts, - id, - javascript_disabled, - .. - } = attrs; - - let string_class = self.env.find_class("java/lang/String")?; - let initialization_scripts_array = self.env.new_object_array( - initialization_scripts.len() as i32, - string_class, - self.env.new_string("")?, - )?; - for (i, init_script) in initialization_scripts.into_iter().enumerate() { - self.env.set_object_array_element( - &initialization_scripts_array, - i as i32, - self.env.new_string(init_script.script)?, - )?; - } - let id = self.env.new_string(id)?; - // Create webview - let rust_webview_class = find_class( - &mut self.env, - &activity, - format!("{}/RustWebView", PACKAGE.get().unwrap()), - )?; - let webview = self.env.new_object( - &rust_webview_class, - "(Landroid/content/Context;[Ljava/lang/String;Ljava/lang/String;)V", - &[ - (&activity).into(), - (&initialization_scripts_array).into(), - (&id).into(), - ], - )?; - // get settings - let web_settings = self - .env - .call_method( - &webview, - "getSettings", - "()Landroid/webkit/WebSettings;", - &[], - )? - .l()?; - // set media autoplay - self.env.call_method( - &web_settings, - "setMediaPlaybackRequiresUserGesture", - "(Z)V", - &[(!autoplay).into()], - )?; - // set user-agent - if let Some(user_agent) = user_agent { - let user_agent = self.env.new_string(user_agent)?; - self.env.call_method( - &web_settings, - "setUserAgentString", - "(Ljava/lang/String;)V", - &[(&user_agent).into()], - )?; - } - - // disable javascript - if javascript_disabled { - self.env.call_method( - &web_settings, - "setJavaScriptEnabled", - "(Z)V", - &[false.into()], - )?; - } - - let webview_class_name = format!("{}/RustWebView", PACKAGE.get().unwrap()); - self.env.call_method( - &activity, - "setWebView", - format!("(L{webview_class_name};)V"), - &[(&webview).into()], - )?; - // Navigation - if let Some(u) = url { - if let Ok(url) = self.env.new_string(u) { - load_url(&mut self.env, &webview, &url, headers, true)?; - } - } else if let Some(h) = html { - if let Ok(html) = self.env.new_string(h) { - load_html(&mut self.env, &webview, &html)?; - } - } - // Enable devtools - #[cfg(any(debug_assertions, feature = "devtools"))] - self.env.call_static_method( - &rust_webview_class, - "setWebContentsDebuggingEnabled", - "(Z)V", - &[devtools.into()], - )?; - if transparent { - set_background_color(&mut self.env, &webview, (0, 0, 0, 0))?; - } else if let Some(color) = background_color { - set_background_color(&mut self.env, &webview, color)?; - } - // Create and set webview client - let client_class_name = format!("{}/RustWebViewClient", PACKAGE.get().unwrap()); - let rust_webview_client_class = - find_class(&mut self.env, &activity, client_class_name.clone())?; - let webview_client = self.env.new_object( - &rust_webview_client_class, - format!("(L{webview_class_name};Landroid/content/Context;)V"), - &[(&webview).into(), (&activity).into()], - )?; - self.env.call_method( - &webview, - "setWebViewClient", - "(Landroid/webkit/WebViewClient;)V", - &[(&webview_client).into()], - )?; - // set webchrome client - self.env.call_method( - &webview, - "setWebChromeClient", - "(Landroid/webkit/WebChromeClient;)V", - &[web_chrome_client.as_obj().into()], - )?; - - // Add javascript interface (IPC) - let ipc_class = find_class( - &mut self.env, - &activity, - format!("{}/Ipc", PACKAGE.get().unwrap()), - )?; - let ipc = self.env.new_object( - ipc_class, - format!("(L{webview_class_name};L{client_class_name};)V"), - &[(&webview).into(), (&webview_client).into()], - )?; - let ipc_str = self.env.new_string("ipc")?; - self.env.call_method( - &webview, - "addJavascriptInterface", - "(Ljava/lang/Object;Ljava/lang/String;)V", - &[(&ipc).into(), (&ipc_str).into()], - )?; - - // Set content view - self.env.call_method( - &activity, - "setContentView", - "(Landroid/view/View;)V", - &[(&webview).into()], - )?; - - if let Some(on_webview_created) = on_webview_created { - if let Err(_e) = on_webview_created(super::Context { - env: &mut self.env, - activity: &activity, - webview: &webview, - }) { - #[cfg(feature = "tracing")] - tracing::warn!("failed to run webview created hook: {_e}"); - } - } - - let webview = self.env.new_global_ref(webview)?; - - ACTIVITY_PROXY - .lock() - .unwrap() - .get_mut(&activity_id) - .unwrap() - .webview - .replace(webview); - } - WebViewMessage::Eval(script, callback) => { - if let Some(webview) = get_webview(activity_id) { - let id = EVAL_ID_GENERATOR.next() as i32; - - #[cfg(feature = "tracing")] - let span = std::sync::Mutex::new(Some(SendEnteredSpan( - tracing::debug_span!("wry::eval").entered(), - ))); - - EVAL_CALLBACKS - .get_or_init(Default::default) - .lock() - .unwrap() - .insert( - id, - Box::new(move |result| { - #[cfg(feature = "tracing")] - span.lock().unwrap().take(); - - if let Some(callback) = &callback { - callback(result); - } - }), - ); - - let s = self.env.new_string(script)?; - self.env.call_method( - webview.as_obj(), - "evalScript", - "(ILjava/lang/String;)V", - &[id.into(), (&s).into()], - )?; - } - } - WebViewMessage::SetBackgroundColor(background_color) => { - if let Some(webview) = get_webview(activity_id) { - set_background_color(&mut self.env, webview.as_obj(), background_color)?; - } - } - WebViewMessage::GetWebViewVersion(tx) => { - if let Some(activity) = activity_proxy(activity_id).map(|p| p.activity.clone()) { - match self - .env - .call_method(activity, "getVersion", "()Ljava/lang/String;", &[]) - .and_then(|v| v.l()) - .and_then(|s| { - let s = JString::from(s); - self - .env - .get_string(&s) - .map(|v| v.to_string_lossy().to_string()) - }) { - Ok(version) => { - tx.send(Ok(version)).unwrap(); - } - Err(e) => tx.send(Err(e.into())).unwrap(), - } - } else { - tx.send(Err(Error::ActivityNotFound)).unwrap(); - } - } - WebViewMessage::GetUrl(tx) => { - if let Some(webview) = get_webview(activity_id) { - let url = self - .env - .call_method(webview.as_obj(), "getUrl", "()Ljava/lang/String;", &[]) - .and_then(|v| v.l()) - .and_then(|s| { - let s = JString::from(s); - self - .env - .get_string(&s) - .map(|v| v.to_string_lossy().to_string()) - }) - .unwrap_or_default(); - - tx.send(url).unwrap() - } - } - WebViewMessage::Jni(f) => { - match activity_proxy(activity_id).map(|p| (p.activity.clone(), p.webview.clone())) { - Some((activity, Some(webview))) => { - f(&mut self.env, &activity, webview.as_obj()); - } - Some((activity, None)) => { - f(&mut self.env, &activity, &JObject::null()); - } - _ => { - f(&mut self.env, &JObject::null(), &JObject::null()); - } - } - } - WebViewMessage::LoadUrl(url, headers) => { - if let Some(webview) = get_webview(activity_id) { - let url = self.env.new_string(url)?; - load_url(&mut self.env, webview.as_obj(), &url, headers, false)?; - } - } - WebViewMessage::ClearAllBrowsingData => { - if let Some(webview) = get_webview(activity_id) { - self - .env - .call_method(webview, "clearAllBrowsingData", "()V", &[])?; - } - } - WebViewMessage::LoadHtml(html) => { - if let Some(webview) = get_webview(activity_id) { - let html = self.env.new_string(html)?; - load_html(&mut self.env, webview.as_obj(), &html)?; - } - } - WebViewMessage::Reload => { - if let Some(webview) = get_webview(activity_id) { - reload(&mut self.env, webview.as_obj())?; - } - } - WebViewMessage::GetCookies(tx, url) => { - if let Some(webview) = get_webview(activity_id) { - let url = self.env.new_string(url)?; - let cookies = self - .env - .call_method( - webview, - "getCookies", - "(Ljava/lang/String;)Ljava/lang/String;", - &[(&url).into()], - ) - .and_then(|v| v.l()) - .and_then(|s| { - let s = JString::from(s); - self - .env - .get_string(&s) - .map(|v| v.to_string_lossy().to_string()) - }) - .unwrap_or_default(); - - tx.send( - cookies - .split("; ") - .flat_map(|c| cookie::Cookie::parse(c.to_string())) - .collect(), - ) - .unwrap(); - } - } - WebViewMessage::OnDestroy { - activity_id, - webview_id, - is_changing_configurations, - } => { - // keep our webview references (callbacks etc) alive if the activity is going to be recreated due to configuration changes - // e.g. rotation, multi-window mode change, etc - if !is_changing_configurations { - super::destroy_webview(activity_id, &webview_id); - remove_activity_proxy(activity_id); - } - } - } - } - Ok(()) - } -} - -fn load_url<'a>( - env: &mut JNIEnv<'a>, - webview: &JObject<'a>, - url: &JString<'a>, - headers: Option, - main_thread: bool, -) -> JniResult<()> { - let function = if main_thread { - "loadUrlMainThread" - } else { - "loadUrl" - }; - if let Some(headers) = headers { - let obj = env.new_object("java/util/HashMap", "()V", &[])?; - let headers_map = { - let headers_map = JMap::from_env(env, &obj)?; - for (name, value) in headers.iter() { - let key = env.new_string(name)?; - let value = env.new_string(value.to_str().unwrap_or_default())?; - headers_map.put(env, &key, &value)?; - } - headers_map - }; - env.call_method( - webview, - function, - "(Ljava/lang/String;Ljava/util/Map;)V", - &[url.into(), (&headers_map).into()], - )?; - } else { - env.call_method(webview, function, "(Ljava/lang/String;)V", &[url.into()])?; - } - Ok(()) -} - -fn load_html<'a>(env: &mut JNIEnv<'a>, webview: &JObject<'a>, html: &JString<'a>) -> JniResult<()> { - env.call_method( - webview, - "loadHTMLMainThread", - "(Ljava/lang/String;)V", - &[html.into()], - )?; - Ok(()) -} - -fn reload<'a>(env: &mut JNIEnv<'a>, webview: &JObject<'a>) -> JniResult<()> { - env.call_method(webview, "reload", "()V", &[])?; - Ok(()) -} - -fn set_background_color<'a>( - env: &mut JNIEnv<'a>, - webview: &JObject<'a>, - (r, g, b, a): RGBA, -) -> JniResult<()> { - let color = (a as i32) << 24 | (r as i32) << 16 | (g as i32) << 8 | (b as i32); - env.call_method(webview, "setBackgroundColor", "(I)V", &[color.into()])?; - Ok(()) -} - -pub(crate) enum WebViewMessage { - CreateWebView(CreateWebViewAttributes), - Eval(String, Option), - SetBackgroundColor(RGBA), - GetWebViewVersion(Sender>), - GetUrl(Sender), - GetCookies(Sender>>, String), - Jni(Box), - LoadUrl(String, Option), - LoadHtml(String), - Reload, - ClearAllBrowsingData, - OnDestroy { - activity_id: ActivityId, - webview_id: WebviewId, - is_changing_configurations: bool, - }, -} - -#[derive(Clone)] -pub(crate) struct CreateWebViewAttributes { - pub id: String, - pub url: Option, - pub html: Option, - #[cfg(any(debug_assertions, feature = "devtools"))] - pub devtools: bool, - pub transparent: bool, - pub background_color: Option, - pub headers: Option, - pub autoplay: bool, - pub on_webview_created: - Option JniResult<()> + Send + Sync + 'static>>, - pub user_agent: Option, - pub initialization_scripts: Vec, - pub javascript_disabled: bool, -} - -// SAFETY: only use this when you are sure the span will be dropped on the same thread it was entered -#[cfg(feature = "tracing")] -struct SendEnteredSpan(tracing::span::EnteredSpan); - -#[cfg(feature = "tracing")] -unsafe impl Send for SendEnteredSpan {} diff --git a/vendor/wry/src/android/mod.rs b/vendor/wry/src/android/mod.rs deleted file mode 100644 index 8f44c70..0000000 --- a/vendor/wry/src/android/mod.rs +++ /dev/null @@ -1,530 +0,0 @@ -// Copyright 2020-2023 Tauri Programme within The Commons Conservancy -// SPDX-License-Identifier: Apache-2.0 -// SPDX-License-Identifier: MIT - -use super::{PageLoadEvent, WebViewAttributes, RGBA}; -use crate::{ - custom_protocol_workaround, inject_initialization_scripts::inject_scripts_into_html, Error, - RequestAsyncResponder, Result, -}; -use crossbeam_channel::*; - -use http::{Request, Response as HttpResponse}; -use jni::{ - errors::Result as JniResult, - objects::{GlobalRef, JClass, JObject}, - JNIEnv, -}; -use ndk::looper::ThreadLooper; -use once_cell::sync::{Lazy, OnceCell}; -use raw_window_handle::HasWindowHandle; -use std::{ - borrow::Cow, - collections::HashMap, - sync::{mpsc::channel, Mutex}, - time::Duration, -}; - -pub(crate) mod binding; -mod main_pipe; -use main_pipe::{ - activity_id_for_window_manager, first_activity_id, register_activity_proxy, ActivityId, - CreateWebViewAttributes, MainPipe, WebViewMessage, -}; - -use crate::util::Counter; - -static COUNTER: Counter = Counter::new(); -const MAIN_PIPE_TIMEOUT: Duration = Duration::from_secs(10); - -pub struct Context<'a, 'b> { - pub env: &'a mut JNIEnv<'b>, - pub activity: &'a JObject<'b>, - pub webview: &'a JObject<'b>, -} - -type WebviewId = String; - -macro_rules! define_static_handlers { - ($($key: ident, $var:ident = $type_name:ident);+ $(;)?) => { - $(static $var: Lazy>> = Lazy::new(||Mutex::new(HashMap::new()));)* - }; - - ($($var:ident = $type_name:ident { $($fields:ident:$types:ty),+ $(,)? });+ $(;)?) => { - $( - static $var: Lazy>> = Lazy::new(||Mutex::new(HashMap::new())); - pub struct $type_name { - $($fields: $types,)* - } - impl $type_name { - pub fn new($($fields: $types,)*) -> Self { - Self { - $($fields,)* - } - } - } - unsafe impl Send for $type_name {} - unsafe impl Sync for $type_name {})* - }; -} - -define_static_handlers! { - IPC = UnsafeIpc { handler: Box)> }; - REQUEST_HANDLER = UnsafeRequestHandler { handler: Box>, bool) -> Option>>> }; - TITLE_CHANGE_HANDLER = UnsafeTitleHandler { handler: Box }; - URL_LOADING_OVERRIDE = UnsafeUrlLoadingOverride { handler: Box bool> }; - ON_LOAD_HANDLER = UnsafeOnPageLoadHandler { handler: Box }; -} -define_static_handlers! { - WebviewId, WITH_ASSET_LOADER = bool; - WebviewId, ASSET_LOADER_DOMAIN = String; - ActivityId, WEBVIEW_ATTRIBUTES = CreateWebViewAttributes; -} - -pub(crate) static PACKAGE: OnceCell = OnceCell::new(); - -type EvalCallback = Box; - -pub static EVAL_ID_GENERATOR: Counter = Counter::new(); -pub static EVAL_CALLBACKS: OnceCell>> = OnceCell::new(); - -pub fn destroy_webview(activity_id: ActivityId, webview_id: &WebviewId) { - WEBVIEW_ATTRIBUTES.lock().unwrap().remove(&activity_id); - IPC.lock().unwrap().remove(webview_id); - REQUEST_HANDLER.lock().unwrap().remove(webview_id); - TITLE_CHANGE_HANDLER.lock().unwrap().remove(webview_id); - URL_LOADING_OVERRIDE.lock().unwrap().remove(webview_id); - ON_LOAD_HANDLER.lock().unwrap().remove(webview_id); - WITH_ASSET_LOADER.lock().unwrap().remove(webview_id); - ASSET_LOADER_DOMAIN.lock().unwrap().remove(webview_id); -} - -/// Sets up the necessary logic for wry to be able to create the webviews later. -/// -/// This function must be run on the thread where the [`JNIEnv`] is registered and the looper is local, -/// hence the requirement for a [`ThreadLooper`]. -pub unsafe fn android_setup( - package: &str, - mut env: JNIEnv, - _looper: &ThreadLooper, - activity: GlobalRef, -) { - PACKAGE.get_or_init(move || package.to_string()); - - let vm = env.get_java_vm().unwrap(); - - let activity_id = env - .call_method(activity.as_obj(), "getId", "()I", &[]) - .unwrap() - .i() - .unwrap(); - - let window_manager = env - .call_method( - &activity, - "getWindowManager", - "()Landroid/view/WindowManager;", - &[], - ) - .unwrap() - .l() - .unwrap(); - let window_manager = env.new_global_ref(window_manager).unwrap(); - - // we must create the WebChromeClient here because it calls `registerForActivityResult`, - // which gives an `LifecycleOwners must call register before they are STARTED.` error when called outside the onCreate hook - let rust_webchrome_client_class = find_class( - &mut env, - activity.as_obj(), - format!("{}/RustWebChromeClient", PACKAGE.get().unwrap()), - ) - .unwrap(); - let webchrome_client = env - .new_object( - &rust_webchrome_client_class, - format!("(L{}/WryActivity;)V", PACKAGE.get().unwrap()), - &[activity.as_obj().into()], - ) - .unwrap(); - - let webchrome_client = env.new_global_ref(webchrome_client).unwrap(); - - register_activity_proxy(vm, activity_id, activity, window_manager, webchrome_client); - - if let Some(webview_attributes) = WEBVIEW_ATTRIBUTES.lock().unwrap().get(&activity_id) { - MainPipe::send( - activity_id, - WebViewMessage::CreateWebView(webview_attributes.clone()), - ); - } -} - -pub(crate) struct InnerWebView { - id: String, - pub activity_id: ActivityId, -} - -impl InnerWebView { - pub fn new_as_child( - window: &impl HasWindowHandle, - attributes: WebViewAttributes, - pl_attrs: super::PlatformSpecificWebViewAttributes, - ) -> Result { - Self::new(window, attributes, pl_attrs) - } - - pub fn new( - window: &impl HasWindowHandle, - attributes: WebViewAttributes, - pl_attrs: super::PlatformSpecificWebViewAttributes, - ) -> Result { - let window_manager = match window.window_handle()?.as_raw() { - raw_window_handle::RawWindowHandle::AndroidNdk(window_manager) => { - window_manager.a_native_window - } - _ => return Err(Error::UnsupportedWindowHandle), - }; - let window_manager = unsafe { JObject::from_raw(window_manager.as_ptr().cast()) }; - let activity_id = - activity_id_for_window_manager(window_manager).expect("no available activity"); - let WebViewAttributes { - url, - html, - initialization_scripts, - ipc_handler, - #[cfg(any(debug_assertions, feature = "devtools"))] - devtools, - custom_protocols, - background_color, - transparent, - headers, - autoplay, - user_agent, - javascript_disabled, - .. - } = attributes; - - let super::PlatformSpecificWebViewAttributes { - on_webview_created, - with_asset_loader, - asset_loader_domain, - https_scheme, - } = pl_attrs; - - let http_or_https = if https_scheme { "https" } else { "http" }; - - let url = if let Some(mut url) = url { - if let Some((protocol, _)) = url.split_once("://") { - if custom_protocols.contains_key(protocol) { - url = custom_protocol_workaround::apply_uri_work_around(&url, http_or_https, protocol) - } - } - - Some(url) - } else { - None - }; - - let id = attributes - .id - .map(|id| id.to_string()) - .unwrap_or_else(|| COUNTER.next().to_string()); - - WITH_ASSET_LOADER - .lock() - .unwrap() - .insert(id.clone(), with_asset_loader); - if let Some(domain) = asset_loader_domain { - ASSET_LOADER_DOMAIN - .lock() - .unwrap() - .insert(id.clone(), domain); - } - - let initialization_scripts_ = initialization_scripts.clone(); - REQUEST_HANDLER - .lock() - .unwrap() - .insert( - id.clone(), - UnsafeRequestHandler::new(Box::new( - move |webview_id: &str, mut request, is_document_start_script_enabled| { - let uri = request.uri().to_string(); - if let Some((custom_protocol, custom_protocol_handler)) = - custom_protocols.iter().find(|(protocol, _)| { - custom_protocol_workaround::is_work_around_uri(&uri, http_or_https, protocol) - }) - { - let uri_res = custom_protocol_workaround::revert_uri_work_around( - &uri, - http_or_https, - custom_protocol, - ) - .parse(); - - if let Ok(uri) = uri_res { - *request.uri_mut() = uri; - } - - let (tx, rx) = channel(); - let initialization_scripts = initialization_scripts_.clone(); - let responder: Box>)> = - Box::new(move |mut response| { - if !is_document_start_script_enabled { - #[cfg(feature = "tracing")] - tracing::info!("`addDocumentStartJavaScript` is not supported; injecting initialization scripts via custom protocol handler"); - response = inject_scripts_into_html(response, &initialization_scripts); - } - let _ = tx.send(response); - }); - - (custom_protocol_handler)(webview_id, request, RequestAsyncResponder { responder }); - // 3x the timeout while we monitor https://github.com/tauri-apps/wry/issues/1551 - // TODO: Remove timeout - return rx.recv_timeout(MAIN_PIPE_TIMEOUT * 3).inspect_err(|e| {eprintln!("custom protocol timed out: {e}");}).ok(); - } - None - }, - ))); - - if let Some(i) = ipc_handler { - IPC - .lock() - .unwrap() - .insert(id.clone(), UnsafeIpc::new(Box::new(i))); - } - - if let Some(i) = attributes.document_title_changed_handler { - TITLE_CHANGE_HANDLER - .lock() - .unwrap() - .insert(id.clone(), UnsafeTitleHandler::new(i)); - } - - if let Some(i) = attributes.navigation_handler { - URL_LOADING_OVERRIDE - .lock() - .unwrap() - .insert(id.clone(), UnsafeUrlLoadingOverride::new(i)); - } - - if let Some(h) = attributes.on_page_load_handler { - ON_LOAD_HANDLER - .lock() - .unwrap() - .insert(id.clone(), UnsafeOnPageLoadHandler::new(h)); - } - - let attributes = CreateWebViewAttributes { - id: id.clone(), - url, - html, - #[cfg(any(debug_assertions, feature = "devtools"))] - devtools, - background_color, - transparent, - headers, - on_webview_created, - autoplay, - user_agent, - initialization_scripts, - javascript_disabled, - }; - - WEBVIEW_ATTRIBUTES - .lock() - .unwrap() - .insert(activity_id, attributes.clone()); - - MainPipe::send(activity_id, WebViewMessage::CreateWebView(attributes)); - - Ok(Self { id, activity_id }) - } - - pub fn print(&self) -> crate::Result<()> { - Ok(()) - } - - pub fn id(&self) -> crate::WebViewId<'_> { - &self.id - } - - pub fn url(&self) -> crate::Result { - let (tx, rx) = bounded(1); - MainPipe::send(self.activity_id, WebViewMessage::GetUrl(tx)); - rx.recv_timeout(MAIN_PIPE_TIMEOUT).map_err(Into::into) - } - - pub fn eval(&self, js: &str, callback: Option) -> Result<()> { - MainPipe::send( - self.activity_id, - WebViewMessage::Eval( - js.into(), - callback.map(|c| Box::new(c) as Box), - ), - ); - Ok(()) - } - - #[cfg(any(debug_assertions, feature = "devtools"))] - pub fn open_devtools(&self) {} - - #[cfg(any(debug_assertions, feature = "devtools"))] - pub fn close_devtools(&self) {} - - #[cfg(any(debug_assertions, feature = "devtools"))] - pub fn is_devtools_open(&self) -> bool { - false - } - - pub fn zoom(&self, _scale_factor: f64) -> Result<()> { - Ok(()) - } - - pub fn set_background_color(&self, background_color: RGBA) -> Result<()> { - MainPipe::send( - self.activity_id, - WebViewMessage::SetBackgroundColor(background_color), - ); - Ok(()) - } - - pub fn load_url(&self, url: &str) -> Result<()> { - MainPipe::send( - self.activity_id, - WebViewMessage::LoadUrl(url.to_string(), None), - ); - Ok(()) - } - - pub fn load_url_with_headers(&self, url: &str, headers: http::HeaderMap) -> Result<()> { - MainPipe::send( - self.activity_id, - WebViewMessage::LoadUrl(url.to_string(), Some(headers)), - ); - Ok(()) - } - - pub fn load_html(&self, html: &str) -> Result<()> { - MainPipe::send(self.activity_id, WebViewMessage::LoadHtml(html.to_string())); - Ok(()) - } - - pub fn reload(&self) -> Result<()> { - MainPipe::send(self.activity_id, WebViewMessage::Reload); - Ok(()) - } - - pub fn clear_all_browsing_data(&self) -> Result<()> { - MainPipe::send(self.activity_id, WebViewMessage::ClearAllBrowsingData); - Ok(()) - } - - pub fn cookies_for_url(&self, url: &str) -> Result>> { - let (tx, rx) = bounded(1); - MainPipe::send( - self.activity_id, - WebViewMessage::GetCookies(tx, url.to_string()), - ); - rx.recv_timeout(MAIN_PIPE_TIMEOUT).map_err(Into::into) - } - - pub fn set_cookie(&self, #[allow(unused)] cookie: &cookie::Cookie<'_>) -> Result<()> { - // Unsupported - Ok(()) - } - - pub fn delete_cookie(&self, #[allow(unused)] cookie: &cookie::Cookie<'_>) -> Result<()> { - // Unsupported - Ok(()) - } - - pub fn cookies(&self) -> Result>> { - Ok(Vec::new()) - } - - pub fn bounds(&self) -> Result { - Ok(crate::Rect::default()) - } - - pub fn set_bounds(&self, _bounds: crate::Rect) -> Result<()> { - // Unsupported - Ok(()) - } - - pub fn set_visible(&self, _visible: bool) -> Result<()> { - // Unsupported - Ok(()) - } - - pub fn focus(&self) -> Result<()> { - // Unsupported - Ok(()) - } - - pub fn focus_parent(&self) -> Result<()> { - // Unsupported - Ok(()) - } -} - -#[derive(Clone, Copy)] -pub struct JniHandle { - pub(crate) activity_id: ActivityId, -} - -impl JniHandle { - /// Execute jni code on the thread of the webview. - /// Provided function will be provided with the jni evironment, Android activity and WebView - pub fn exec(&self, func: F) - where - F: FnOnce(&mut JNIEnv, &JObject, &JObject) + Send + 'static, - { - MainPipe::send(self.activity_id, WebViewMessage::Jni(Box::new(func))); - } -} - -pub fn platform_webview_version() -> Result { - let (tx, rx) = bounded(1); - let activity_id = loop { - match first_activity_id() { - Some(id) => break id, - None => { - std::thread::sleep(Duration::from_millis(100)); - } - } - }; - MainPipe::send(activity_id, WebViewMessage::GetWebViewVersion(tx)); - rx.recv_timeout(MAIN_PIPE_TIMEOUT)? -} - -/// Finds a class in the project scope. -pub fn find_class<'a>( - env: &mut JNIEnv<'a>, - activity: &JObject<'_>, - name: String, -) -> JniResult> { - let class_name = env.new_string(name.replace('/', "."))?; - let my_class = env - .call_method( - activity, - "getAppClass", - "(Ljava/lang/String;)Ljava/lang/Class;", - &[(&class_name).into()], - )? - .l()?; - Ok(my_class.into()) -} - -/// Dispatch a closure to run on the Android context. -/// -/// The closure takes the JNI env, the Android activity instance and the possibly null webview. -pub fn dispatch(func: F) -where - F: FnOnce(&mut JNIEnv, &JObject, &JObject) + Send + 'static, -{ - MainPipe::send( - first_activity_id().expect("no available activity"), - WebViewMessage::Jni(Box::new(func)), - ); -} diff --git a/vendor/wry/src/custom_protocol_workaround.rs b/vendor/wry/src/custom_protocol_workaround.rs deleted file mode 100644 index 122b134..0000000 --- a/vendor/wry/src/custom_protocol_workaround.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! - WebView2 supports non-standard protocols only on Windows 10+, so we have to use a workaround. -//! See -//! - On Android, there's no API for registering custom protocols, so this workaround is also used. -//! -//! The process looks like this: -//! -//! 1. Use [`apply_uri_work_around`] to convert the URI we want to navigate to -//! 2. Intercept http(s) requests, test the request URI against [`is_work_around_uri`], -//! if it matches, we apply [`revert_uri_work_around`] to the URI and feed it to the custom protocol handler - -/// If the URI is a work around URI for this protocol which starts with `{http_or_https}://{protocol}.` -pub fn is_work_around_uri(uri: &str, http_or_https: &str, protocol: &str) -> bool { - uri - .strip_prefix(http_or_https) - .and_then(|rest| rest.strip_prefix("://")) - .and_then(|rest| rest.strip_prefix(protocol)) - .and_then(|rest| rest.strip_prefix(".")) - .is_some() -} - -/// Conveting `{protocol}://localhost/abc` to `{http_or_https}://{protocol}.localhost/abc` -pub fn apply_uri_work_around(uri: &str, http_or_https: &str, protocol: &str) -> String { - uri.replace( - &original_uri_prefix(protocol), - &work_around_uri_prefix(http_or_https, protocol), - ) -} - -/// Conveting `{http_or_https}://{protocol}.localhost/abc` back to `{protocol}://localhost/abc` -pub fn revert_uri_work_around(uri: &str, http_or_https: &str, protocol: &str) -> String { - uri.replace( - &work_around_uri_prefix(http_or_https, protocol), - &original_uri_prefix(protocol), - ) -} - -pub fn original_uri_prefix(protocol: &str) -> String { - format!("{protocol}://") -} - -pub fn work_around_uri_prefix(http_or_https: &str, protocol: &str) -> String { - format!("{http_or_https}://{protocol}.") -} - -#[cfg(test)] -mod tests { - use super::is_work_around_uri; - - #[test] - fn checks_if_custom_protocol_uri() { - let scheme = "http"; - let uri = "http://wry.localhost/path/to/page"; - assert!(is_work_around_uri(uri, scheme, "wry")); - assert!(!is_work_around_uri(uri, scheme, "asset")); - } -} diff --git a/vendor/wry/src/error.rs b/vendor/wry/src/error.rs deleted file mode 100644 index 9598993..0000000 --- a/vendor/wry/src/error.rs +++ /dev/null @@ -1,80 +0,0 @@ -/// Convenient type alias of Result type for wry. -pub type Result = std::result::Result; - -/// Errors returned by wry. -#[non_exhaustive] -#[derive(thiserror::Error, Debug)] -pub enum Error { - #[cfg(gtk)] - #[error(transparent)] - GlibError(#[from] gtk::glib::Error), - #[cfg(gtk)] - #[error(transparent)] - GlibBoolError(#[from] gtk::glib::BoolError), - #[cfg(gtk)] - #[error("Fail to fetch security manager")] - MissingManager, - #[cfg(gtk)] - #[error("Couldn't find X11 Display")] - X11DisplayNotFound, - #[cfg(all(gtk, feature = "x11"))] - #[error(transparent)] - XlibError(#[from] x11_dl::error::OpenError), - #[error("Failed to initialize the script")] - InitScriptError, - #[error("Bad RPC request: {0} ((1))")] - RpcScriptError(String, String), - #[error(transparent)] - NulError(#[from] std::ffi::NulError), - #[error(transparent)] - ReceiverError(#[from] std::sync::mpsc::RecvError), - #[cfg(target_os = "android")] - #[error(transparent)] - ReceiverTimeoutError(#[from] crossbeam_channel::RecvTimeoutError), - #[error(transparent)] - SenderError(#[from] std::sync::mpsc::SendError), - #[error("Failed to send the message")] - MessageSender, - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - #[cfg(target_os = "windows")] - #[error("WebView2 error: {0}")] - WebView2Error(webview2_com::Error), - #[error(transparent)] - HttpError(#[from] http::Error), - #[error("Infallible error, something went really wrong: {0}")] - Infallible(#[from] std::convert::Infallible), - #[cfg(target_os = "android")] - #[error(transparent)] - JniError(#[from] jni::errors::Error), - #[error("Failed to create proxy endpoint")] - ProxyEndpointCreationFailed, - #[error(transparent)] - WindowHandleError(#[from] raw_window_handle::HandleError), - #[error("the window handle kind is not supported")] - UnsupportedWindowHandle, - #[error(transparent)] - Utf8Error(#[from] std::str::Utf8Error), - #[cfg(target_os = "android")] - #[error(transparent)] - CrossBeamRecvError(#[from] crossbeam_channel::RecvError), - #[error("not on the main thread")] - NotMainThread, - #[error("Custom protocol task is invalid.")] - CustomProtocolTaskInvalid, - #[error("Failed to register URL scheme: {0}, could be due to invalid URL scheme or the scheme is already registered.")] - UrlSchemeRegisterError(String), - #[error("Duplicate custom protocol '{0}' registered on the WebViewBuilder")] - DuplicateCustomProtocol(String), - #[error("Duplicate custom protocol '{0}' registered on the same web context on Linux")] - ContextDuplicateCustomProtocol(String), - #[error(transparent)] - #[cfg(any(target_os = "macos", target_os = "ios"))] - UrlParse(#[from] url::ParseError), - #[cfg(any(target_os = "macos", target_os = "ios"))] - #[error("data store is currently opened")] - DataStoreInUse, - #[cfg(target_os = "android")] - #[error("Activity not found")] - ActivityNotFound, -} diff --git a/vendor/wry/src/inject_initialization_scripts.rs b/vendor/wry/src/inject_initialization_scripts.rs deleted file mode 100644 index 61636d2..0000000 --- a/vendor/wry/src/inject_initialization_scripts.rs +++ /dev/null @@ -1,225 +0,0 @@ -// Copyright 2020-2023 Tauri Programme within The Commons Conservancy -// SPDX-License-Identifier: Apache-2.0 -// SPDX-License-Identifier: MIT - -//! This is an internal implementation detail used by the Android backend to inject -//! initialization scripts when `addDocumentStartJavaScript` is not supported. - -use base64::{prelude::BASE64_STANDARD, Engine}; -use dom_query::Document; -use http::{ - header::{HeaderValue, CONTENT_SECURITY_POLICY, CONTENT_TYPE}, - Response as HttpResponse, -}; -use sha2::{Digest, Sha256}; -use std::borrow::Cow; - -use crate::InitializationScript; - -pub fn inject_scripts_into_html( - mut response: HttpResponse>, - scripts: &[InitializationScript], -) -> HttpResponse> { - if scripts.is_empty() { - return response; - } - - let should_inject_scripts = response - .headers() - .get(CONTENT_TYPE) - // Content-Type must begin with the media type, but is case-insensitive. - // It may also be followed by any number of semicolon-delimited key value pairs. - // We don't care about these here. - // source: https://httpwg.org/specs/rfc9110.html#rfc.section.8.3.1 - .and_then(|content_type| content_type.to_str().ok()) - .map(|content_type_str| content_type_str.to_lowercase().starts_with("text/html")) - .unwrap_or_default(); - - if !should_inject_scripts { - return response; - } - - let document = Document::from(String::from_utf8_lossy(response.body()).as_ref()); - let csp = response.headers_mut().get_mut(CONTENT_SECURITY_POLICY); - - // Get or create head element - let head = document.head().unwrap_or_else(|| { - let html = document.html_root(); - let head = document.tree.new_element("head"); - html.prepend_child(&head); - head - }); - - // Iterate in reverse order since we are prepending each script to the head tag - let mut hashes = Vec::new(); - for script in scripts.iter().rev().map(|s| &s.script) { - let script_tag = document.tree.new_element("script"); - script_tag.set_text(script.as_str()); - head.prepend_child(&script_tag); - if csp.is_some() { - hashes.push(hash_script(script)); - } - } - - if let Some(csp) = csp { - let csp_string = csp.to_str().unwrap().to_string(); - let csp_string = if csp_string.contains("script-src") { - csp_string.replace("script-src", &format!("script-src {}", hashes.join(" "))) - } else { - format!("{csp_string} script-src {}", hashes.join(" ")) - }; - *csp = HeaderValue::from_str(&csp_string).unwrap(); - } - - *response.body_mut() = Cow::Owned(document.html().as_bytes().to_vec()); - response -} - -fn hash_script(script: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(script); - let hash = hasher.finalize(); - format!("'sha256-{}'", BASE64_STANDARD.encode(hash)) -} - -#[cfg(test)] -mod tests { - use super::*; - use http::StatusCode; - - #[test] - fn test_no_scripts_returns_original_response() { - let body = "Test"; - - let result = run(body, "text/html", vec![]); - - assert_eq!(result, body); - } - - #[test] - fn test_non_html_response_not_modified() { - let body = r#"{"key": "value"}"#; - let scripts = vec!["console.log('test');".to_string()]; - - let result = run(body, "application/json", scripts); - - assert_eq!(result, body); - } - - #[test] - fn test_inject_single_script() { - let body = "Content"; - let scripts = vec!["console.log('injected');".to_string()]; - - let result = run(body, "text/html", scripts); - - assert_eq!( - result, - "Content" - ); - } - - #[test] - fn test_inject_multiple_scripts() { - let body = "Content"; - let scripts = vec![ - "var first = 1;".to_owned(), - "let second = 2;".to_owned(), - "const third = 3;".to_owned(), - "window.test = () => console.log('test');".to_owned(), - ]; - - let result = run(body, "text/html", scripts); - - assert_eq!( - result, - "Content" - ); - } - - #[test] - fn test_inject_script_creates_head_if_missing() { - let body = "Content"; - let scripts = vec!["console.log('test');".to_string()]; - - let result = run(body, "text/html", scripts); - - assert_eq!( - result, - "Content" - ); - } - - #[test] - fn test_inject_script_creates_html_structure_if_missing() { - let body = "Just some text"; - let scripts = vec!["console.log('test');".to_string()]; - - let result = run(body, "text/html", scripts); - - assert_eq!( - result, - "Just some text" - ); - } - - #[test] - fn test_csp_header_updated_with_script_hashes() { - let body = "Content"; - let mut response = create_response(body, "text/html"); - response.headers_mut().insert( - CONTENT_SECURITY_POLICY, - HeaderValue::from_static("default-src 'self'"), - ); - - let script_code = "console.log('test');"; - let scripts = vec![script_code.to_string()]; - - let scripts: Vec = scripts - .into_iter() - .map(|script| InitializationScript { - script, - for_main_frame_only: true, - }) - .collect(); - let result = inject_scripts_into_html(response, &scripts); - let result_body = String::from_utf8_lossy(result.body()).to_string(); - let csp = result.headers().get(CONTENT_SECURITY_POLICY).unwrap(); - let csp_str = csp.to_str().unwrap(); - - assert_eq!( - result_body, - "Content" - ); - assert_eq!( - csp_str, - "default-src 'self' script-src 'sha256-3x8DE279hr8o/Aq0dEdH4WApIwn5rbRKhugPzn6Bofw='" - ); - } - - fn create_response(body: &str, content_type: &'static str) -> HttpResponse> { - let mut response = HttpResponse::builder() - .status(StatusCode::OK) - .body(Cow::Owned(body.as_bytes().to_vec())) - .unwrap(); - response - .headers_mut() - .insert(CONTENT_TYPE, HeaderValue::from_static(content_type)); - - response - } - - /// Helper function to create a response, inject scripts, and return the body as a string - fn run(body: &str, content_type: &'static str, scripts: Vec) -> String { - let response = create_response(body, content_type); - let scripts: Vec = scripts - .into_iter() - .map(|script| InitializationScript { - script, - for_main_frame_only: true, - }) - .collect(); - let result = inject_scripts_into_html(response, &scripts); - String::from_utf8_lossy(result.body()).to_string() - } -} diff --git a/vendor/wry/src/lib.rs b/vendor/wry/src/lib.rs deleted file mode 100644 index 2a4bd64..0000000 --- a/vendor/wry/src/lib.rs +++ /dev/null @@ -1,2519 +0,0 @@ -// Copyright 2020-2023 Tauri Programme within The Commons Conservancy -// SPDX-License-Identifier: Apache-2.0 -// SPDX-License-Identifier: MIT - -//!

WRY Webview Rendering library

-//! -//! [![](https://img.shields.io/crates/v/wry?style=flat-square)](https://crates.io/crates/wry) [![](https://img.shields.io/docsrs/wry?style=flat-square)](https://docs.rs/wry/) -//! [![License](https://img.shields.io/badge/License-MIT%20or%20Apache%202-green.svg)](https://opencollective.com/tauri) -//! [![Chat Server](https://img.shields.io/badge/chat-discord-7289da.svg)](https://discord.gg/SpmNs4S) -//! [![website](https://img.shields.io/badge/website-tauri.app-purple.svg)](https://tauri.app) -//! [![https://good-labs.github.io/greater-good-affirmation/assets/images/badge.svg](https://good-labs.github.io/greater-good-affirmation/assets/images/badge.svg)](https://good-labs.github.io/greater-good-affirmation) -//! [![support](https://img.shields.io/badge/sponsor-Open%20Collective-blue.svg)](https://opencollective.com/tauri) -//! -//! Wry is a cross-platform WebView rendering library. -//! -//! The webview requires a running event loop and a window type that implements [`HasWindowHandle`], -//! or a gtk container widget if you need to support X11 and Wayland. -//! You can use a windowing library like [`tao`] or [`winit`]. -//! -//! ## Examples -//! -//! This example leverages the [`HasWindowHandle`] and supports Windows, macOS, iOS, Android and Linux (X11 Only). -//! See the following example using [`winit`]: -//! -//! ```no_run -//! # use wry::{WebViewBuilder, raw_window_handle}; -//! # use winit::{application::ApplicationHandler, event::WindowEvent, event_loop::{ActiveEventLoop, EventLoop}, window::{Window, WindowId}}; -//! #[derive(Default)] -//! struct App { -//! window: Option, -//! webview: Option, -//! } -//! -//! impl ApplicationHandler for App { -//! fn resumed(&mut self, event_loop: &ActiveEventLoop) { -//! let window = event_loop.create_window(Window::default_attributes()).unwrap(); -//! let webview = WebViewBuilder::new() -//! .with_url("https://tauri.app") -//! .build(&window) -//! .unwrap(); -//! -//! self.window = Some(window); -//! self.webview = Some(webview); -//! } -//! -//! fn window_event(&mut self, _event_loop: &ActiveEventLoop, _window_id: WindowId, event: WindowEvent) {} -//! } -//! -//! let event_loop = EventLoop::new().unwrap(); -//! let mut app = App::default(); -//! event_loop.run_app(&mut app).unwrap(); -//! ``` -//! -//! If you also want to support Wayland too, then we recommend you use [`WebViewBuilderExtUnix::new_gtk`] on Linux. -//! See the following example using [`tao`]: -//! -//! ```no_run -//! # use wry::WebViewBuilder; -//! # use tao::{window::WindowBuilder, event_loop::EventLoop}; -//! # #[cfg(target_os = "linux")] -//! # use tao::platform::unix::WindowExtUnix; -//! # #[cfg(target_os = "linux")] -//! # use wry::WebViewBuilderExtUnix; -//! let event_loop = EventLoop::new(); -//! let window = WindowBuilder::new().build(&event_loop).unwrap(); -//! -//! let builder = WebViewBuilder::new().with_url("https://tauri.app"); -//! -//! #[cfg(not(target_os = "linux"))] -//! let webview = builder.build(&window).unwrap(); -//! #[cfg(target_os = "linux")] -//! let webview = builder.build_gtk(window.gtk_window()).unwrap(); -//! ``` -//! -//! ## Child webviews -//! -//! You can use [`WebViewBuilder::build_as_child`] to create the webview as a child inside another window. This is supported on -//! macOS, Windows and Linux (X11 Only). -//! -//! ```no_run -//! # use wry::{WebViewBuilder, raw_window_handle, Rect, dpi::*}; -//! # use winit::{application::ApplicationHandler, event::WindowEvent, event_loop::{ActiveEventLoop, EventLoop}, window::{Window, WindowId}}; -//! #[derive(Default)] -//! struct App { -//! window: Option, -//! webview: Option, -//! } -//! -//! impl ApplicationHandler for App { -//! fn resumed(&mut self, event_loop: &ActiveEventLoop) { -//! let window = event_loop.create_window(Window::default_attributes()).unwrap(); -//! let webview = WebViewBuilder::new() -//! .with_url("https://tauri.app") -//! .with_bounds(Rect { -//! position: LogicalPosition::new(100, 100).into(), -//! size: LogicalSize::new(200, 200).into(), -//! }) -//! .build_as_child(&window) -//! .unwrap(); -//! -//! self.window = Some(window); -//! self.webview = Some(webview); -//! } -//! -//! fn window_event(&mut self, _event_loop: &ActiveEventLoop, _window_id: WindowId, event: WindowEvent) {} -//! } -//! -//! let event_loop = EventLoop::new().unwrap(); -//! let mut app = App::default(); -//! event_loop.run_app(&mut app).unwrap(); -//! ``` -//! -//! If you want to support X11 and Wayland at the same time, we recommend using -//! [`WebViewExtUnix::new_gtk`] or [`WebViewBuilderExtUnix::new_gtk`] with [`gtk::Fixed`]. -//! -//! ```no_run -//! # use wry::{WebViewBuilder, raw_window_handle, Rect, dpi::*}; -//! # use tao::{window::WindowBuilder, event_loop::EventLoop}; -//! # #[cfg(target_os = "linux")] -//! # use wry::WebViewBuilderExtUnix; -//! # #[cfg(target_os = "linux")] -//! # use tao::platform::unix::WindowExtUnix; -//! let event_loop = EventLoop::new(); -//! let window = WindowBuilder::new().build(&event_loop).unwrap(); -//! -//! let builder = WebViewBuilder::new() -//! .with_url("https://tauri.app") -//! .with_bounds(Rect { -//! position: LogicalPosition::new(100, 100).into(), -//! size: LogicalSize::new(200, 200).into(), -//! }); -//! -//! #[cfg(not(target_os = "linux"))] -//! let webview = builder.build_as_child(&window).unwrap(); -//! #[cfg(target_os = "linux")] -//! let webview = { -//! # use gtk::prelude::*; -//! let vbox = window.default_vbox().unwrap(); // tao adds a gtk::Box by default -//! let fixed = gtk::Fixed::new(); -//! fixed.show_all(); -//! vbox.pack_start(&fixed, true, true, 0); -//! builder.build_gtk(&fixed).unwrap() -//! }; -//! ``` -//! -//! ## Platform Considerations -//! -//! Here is the underlying web engine each platform uses, and some dependencies you might need to install. -//! -//! ### Linux -//! -//! [WebKitGTK](https://webkitgtk.org/) is used to provide webviews on Linux which requires GTK, -//! so if the windowing library doesn't support GTK (as in [`winit`]) -//! you'll need to call [`gtk::init`] before creating the webview and then call [`gtk::main_iteration_do`] alongside -//! your windowing library event loop. -//! -//! ```no_run -//! # use wry::{WebView, WebViewBuilder}; -//! # use winit::{application::ApplicationHandler, event::WindowEvent, event_loop::{ActiveEventLoop, EventLoop}, window::{Window, WindowId}}; -//! #[derive(Default)] -//! struct App { -//! webview_window: Option<(Window, WebView)>, -//! } -//! -//! impl ApplicationHandler for App { -//! fn resumed(&mut self, event_loop: &ActiveEventLoop) { -//! let window = event_loop.create_window(Window::default_attributes()).unwrap(); -//! let webview = WebViewBuilder::new() -//! .with_url("https://tauri.app") -//! .build(&window) -//! .unwrap(); -//! -//! self.webview_window = Some((window, webview)); -//! } -//! -//! fn window_event(&mut self, _event_loop: &ActiveEventLoop, _window_id: WindowId, event: WindowEvent) {} -//! -//! // Advance GTK event loop + + + + + + + + + + + + + diff --git a/.signpath/artifact-configurations/installer.xml b/.signpath/artifact-configurations/installer.xml new file mode 100644 index 0000000..88d866b --- /dev/null +++ b/.signpath/artifact-configurations/installer.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Cargo.lock b/Cargo.lock index 0e93251..016fdda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -156,6 +156,7 @@ dependencies = [ "is-terminal", "serde", "serde_json", + "tauri-winres", "unicode-width", ] diff --git a/crates/accshift-cli/Cargo.toml b/crates/accshift-cli/Cargo.toml index c1d184c..4d24e2b 100644 --- a/crates/accshift-cli/Cargo.toml +++ b/crates/accshift-cli/Cargo.toml @@ -17,3 +17,7 @@ is-terminal = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } unicode-width = { workspace = true } + +[build-dependencies] +serde_json = "1" +tauri-winres = "0.3" diff --git a/crates/accshift-cli/build.rs b/crates/accshift-cli/build.rs new file mode 100644 index 0000000..5c38aee --- /dev/null +++ b/crates/accshift-cli/build.rs @@ -0,0 +1,41 @@ +// Windows: the version resource the GUI also carries, with the same product +// name and version. Code signing restricts signed files to these values. +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + let windows = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows"); + let msvc = std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc"); + if !(windows && msvc) { + return; + } + // The installer's config names the product; the workspace version is + // checked against it by a test. + let conf_path = std::path::Path::new("..") + .join("accshift-egui") + .join("tauri.conf.json"); + println!("cargo:rerun-if-changed={}", conf_path.display()); + let conf: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&conf_path).expect("tauri.conf.json")) + .expect("tauri.conf.json"); + let product = conf["productName"].as_str().expect("productName"); + let identifier = conf["identifier"].as_str().expect("identifier"); + let version = std::env::var("CARGO_PKG_VERSION").expect("CARGO_PKG_VERSION"); + + let parts: Vec = version + .split('.') + .map(|p| p.parse().expect("numeric version")) + .collect(); + let packed = (parts[0] << 48) | (parts[1] << 32) | (parts[2] << 16); + let mut res = tauri_winres::WindowsResource::new(); + res.set_version_info(tauri_winres::VersionInfo::FILEVERSION, packed); + res.set_version_info(tauri_winres::VersionInfo::PRODUCTVERSION, packed); + res.set("FileVersion", &version); + res.set("ProductVersion", &version); + res.set("ProductName", product); + res.set( + "CompanyName", + identifier.split('.').nth(1).unwrap_or(identifier), + ); + res.set("FileDescription", &format!("{product} CLI")); + res.set("OriginalFilename", "accshift.exe"); + res.compile().expect("Windows resources"); +} From ea268903b7eef2f0471bc17735addd07bd60a5c9 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:39:49 +0200 Subject: [PATCH 103/106] fix(bundle): ship AppRun.wrapped world-executable in the AppImage --- crates/accshift-egui/bundle/bundle.mjs | 65 +++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/crates/accshift-egui/bundle/bundle.mjs b/crates/accshift-egui/bundle/bundle.mjs index 6d58968..0574939 100644 --- a/crates/accshift-egui/bundle/bundle.mjs +++ b/crates/accshift-egui/bundle/bundle.mjs @@ -16,8 +16,19 @@ // installers and the updater know. The CLI goes next to it (`externalBin`) // from `binaries/accshift-[.exe]`. import { execFileSync } from "node:child_process"; -import { copyFileSync, mkdirSync, readdirSync } from "node:fs"; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { createRequire } from "node:module"; +import { homedir, tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -84,6 +95,51 @@ if (flag("--no-updater")) { configs.push(JSON.stringify({ bundle: { createUpdaterArtifacts: false } })); } +// The bundler downloads linuxdeploy's AppRun into its tool cache with mode +// 0770 and copies it into the AppDir as is, where linuxdeploy renames it +// AppRun.wrapped. When the kernel mounts the image instead of FUSE (firejail +// --appimage, the AppImage catalog test), a user other than root cannot run +// it: "AppRun.wrapped: Permission denied". The bundler only downloads a +// missing file, so a copy left there with mode 0755 is the one it packs. +const appimage = process.platform === "linux" && bundles.split(",").includes("appimage"); +const toolsArch = /^arm.*hf$/.test(triple) ? "armhf" : triple.split("-")[0]; + +async function seedAppRun() { + const cache = process.env.XDG_CACHE_HOME || join(homedir(), ".cache"); + const apprun = join(cache, "tauri", `AppRun-${toolsArch}`); + if (!existsSync(apprun)) { + const url = `https://github.com/tauri-apps/binary-releases/releases/download/apprun-old/AppRun-${toolsArch}`; + const response = await fetch(url); + if (!response.ok) throw new Error(`could not download ${url}: HTTP ${response.status}`); + mkdirSync(dirname(apprun), { recursive: true }); + writeFileSync(apprun, Buffer.from(await response.arrayBuffer())); + } + chmodSync(apprun, 0o755); + console.log(`seeded AppRun: ${apprun}`); +} + +// Fails the build when the bundler stops packing the seeded copy (a new cache +// path or file name): every user must be able to run the image's entry points. +function checkAppImage(file) { + const dir = mkdtempSync(join(tmpdir(), "accshift-appimage-")); + try { + execFileSync(file, ["--appimage-extract", "AppRun*"], { cwd: dir, stdio: "ignore" }); + for (const name of ["AppRun", "AppRun.wrapped"]) { + const path = join(dir, "squashfs-root", name); + if (!existsSync(path)) continue; + const mode = statSync(path).mode & 0o777; + if ((mode & 0o005) !== 0o005) { + throw new Error(`${file}: ${name} has mode ${mode.toString(8)}, other users cannot run it`); + } + } + console.log(`checked AppImage entry points: ${file}`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +if (appimage) await seedAppRun(); + const tauri = createRequire(join(root, "package.json")).resolve("@tauri-apps/cli/tauri.js"); execFileSync( process.execPath, @@ -104,3 +160,10 @@ const out = join(release, "bundle"); for (const kind of readdirSync(out)) { for (const name of readdirSync(join(out, kind))) console.log(`bundle: ${join(out, kind, name)}`); } + +if (appimage) { + const dir = join(out, "appimage"); + for (const name of readdirSync(dir).filter((name) => name.endsWith(".AppImage"))) { + checkAppImage(join(dir, name)); + } +} From afbed585e852824adb8df8ff2e669aa0f90eb126 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:42:07 +0200 Subject: [PATCH 104/106] chore(ci): bump voidzero-dev/setup-vp to v1.21.2 --- .github/workflows/ci.yml | 2 +- .github/workflows/dev-build.yml | 2 +- .github/workflows/release.yml | 6 +++--- .github/workflows/worker.yml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7e9512..40f619f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,7 @@ jobs: # through corepack itself (version pinned by packageManager in the root # package.json). - name: Setup Node - uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1 + uses: voidzero-dev/setup-vp@68f39c78aaf39be468cabd2afb09c162574c84af # v1.21.2 with: node-version: 22 run-install: false diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml index 2927a4a..c5b05f0 100644 --- a/.github/workflows/dev-build.yml +++ b/.github/workflows/dev-build.yml @@ -49,7 +49,7 @@ jobs: pkg-config - name: Setup Node - uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1 + uses: voidzero-dev/setup-vp@68f39c78aaf39be468cabd2afb09c162574c84af # v1.21.2 with: node-version: 22 run-install: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ff2732..d522fe7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: "version=$version" >> $env:GITHUB_OUTPUT - name: Setup Node - uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1 + uses: voidzero-dev/setup-vp@68f39c78aaf39be468cabd2afb09c162574c84af # v1.21.2 with: node-version: 22 run-install: false @@ -399,7 +399,7 @@ jobs: pkg-config - name: Setup Node - uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1 + uses: voidzero-dev/setup-vp@68f39c78aaf39be468cabd2afb09c162574c84af # v1.21.2 with: node-version: 22 run-install: false @@ -464,7 +464,7 @@ jobs: echo "version=${tag#v}" >> "$GITHUB_OUTPUT" - name: Setup Node - uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1 + uses: voidzero-dev/setup-vp@68f39c78aaf39be468cabd2afb09c162574c84af # v1.21.2 with: node-version: 22 run-install: false diff --git a/.github/workflows/worker.yml b/.github/workflows/worker.yml index ac4de67..c5b5763 100644 --- a/.github/workflows/worker.yml +++ b/.github/workflows/worker.yml @@ -38,7 +38,7 @@ jobs: # Only used for the Node runtime here; the Worker itself is a plain pnpm # package driven through corepack, exactly as CI does it. - name: Setup Vite+ - uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1 + uses: voidzero-dev/setup-vp@68f39c78aaf39be468cabd2afb09c162574c84af # v1.21.2 with: node-version: 22 cache: true From 2076f9e1228cd473e4db9208fc5f4e4abe5ebd26 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:09:40 +0000 Subject: [PATCH 105/106] fix(egui): recover migration and refresh external storage Keep release panic recovery enabled for legacy LevelDB reads and background jobs. Refresh external stores on focus and restoration without replacing local edits, and retain arrow focus in text inputs. Cover these paths with headless regressions and a release migration smoke check. Update the webpki certificate patches required by cargo audit. --- .github/workflows/ci.yml | 6 + Cargo.lock | 8 +- Cargo.toml | 4 +- crates/accshift-app/Cargo.toml | 3 + .../examples/legacy_storage_smoke.rs | 55 ++++ crates/accshift-egui/src/app/accounts.rs | 20 ++ .../accshift-egui/src/app/external_refresh.rs | 250 ++++++++++++++ crates/accshift-egui/src/app/mod.rs | 4 + crates/accshift-egui/src/app/tests.rs | 2 + .../src/app/tests/native_regressions.rs | 309 ++++++++++++++++++ .../src/ui/components/controls/text_input.rs | 12 - 11 files changed, 656 insertions(+), 17 deletions(-) create mode 100644 crates/accshift-app/examples/legacy_storage_smoke.rs create mode 100644 crates/accshift-egui/src/app/external_refresh.rs create mode 100644 crates/accshift-egui/src/app/tests/native_regressions.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40f619f..ad78b69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,12 @@ jobs: - name: Rust tests run: cargo test --workspace --locked + # The test harness always unwinds. Run the real Chromium reader as a + # binary too, so the release panic strategy cannot break recovery. + - name: Legacy migration release smoke + if: matrix.os == 'ubuntu-latest' + run: cargo run --release --locked -p accshift-app --example legacy_storage_smoke + - name: Cargo audit if: matrix.os == 'ubuntu-latest' run: | diff --git a/Cargo.lock b/Cargo.lock index 016fdda..aa20e0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4078,9 +4078,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -5415,9 +5415,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" dependencies = [ "rustls-pki-types", ] diff --git a/Cargo.toml b/Cargo.toml index d392f95..ed9ed2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,7 +66,9 @@ epaint = { path = "crates/accshift-egui/vendor/epaint" } # Built for size: a smaller exe reaches `main` sooner (crates/accshift-egui # build.rs), except the per-frame hot path below. [profile.release] -panic = "abort" +# Corrupt legacy databases and failed background jobs recover through +# catch_unwind. Keep that recovery enabled in the shipped binaries. +panic = "unwind" codegen-units = 1 lto = "fat" opt-level = "s" diff --git a/crates/accshift-app/Cargo.toml b/crates/accshift-app/Cargo.toml index a6e4026..7a2e2d8 100644 --- a/crates/accshift-app/Cargo.toml +++ b/crates/accshift-app/Cargo.toml @@ -100,6 +100,9 @@ osakit = { version = "0.3", features = ["full"] } plist = "1" [dev-dependencies] +# Exercise Chromium's legacy reader on every CI host, including the release +# panic strategy checked by the legacy_storage_smoke example. +rusty-leveldb = { version = "4", default-features = false } # Signs the updater fixtures with a key generated in the test, as the # `tauri signer` command does (tauri-cli signs with this crate). minisign = "0.10" diff --git a/crates/accshift-app/examples/legacy_storage_smoke.rs b/crates/accshift-app/examples/legacy_storage_smoke.rs new file mode 100644 index 0000000..a0f6ee5 --- /dev/null +++ b/crates/accshift-app/examples/legacy_storage_smoke.rs @@ -0,0 +1,55 @@ +//! Run with `cargo run --release -p accshift-app --example legacy_storage_smoke`. +//! Unlike the test harness, this uses the application's release panic strategy. + +use accshift_app::legacy_web_storage::{is_legacy_key, LegacyRead}; + +#[path = "../src/legacy_web_storage/chromium.rs"] +#[allow(dead_code)] +mod chromium; + +fn main() { + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/legacy_web_storage/clean"); + let scratch = tempfile::tempdir().expect("create migration fixture directory"); + let mut originals = Vec::new(); + for entry in std::fs::read_dir(fixture).unwrap() { + let entry = entry.unwrap(); + let bytes = std::fs::read(entry.path()).unwrap(); + let path = scratch.path().join(entry.file_name()); + std::fs::write(&path, &bytes).unwrap(); + originals.push((path, bytes)); + } + let read = chromium::read_leveldb_origins(scratch.path(), &["http://localhost:1420"]); + assert!(!read.storage.is_empty(), "clean fixture: {:?}", read.notes); + + let (table, bytes) = originals + .iter_mut() + .find(|(path, _)| path.extension().is_some_and(|ext| ext == "ldb")) + .expect("fixture has a LevelDB table"); + bytes.truncate(bytes.len() / 2); + std::fs::write(table, bytes).unwrap(); + + let read = chromium::read_leveldb(scratch.path()); + assert!( + read.storage.is_empty(), + "truncated table must not migrate data" + ); + assert!( + !read.notes.is_empty(), + "corruption must return a diagnostic: {:?}", + read.notes + ); + assert_eq!( + std::fs::read_dir(scratch.path()).unwrap().count(), + originals.len() + ); + for (path, bytes) in originals { + assert_eq!( + std::fs::read(&path).unwrap(), + bytes, + "{} changed", + path.display() + ); + } + println!("legacy migration: clean fixture read, corrupt table recovered, source unchanged"); +} diff --git a/crates/accshift-egui/src/app/accounts.rs b/crates/accshift-egui/src/app/accounts.rs index 0ca44bd..c27ddce 100644 --- a/crates/accshift-egui/src/app/accounts.rs +++ b/crates/accshift-egui/src/app/accounts.rs @@ -402,6 +402,26 @@ impl Accounts { }); } + /// Re-seed visible avatars from the refreshed stores, without forcing + /// network profile requests on every focus event. + pub(super) fn prepare_visible_accounts(&mut self, stores: &ClientStores, tab: &str) { + let Some(adapter) = self.registry.adapter(tab) else { + return; + }; + let caches = Arc::clone(self.loading.caches()); + let hooks = accshift_app::accounts::loading::AdapterHooks { + adapter: &adapter, + stores, + caches: &caches, + expiry: caches.expiry(stores.cache_duration_ms(), self.clock.now_ms()), + }; + self.loading.loader.prepare_visible_accounts( + Some(&self.lists.visible_account_ids), + false, + &hooks, + ); + } + /// `navigateTo(folderId)` without history: the folder, its items. pub fn navigate_to(&mut self, stores: &ClientStores, tab: &str, folder: Option) { self.navigation.set_current_folder_id(folder); diff --git a/crates/accshift-egui/src/app/external_refresh.rs b/crates/accshift-egui/src/app/external_refresh.rs new file mode 100644 index 0000000..e5727eb --- /dev/null +++ b/crates/accshift-egui/src/app/external_refresh.rs @@ -0,0 +1,250 @@ +//! Focus and visibility restoration refresh the same stores as the web shell. +//! Only backend reads run on workers: begin/finish_refresh stay on the UI +//! thread so local edits made while a snapshot is loading keep their revisions. + +use accshift_app::accounts::loader::LoadOptions; +use accshift_app::lifecycle::app_shell::{plan_external_refresh, RefreshStep}; +use accshift_app::storage::{RefreshPlan, STORAGE_TARGET_CUSTOM_THEMES}; +use accshift_app::types::{ClientStorageSnapshot, StorageManifest}; + +use super::*; + +#[derive(Default)] +pub(super) struct ExternalRefresh { + window: Option<(bool, bool)>, + job: Option, +} + +enum RefreshJob { + Manifest(Pending>), + Snapshot { + plan: RefreshPlan, + pending: Pending>, + }, + Accounts, +} + +struct RefreshContents { + snapshot: Option, + themes: Option>, +} + +impl AppModel { + pub(super) fn tick_external_refresh(&mut self, ctx: &Context) { + if self.booted.is_none() { + return; + } + let window = ctx.input(|i| { + let vp = i.viewport(); + ( + vp.focused.unwrap_or(i.focused), + vp.minimized.unwrap_or(false), + ) + }); + let trigger = self + .external_refresh + .window + .replace(window) + .is_some_and(|previous| (!previous.0 && window.0) || (previous.1 && !window.1)); + // The pending stage is the web lifecycle's in-flight guard. A single + // restoration can produce both focus and visibility notifications. + if trigger && self.external_refresh.job.is_none() { + self.external_refresh.job = Some(RefreshJob::Manifest( + self.jobs.run(|backend| backend.get_storage_manifest()), + )); + } + let Some(job) = self.external_refresh.job.take() else { + return; + }; + self.external_refresh.job = match job { + RefreshJob::Manifest(mut pending) => match pending.poll() { + Poll::Waiting => Some(RefreshJob::Manifest(pending)), + Poll::Ready(Ok(manifest)) => self + .booted + .as_mut() + .unwrap() + .stores + .begin_refresh(&manifest) + .map(|plan| { + let snapshot_needed = plan.needs_snapshot(); + let themes_needed = plan + .changed + .iter() + .any(|id| id == STORAGE_TARGET_CUSTOM_THEMES); + let pending = self.jobs.run(move |backend| { + let snapshot = if snapshot_needed { + Some(backend.load_client_storage_snapshot()?) + } else { + None + }; + let themes = if themes_needed { + // A missing themes directory is harmless, + // as loadCustomThemes was in the web shell. + backend.list_custom_themes().ok().map(|themes| { + themes + .iter() + .filter_map(|t| serde_json::to_value(t).ok()) + .filter_map(|v| serde_json::from_value(v).ok()) + .collect() + }) + } else { + None + }; + Ok(RefreshContents { snapshot, themes }) + }); + RefreshJob::Snapshot { plan, pending } + }), + Poll::Ready(Err(error)) => { + self.external_refresh_failed(error.message); + None + } + Poll::Gone => { + self.external_refresh_failed("manifest job panicked".into()); + None + } + }, + RefreshJob::Snapshot { plan, mut pending } => match pending.poll() { + Poll::Waiting => Some(RefreshJob::Snapshot { plan, pending }), + Poll::Ready(Ok(contents)) => { + let b = self.booted.as_mut().unwrap(); + if let Some(themes) = contents.themes { + b.registry.apply_custom_theme_payloads(&themes); + let mut context = b.stores.context().clone(); + context.theme_ids = + b.registry.all_themes().into_iter().map(|t| t.id).collect(); + b.stores.set_context(context); + // ThemeKey counts themes, so editing one existing file + // needs an explicit recomputation too. + if b.theme_preview.is_none() { + b.theme = compute_theme( + &b.registry, + &b.stores, + b.runtime_os, + self.os_reduced_motion, + self.liquid.present(), + ); + } + } + let changed = match contents.snapshot { + Some(snapshot) => b.stores.finish_refresh(plan, snapshot), + None => plan.other_targets, + }; + self.apply_external_refresh(ctx, &changed); + self.booted + .as_ref() + .unwrap() + .accounts + .loading + .loads_in_flight() + .then_some(RefreshJob::Accounts) + } + Poll::Ready(Err(error)) => { + self.external_refresh_failed(error.message); + None + } + Poll::Gone => { + self.external_refresh_failed("snapshot job panicked".into()); + None + } + }, + RefreshJob::Accounts => self + .booted + .as_ref() + .unwrap() + .accounts + .loading + .loads_in_flight() + .then_some(RefreshJob::Accounts), + }; + } + + fn external_refresh_failed(&self, message: String) { + drop(self.jobs.run(move |backend| { + backend.log_app_event( + "error", + "frontend.storage.refresh", + "Failed to refresh external storage state", + Some(&message), + ) + })); + } + + fn apply_external_refresh(&mut self, ctx: &Context, changed: &[String]) { + self.sync_settings(ctx); + let b = self.booted.as_mut().unwrap(); + let steps = plan_external_refresh( + changed, + b.stores.settings(), + &b.nav.active_tab, + &b.accounts.registry, + accounts::app_os(b.runtime_os), + b.accounts.add_flow.is_active(), + ); + let mut tab_changed = false; + for step in steps { + match step { + RefreshStep::Log { details } => { + drop(self.jobs.run(move |backend| { + backend.log_app_event( + "info", + "frontend.storage.refresh", + "Detected external storage changes", + Some(&details), + ) + })); + } + RefreshStep::CancelAddFlow => { + b.accounts.cancel_add_flow(&self.jobs, &b.nav.active_tab) + } + RefreshStep::SetActiveTab(tab) => { + // switch_tab combines the preceding priming and loader + // reset and also points the loader at the new adapter. + b.accounts.loading.switch_tab(&b.accounts.registry, &tab); + b.nav.active_tab = tab; + tab_changed = true; + } + RefreshStep::ClearCurrentFolder => { + b.accounts.navigation.set_current_folder_id(None) + } + RefreshStep::RefreshPersonas => b.personas.refresh(&b.stores), + RefreshStep::RefreshCurrentItems => b + .accounts + .navigation + .refresh_current_items(&b.stores, &b.nav.active_tab), + RefreshStep::PrepareVisibleAccounts => b + .accounts + .prepare_visible_accounts(&b.stores, &b.nav.active_tab), + RefreshStep::LoadAccounts { + force: silent, + warnings, + } => { + b.accounts.load( + &self.jobs, + &mut b.stores, + &b.nav.active_tab, + LoadOptions { + silent, + show_refreshed_toast: false, + force_refresh: false, + check_bans: warnings, + defer_background: tab_changed, + }, + ); + } + // Themes were read before finish_refresh sanitized settings; + // sync_settings applied settings above. The typed stores and + // display pipeline read view mode, notes and colors each frame; + // layout is immediate and native navigation has no web history. + RefreshStep::LoadCustomThemes + | RefreshStep::RefreshSettings + | RefreshStep::SyncViewMode + | RefreshStep::BumpCardColorVersion + | RefreshStep::BumpCardNoteVersion + | RefreshStep::QueueGridPadding + | RefreshStep::ReplaceHistory(_) + | RefreshStep::ResetVisiblePrimeState + | RefreshStep::ClearLoaderForPlatformChange => {} + } + } + } +} diff --git a/crates/accshift-egui/src/app/mod.rs b/crates/accshift-egui/src/app/mod.rs index e77a525..717d87b 100644 --- a/crates/accshift-egui/src/app/mod.rs +++ b/crates/accshift-egui/src/app/mod.rs @@ -26,6 +26,7 @@ pub mod avatars; pub mod clock; mod deep_link; mod drag; +mod external_refresh; pub(crate) mod keys; pub mod onboarding; mod palette; @@ -408,6 +409,7 @@ pub struct AppModel { boot_error: Option, booted: Option, saves: Vec<(StoreId, Pending>>)>, + external_refresh: external_refresh::ExternalRefresh, /// `createUiScale`: the zoom shortcuts and the debounced save. ui_scale: accshift_app::lifecycle::ui_scale::UiScale, /// `uiScalePercent` changed by a shortcut, until it is saved. @@ -557,6 +559,7 @@ impl AppModel { boot_error: None, booted: None, saves: Vec::new(), + external_refresh: Default::default(), ui_scale: Default::default(), ui_scale_live: None, stage_raster: ui_scale::StageRaster::Unknown, @@ -941,6 +944,7 @@ impl AppModel { } } } + self.tick_external_refresh(&ctx); self.sync_settings(&ctx); self.tick_ui_scale(&ctx, Instant::now()); self.tick_secure(&ctx); diff --git a/crates/accshift-egui/src/app/tests.rs b/crates/accshift-egui/src/app/tests.rs index 3b69dba..e8f2991 100644 --- a/crates/accshift-egui/src/app/tests.rs +++ b/crates/accshift-egui/src/app/tests.rs @@ -11,6 +11,8 @@ use accshift_core::AppContext; use super::*; +mod native_regressions; + struct Scratch { root: PathBuf, } diff --git a/crates/accshift-egui/src/app/tests/native_regressions.rs b/crates/accshift-egui/src/app/tests/native_regressions.rs new file mode 100644 index 0000000..b8d3b10 --- /dev/null +++ b/crates/accshift-egui/src/app/tests/native_regressions.rs @@ -0,0 +1,309 @@ +use super::*; +use egui::{pos2, vec2, Id, RawInput, Rect, Sense, ViewportId}; +use serde_json::json; + +fn external_app(ctx: &Context) -> (AppModel, Arc) { + external_app_with(ctx, |_| {}) +} + +fn external_app_with( + ctx: &Context, + configure: impl FnOnce(&mut Value), +) -> (AppModel, Arc) { + let mut fixture: Value = + serde_json::from_str(include_str!("../../../parity/data/empty.json")).unwrap(); + fixture["spec"]["switchDelayMs"] = json!(0); + let boot = &mut fixture["commands"]["get_boot_payload"][0]["result"]; + boot["storageSnapshot"]["stores"]["client.settings"]["themeId"] = json!("dark"); + boot["storageSnapshot"]["stores"]["client.settings"]["animations"] = json!("off"); + let mut snapshot = boot["storageSnapshot"].clone(); + snapshot["stores"]["client.settings"]["language"] = json!("fr"); + snapshot["stores"]["client.view-mode"] = json!("list"); + snapshot["stores"]["client.account-card-notes"] = json!({"external": "external note"}); + snapshot["manifest"]["stores"] = json!({ + "client.settings": "settings-2", + "client.view-mode": "view-2", + "client.account-card-notes": "notes-2", + "app.config.local": "config-2" + }); + fixture["commands"]["get_storage_manifest"][0]["result"] = snapshot["manifest"].clone(); + fixture["commands"]["load_client_storage_snapshot"][0]["result"] = snapshot; + configure(&mut fixture); + let mock = Arc::new(MockBackend::from_json(&fixture.to_string(), None).unwrap()); + let mut app = AppModel::with_backend(ctx, mock.clone(), Some(mock.clone()), Clock::wall()); + app.wait_boot(ctx).unwrap(); + (app, mock) +} + +#[test] +fn native_external_settings_disable_the_active_platform() { + let ctx = Context::default(); + let (mut app, _) = external_app_with(&ctx, |fixture| { + let settings = &mut fixture["commands"]["load_client_storage_snapshot"][0]["result"] + ["stores"]["client.settings"]; + settings["enabledPlatforms"] = json!(["riot"]); + settings["defaultPlatformId"] = json!("riot"); + }); + frame(&mut app, &ctx, true, false); + app.booted + .as_mut() + .unwrap() + .accounts + .navigation + .set_current_folder_id(Some("old-steam-folder".into())); + frame(&mut app, &ctx, false, false); + frame(&mut app, &ctx, true, false); + pump_until(&mut app, &ctx, |a| { + a.booted().unwrap().nav.active_tab == "riot" + }); + let b = app.booted().unwrap(); + assert_eq!(b.nav.active_tab, "riot"); + assert_eq!(b.accounts.loading.loader.adapter_id(), Some("riot")); + assert_eq!(b.accounts.navigation.current_folder_id(), None); +} + +#[test] +fn native_external_custom_theme_edits_reapply_with_the_same_theme_count() { + let ctx = Context::default(); + let (mut app, _) = external_app_with(&ctx, |fixture| { + let theme = json!({"schemaVersion": 1, "id": "custom-test", "name": "Test", "colorScheme": "dark", "tokens": {"bgCard": "#112233"}}); + let boot = &mut fixture["commands"]["get_boot_payload"][0]["result"]; + boot["customThemes"] = json!([theme.clone()]); + boot["storageSnapshot"]["stores"]["client.settings"]["themeId"] = json!("custom-test"); + let snapshot = &mut fixture["commands"]["load_client_storage_snapshot"][0]["result"]; + snapshot["stores"]["client.settings"]["themeId"] = json!("custom-test"); + snapshot["manifest"]["stores"]["app.themes"] = json!("theme-2"); + fixture["commands"]["get_storage_manifest"][0]["result"] = snapshot["manifest"].clone(); + let mut next = theme; + next["tokens"]["bgCard"] = json!("#445566"); + fixture["commands"]["list_custom_themes"][0]["result"] = json!([next]); + }); + let before = app.booted().unwrap().theme.vars.bg_card; + assert_eq!( + app.booted().unwrap().stores.settings().theme_id, + "custom-test" + ); + frame(&mut app, &ctx, true, false); + frame(&mut app, &ctx, false, false); + frame(&mut app, &ctx, true, false); + pump_until(&mut app, &ctx, |a| { + a.booted().unwrap().theme.vars.bg_card != before + }); + assert_ne!(app.booted().unwrap().theme.vars.bg_card, before); + assert_eq!(app.booted().unwrap().registry.all_themes().len(), 7); +} + +fn input(focused: bool, minimized: bool) -> RawInput { + let mut input = RawInput { + focused, + screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 680.0))), + ..Default::default() + }; + let vp = input.viewports.get_mut(&ViewportId::ROOT).unwrap(); + vp.focused = Some(focused); + vp.minimized = Some(minimized); + input +} + +fn frame(app: &mut AppModel, ctx: &Context, focused: bool, minimized: bool) { + let mut output = ctx.run_ui(input(focused, minimized), |ui| app.frame(ui)); + output.textures_delta.clear(); +} + +fn pump_until(app: &mut AppModel, ctx: &Context, condition: impl Fn(&AppModel) -> bool) { + let deadline = Instant::now() + std::time::Duration::from_secs(2); + while !condition(app) && Instant::now() < deadline { + frame(app, ctx, true, false); + std::thread::sleep(std::time::Duration::from_millis(1)); + } +} + +#[test] +fn native_external_storage_on_focus_refreshes_stores_and_accounts() { + let ctx = Context::default(); + let (mut app, mock) = external_app(&ctx); + // Finish the initial account load before the external CLI switch. + pump_until(&mut app, &ctx, |a| a.booted().unwrap().accounts.settled()); + mock.platform_switch_account("steam", "external", &json!({})) + .unwrap(); + frame(&mut app, &ctx, false, false); + frame(&mut app, &ctx, true, false); + pump_until(&mut app, &ctx, |a| { + let b = a.booted().unwrap(); + b.stores.account_card_note("external") == "external note" + && b.accounts.loading.loader.current_account() == "external" + }); + let b = app.booted().unwrap(); + assert_eq!(b.stores.account_card_note("external"), "external note"); + assert_eq!(b.stores.view_mode(), ViewMode::List); + assert_eq!(b.locale.code(), "fr"); + assert_eq!(b.accounts.loading.loader.current_account(), "external"); +} + +#[test] +fn native_external_storage_on_restore_preserves_dirty_local_values() { + let ctx = Context::default(); + let (mut app, _) = external_app(&ctx); + frame(&mut app, &ctx, true, false); + app.booted + .as_mut() + .unwrap() + .stores + .set_account_card_note("external", "local draft"); + // Restoration must work even if the viewport still reports focus. + frame(&mut app, &ctx, true, true); + frame(&mut app, &ctx, true, false); + pump_until(&mut app, &ctx, |a| { + a.booted().unwrap().stores.view_mode() == ViewMode::List + }); + let b = app.booted().unwrap(); + assert_eq!(b.stores.view_mode(), ViewMode::List); + assert_eq!(b.stores.account_card_note("external"), "local draft"); +} + +#[test] +fn native_external_snapshot_keeps_edits_made_after_the_manifest_read() { + let ctx = Context::default(); + let (mut app, mock) = external_app(&ctx); + frame(&mut app, &ctx, true, false); + mock.latency(30); + frame(&mut app, &ctx, false, false); + frame(&mut app, &ctx, true, false); + // Other targets are recorded by begin_refresh before the snapshot job + // starts. This observes that boundary without relying on a sleep race. + pump_until(&mut app, &ctx, |a| { + a.booted() + .unwrap() + .stores + .storage() + .manifest() + .stores + .get("app.config.local") + .is_some_and(|v| v == "config-2") + }); + assert_eq!(app.booted().unwrap().stores.view_mode(), ViewMode::Grid); + app.booted + .as_mut() + .unwrap() + .stores + .set_account_card_note("external", "typed during load"); + pump_until(&mut app, &ctx, |a| { + a.booted().unwrap().stores.view_mode() == ViewMode::List + }); + let b = app.booted().unwrap(); + assert_eq!(b.stores.view_mode(), ViewMode::List); + assert_eq!(b.stores.account_card_note("external"), "typed during load"); +} + +#[test] +fn native_external_theme_read_failure_does_not_discard_other_store_changes() { + let ctx = Context::default(); + let (mut app, mock) = external_app_with(&ctx, |fixture| { + fixture["commands"]["get_storage_manifest"][0]["result"]["stores"]["app.themes"] = + json!("theme-2"); + }); + mock.fail("list_custom_themes", Some("missing theme folder")); + frame(&mut app, &ctx, true, false); + frame(&mut app, &ctx, false, false); + frame(&mut app, &ctx, true, false); + pump_until(&mut app, &ctx, |a| { + a.booted().unwrap().stores.view_mode() == ViewMode::List + }); + assert_eq!(app.booted().unwrap().stores.view_mode(), ViewMode::List); + assert_eq!( + app.booted().unwrap().stores.account_card_note("external"), + "external note" + ); +} + +#[test] +fn native_external_account_change_without_client_stores_keeps_the_manifest_schema() { + let ctx = Context::default(); + let (mut app, mock) = external_app_with(&ctx, |fixture| { + fixture["commands"]["get_storage_manifest"][0]["result"] = json!({ + "schemaVersion": 1, "stores": {"app.config.local": "config-2"} + }); + }); + pump_until(&mut app, &ctx, |a| a.booted().unwrap().accounts.settled()); + // A config-only refresh must not need a client snapshot at all. + mock.fail( + "load_client_storage_snapshot", + Some("unexpected snapshot read"), + ); + mock.platform_switch_account("steam", "external", &json!({})) + .unwrap(); + frame(&mut app, &ctx, false, false); + frame(&mut app, &ctx, true, false); + pump_until(&mut app, &ctx, |a| { + a.booted() + .unwrap() + .accounts + .loading + .loader + .current_account() + == "external" + }); + let b = app.booted().unwrap(); + assert_eq!(b.accounts.loading.loader.current_account(), "external"); + assert_eq!(b.stores.storage().manifest().schema_version, 1); + assert_eq!(b.stores.view_mode(), ViewMode::Grid); +} + +#[test] +fn native_text_input_keeps_focus_for_vertical_arrows() { + use crate::ui::components::controls::text_input::{self, InputKind, TextInputProps}; + for key in [egui::Key::ArrowDown, egui::Key::ArrowUp] { + let ctx = Context::default(); + let (app, _) = external_app(&ctx); + let vars = &app.booted().unwrap().theme.vars; + let style = text_input::settings_style(vars); + let field = Id::unique("settings-input"); + let mut value = String::from("editable"); + for pass in 0..4 { + let mut raw = input(true, false); + if pass == 3 { + raw.events.push(Event::Key { + key, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }); + } + let mut output = ctx.run_ui(raw, |ui| { + for (name, y) in [("above", 20.0), ("below", 220.0)] { + ui.interact( + Rect::from_min_size(pos2(50.0, y), vec2(200.0, 40.0)), + Id::unique(name), + Sense::click(), + ); + } + if pass == 0 { + ui.memory_mut(|m| m.request_focus(field)); + } + text_input::text_input( + ui, + Rect::from_min_size(pos2(50.0, 120.0), vec2(200.0, 40.0)), + field, + &mut value, + &TextInputProps { + style: &style, + kind: InputKind::Text, + placeholder: "", + label: "Settings", + max_len: None, + enabled: true, + vars, + dark: true, + }, + ); + }); + output.textures_delta.clear(); + } + assert!( + ctx.memory(|m| m.has_focus(field)), + "{key:?} moved focus out of the text input" + ); + assert_eq!(value, "editable"); + } +} diff --git a/crates/accshift-egui/src/ui/components/controls/text_input.rs b/crates/accshift-egui/src/ui/components/controls/text_input.rs index 56152bd..31217fc 100644 --- a/crates/accshift-egui/src/ui/components/controls/text_input.rs +++ b/crates/accshift-egui/src/ui/components/controls/text_input.rs @@ -262,18 +262,6 @@ pub fn text_input( let focused = props.enabled && take_focus(ui, &response); if focused { crate::ui::components::focus::mark_text_field(&ctx, id); - // Arrows, Tab and Escape stay with the field's own handling. - ui.memory_mut(|m| { - m.set_focus_lock_filter( - id, - egui::EventFilter { - tab: false, - horizontal_arrows: true, - vertical_arrows: false, - escape: false, - }, - ) - }); } let mut events = TextInputEvents { focused, From e177133d714ab8fe141ca99f441aa3da7f60d7ba Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:18:34 +0000 Subject: [PATCH 106/106] fix(ci): install Wayland headers for the migration smoke --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad78b69..242be56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y \ + libwayland-dev \ libxkbcommon-dev \ pkg-config