From 41fa466410276eb8875a04f42c8a98837ce28988 Mon Sep 17 00:00:00 2001 From: Niko Marmeladkov Date: Sun, 18 Oct 2026 09:18:57 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=B0=D1=8F=20=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=81=D0=B8=D1=8F=20Lark=20DE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Композитор Wayland на smithay: сессия, xdg-shell, ввод, рендеринг через OpenGL ES с отслеживанием повреждений. Протоколы xdg-decoration, viewporter и presentation-time. Окна в стиле Windows 11: заголовок с названием и кнопками, перетаскивание, изменение размера за края и углы, сворачивание и разворачивание. Панель задач с подписями окон, часами и переключателем рабочих областей. Меню «Пуск» со списком приложений и поиском в текущей раскладке. Переключение окон по Alt+Tab с подтверждением при отпускании Alt. Собственный растровщик текста на fontdb + fontdue с гамма-коррекцией покрытия: без хинтинга штрихи на мелком кегле выходят рваными. Приложения запускаются в отдельной сессии D-Bus, иначе однопользовательские программы находят уже запущенный экземпляр во внешней сессии и открывают окно не в Lark. 127 тестов, clippy без замечаний. --- .gitignore | 18 + Cargo.lock | 3534 ++++++++++++++++++++++++++++++++++ Cargo.toml | 28 + README.md | 83 + clippy.toml | 6 + docs/ARCHITECTURE.md | 146 ++ docs/BUILD.md | 295 +++ docs/ROADMAP.md | 94 + locales/en-US/app.ftl | 53 + locales/ru-RU/app.ftl | 53 + scripts/dev-run.sh | 120 ++ src/apps/files.rs | 27 + src/apps/mod.rs | 5 + src/apps/settings.rs | 13 + src/apps/terminal.rs | 13 + src/compositor/decoration.rs | 718 +++++++ src/compositor/input.rs | 418 ++++ src/compositor/keys.rs | 225 +++ src/compositor/mod.rs | 2919 ++++++++++++++++++++++++++++ src/config.rs | 369 ++++ src/i18n.rs | 98 + src/lib.rs | 4 + src/main.rs | 126 ++ src/services/audio.rs | 15 + src/services/mod.rs | 23 + src/services/network.rs | 15 + src/services/notification.rs | 16 + src/services/power.rs | 16 + src/services/session.rs | 16 + src/shell/clock.rs | 200 ++ src/shell/desktop.rs | 47 + src/shell/mod.rs | 760 ++++++++ src/shell/notifications.rs | 59 + src/shell/panel.rs | 1148 +++++++++++ src/shell/start_menu.rs | 909 +++++++++ src/shell/switcher.rs | 440 +++++ src/shell/tray.rs | 23 + src/text.rs | 784 ++++++++ tests/smoke.rs | 102 + 39 files changed, 13938 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 clippy.toml create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/BUILD.md create mode 100644 docs/ROADMAP.md create mode 100644 locales/en-US/app.ftl create mode 100644 locales/ru-RU/app.ftl create mode 100755 scripts/dev-run.sh create mode 100644 src/apps/files.rs create mode 100644 src/apps/mod.rs create mode 100644 src/apps/settings.rs create mode 100644 src/apps/terminal.rs create mode 100644 src/compositor/decoration.rs create mode 100644 src/compositor/input.rs create mode 100644 src/compositor/keys.rs create mode 100644 src/compositor/mod.rs create mode 100644 src/config.rs create mode 100644 src/i18n.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/services/audio.rs create mode 100644 src/services/mod.rs create mode 100644 src/services/network.rs create mode 100644 src/services/notification.rs create mode 100644 src/services/power.rs create mode 100644 src/services/session.rs create mode 100644 src/shell/clock.rs create mode 100644 src/shell/desktop.rs create mode 100644 src/shell/mod.rs create mode 100644 src/shell/notifications.rs create mode 100644 src/shell/panel.rs create mode 100644 src/shell/start_menu.rs create mode 100644 src/shell/switcher.rs create mode 100644 src/shell/tray.rs create mode 100644 src/text.rs create mode 100644 tests/smoke.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bbccef4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Артефакты сборки +/target + +# Резервные копии и временные файлы редакторов +*.swp +*.swo +*~ +*.orig +*.rej + +# Снимки экрана и журналы от scripts/dev-run.sh +/*.png +/*.ppm +/*.log + +# Файлы окружения +.env +.env.local diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6e4c710 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3534 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "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 = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[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.2", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.21", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "appendlist" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e149dc73cd30538307e7ffa2acd3d2221148eaeed4871f246657b1c3eaa1cbd2" + +[[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 = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[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 = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "bytes", + "cfg_aliases", +] + +[[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.6", +] + +[[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.2", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix 1.1.5", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.5", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "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", + "rand_core", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +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 = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "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 = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[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.2", + "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.6", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "drm" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "drm-ffi", + "drm-fourcc", + "libc", + "rustix 0.38.44", +] + +[[package]] +name = "drm-ffi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690" +dependencies = [ + "drm-sys", + "rustix 1.1.5", +] + +[[package]] +name = "drm-fourcc" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" + +[[package]] +name = "drm-sys" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8" +dependencies = [ + "libc", + "linux-raw-sys 0.9.4", +] + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", +] + +[[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 = "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 = "fluent" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb74634707bebd0ce645a981148e8fb8c7bccd4c33c652aeffd28bf2f96d555a" +dependencies = [ + "fluent-bundle", + "unic-langid", +] + +[[package]] +name = "fluent-bundle" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe0a21ee80050c678013f82edf4b705fe2f26f1f9877593d13198612503f493" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash 1.1.0", + "self_cell 0.10.3", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a530c4694a6a8d528794ee9bbd8ba0122e779629ac908d15ad5a7ae7763a33d" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3a6f9af55fb97ad673fb7a69533eb2f967648a06fa21f8c9bb2cd6d33975716" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2 0.9.11", + "slotmap", + "tinyvec", + "ttf-parser 0.24.1", +] + +[[package]] +name = "fontdue" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7894823fa221401399e2598f8b63f81ac77ff5c63248b7656779bff1632d7d3d" +dependencies = [ + "hashbrown 0.15.5", + "ttf-parser 0.25.1", +] + +[[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.6", +] + +[[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 = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[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-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gbm" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce852e998d3ca5e4a97014fb31c940dc5ef344ec7d364984525fd11e8a547e6a" +dependencies = [ + "bitflags 2.13.2", + "drm", + "drm-fourcc", + "gbm-sys", + "libc", + "wayland-backend", + "wayland-server", +] + +[[package]] +name = "gbm-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13a5f2acc785d8fb6bf6b7ab6bfb0ef5dad4f4d97e8e70bb8e470722312f76f" +dependencies = [ + "libc", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.5", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[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", + "libc", + "r-efi 6.0.0", + "rand_core", +] + +[[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 = "glam" +version = "0.33.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35b4e837a1d133645d1dcf283e146a4b9901e390a1c13545f959cfed3a72f24f" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[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 = "input" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9793345a65d71317763a33066b5d8351f8760dde8d4930fe9e39b5f14a7959d" +dependencies = [ + "bitflags 2.13.2", + "input-sys", + "libc", + "udev", +] + +[[package]] +name = "input-sys" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eee07d8e02bd95bf52b2e642cf13d33701b94c6e4b04fbf1d1fb07e9cb19e7" + +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "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.21", + "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.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.13.2", + "serde", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "lark" +version = "0.1.0" +dependencies = [ + "anyhow", + "dirs", + "fluent", + "fontdb", + "fontdue", + "libc", + "serde", + "smithay", + "toml", + "tracing", + "tracing-subscriber", + "unic-langid", + "wayland-protocols", + "wayland-server", + "winit 0.30.13", + "xkbcommon 0.7.0", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "plain", + "redox_syscall 0.9.4", +] + +[[package]] +name = "libseat" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6656c69b6e0366ffb83faa232f3e4fda5fc91161722d140f2c95ce2e90b1ef31" +dependencies = [ + "errno", + "libseat-sys", + "log", +] + +[[package]] +name = "libseat-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a484fa48be5f62a8d3ffed767779d0ab808cfead91de98ef4362d469097dfb" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[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.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[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 = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a5a03cefb0d953ec0be133036f14e109412fa594edc2f77227249db66cc3ed" +dependencies = [ + "libc", +] + +[[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 = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "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 = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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.2", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[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.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 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.2", + "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.2", + "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.2", + "block2 0.6.2", + "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.2", + "libc", + "objc2-core-foundation", +] + +[[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-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[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.2", + "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.2", + "block2 0.6.2", + "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.2", + "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.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[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.2", + "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", + "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.2", + "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.2", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "orbclient" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01f140a738f180203d7912dfd369936fff2fb0ad6f426c0aa007ed3e2b443e6b" +dependencies = [ + "libredox", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser 0.25.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[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 = "pixman" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea217d496c19ac0a8e502b37078e1f683d16344adee9eb247a5d57c165e1edf" +dependencies = [ + "drm-fourcc", + "paste", + "pixman-sys", + "thiserror 1.0.69", +] + +[[package]] +name = "pixman-sys" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a0483e89e81d7915defe83c51f23f6800594d64f6f4a21253ce87fd8444ada" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[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 0.5.3", + "pin-project-lite", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.15+spec-1.1.0", +] + +[[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" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[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.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_event" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5018d583d6d2f5499352aea8d177e9067d1eb03ab17c78169d5ba7a30001b15" +dependencies = [ + "bitflags 2.13.2", + "libredox", +] + +[[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.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reis" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b967ec6489a42067a20724f11987bb96178ea796873cbe1afd25fb93ee6f85f" +dependencies = [ + "calloop 0.14.4", + "enumflags2", + "log", + "rustix 1.1.5", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[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.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2 0.9.11", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + +[[package]] +name = "self_cell" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14e4d63b804dc0c7ec4a1e52bcb63f02c7ac94476755aa579edac21e01f915d" +dependencies = [ + "self_cell 1.3.0", +] + +[[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.6", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[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 = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[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.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "smithay" +version = "0.7.0" +source = "git+https://github.com/Smithay/smithay#aaa755934b73e35580e573ddf1f733bd4d761bf1" +dependencies = [ + "aliasable", + "appendlist", + "ash", + "bitflags 2.13.2", + "calloop 0.14.4", + "cc", + "cursor-icon", + "downcast-rs", + "drm", + "drm-ffi", + "drm-fourcc", + "drm-sys", + "encoding_rs", + "errno", + "gbm", + "gl_generator", + "glam", + "indexmap", + "input", + "libc", + "libloading", + "libseat", + "pixman", + "pkg-config", + "portable-atomic", + "profiling", + "rand", + "reis", + "rustix 1.1.5", + "scopeguard", + "siphasher", + "smallvec", + "tempfile", + "thiserror 2.0.21", + "tracing", + "udev", + "wayland-backend", + "wayland-client", + "wayland-cursor", + "wayland-egl", + "wayland-protocols", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-server", + "wayland-sys", + "winit 0.31.0-beta.3", + "x11rb", + "xkbcommon 0.9.0", +] + +[[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.2", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2 0.9.11", + "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.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dc9ee14b0fdcb535f9556141bacac070c994a977d2240ee455d7438a617f00" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "log", + "memmap2 0.9.11", + "rustix 1.1.5", + "thiserror 2.0.21", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[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.6", +] + +[[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.5", + "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.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[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.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[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.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[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 1.0.4", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[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", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "ttf-parser" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be21190ff5d38e8b4a2d3b6a3ae57f612cc39c96e83cedeaf7abc338a8bac4a" +dependencies = [ + "core_maths", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + +[[package]] +name = "udev" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4e37e9ea4401fc841ff54b9ddfc9be1079b1e89434c1a6a865dd68980f7e9f" +dependencies = [ + "io-lifetimes", + "libc", + "libudev-sys", + "pkg-config", +] + +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "tinystr", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[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 = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +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.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +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.5", + "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.2", + "rustix 1.1.5", + "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.2", + "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.5", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-egl" +version = "0.32.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b97bdb7c49e5bd9b7562f38ff84f0dad47079fdc9e926f691a787f6dbc05451" +dependencies = [ + "wayland-backend", + "wayland-sys", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", + "wayland-server", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20251230.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c6c3e7178e553d093d46999032bc79aafea9dedd71dd702c8ace7bbc25cf13" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", + "wayland-server", +] + +[[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.2", + "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.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", + "wayland-server", +] + +[[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-server" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dde9c29be0f723a573977de51ee455bf3dfa03652730a74f9dd3b337e374d75" +dependencies = [ + "bitflags 2.13.2", + "downcast-rs", + "rustix 1.1.5", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "libc", + "log", + "memoffset", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2 0.9.11", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient 0.3.55", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str 0.2.2", + "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 = "winit" +version = "0.31.0-beta.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f431156a3e4f0f1b35eaaf37674e55838e3652908c8088f570466274d1902e2b" +dependencies = [ + "bitflags 2.13.2", + "cfg_aliases", + "cursor-icon", + "dpi", + "libc", + "raw-window-handle", + "rustix 1.1.5", + "smol_str 0.3.6", + "tracing", + "winit-android", + "winit-appkit", + "winit-common", + "winit-core", + "winit-orbital", + "winit-uikit", + "winit-wayland", + "winit-web", + "winit-win32", + "winit-x11", +] + +[[package]] +name = "winit-android" +version = "0.31.0-beta.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d60d944a564986b9311e325334cc22e7e4cf726dd0ff7475fd925404215af4" +dependencies = [ + "android-activity", + "bitflags 2.13.2", + "dpi", + "ndk", + "raw-window-handle", + "smol_str 0.3.6", + "tracing", + "winit-core", +] + +[[package]] +name = "winit-appkit" +version = "0.31.0-beta.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23343ec0ea7ac6aa72f38009a6ff66fb63bcf6395e0622fba72794d2a762ab42" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "dispatch2", + "dpi", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-video", + "objc2-foundation 0.3.2", + "raw-window-handle", + "smol_str 0.3.6", + "tracing", + "winit-common", + "winit-core", +] + +[[package]] +name = "winit-common" +version = "0.31.0-beta.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e90422ff1c04502aaaac1dad1d099791c2bbb3fbb3f823019e0ce5cdeee04025" +dependencies = [ + "block2 0.6.2", + "dpi", + "memmap2 0.9.11", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "smol_str 0.3.6", + "tracing", + "winit-core", + "x11-dl", + "xkbcommon-dl", +] + +[[package]] +name = "winit-core" +version = "0.31.0-beta.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8af6307236c51847199950c15cf80e0481a250bb96ce9132f5026df4ad3d0f9" +dependencies = [ + "bitflags 2.13.2", + "cursor-icon", + "dpi", + "keyboard-types", + "raw-window-handle", + "smol_str 0.3.6", + "url", + "web-time", +] + +[[package]] +name = "winit-orbital" +version = "0.31.0-beta.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "278a8ae837902a3bc95dd2fd696d680da33f32130eecc29905358c9089cf6c50" +dependencies = [ + "bitflags 2.13.2", + "dpi", + "libredox", + "orbclient 0.4.6", + "raw-window-handle", + "redox_event", + "smol_str 0.3.6", + "tracing", + "winit-core", +] + +[[package]] +name = "winit-uikit" +version = "0.31.0-beta.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83315a3ea1739c8466cf23d35e3b0a2cb20c9515d8857666b633afef484740b5" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "dispatch2", + "dpi", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "raw-window-handle", + "smol_str 0.3.6", + "tracing", + "winit-common", + "winit-core", +] + +[[package]] +name = "winit-wayland" +version = "0.31.0-beta.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b64ceed06de0c45886eef05102106abe1413e703384e337bc57c7e95b71f803" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.14.4", + "cursor-icon", + "dpi", + "foldhash 0.2.0", + "libc", + "memmap2 0.9.11", + "percent-encoding", + "raw-window-handle", + "rustix 1.1.5", + "smithay-client-toolkit 0.21.1", + "smol_str 0.3.6", + "tracing", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "winit-common", + "winit-core", +] + +[[package]] +name = "winit-web" +version = "0.31.0-beta.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f539be45f95cc297bdfb80569aef38d925d64aec4133852a4d3fc5cb3660f1c9" +dependencies = [ + "atomic-waker", + "bitflags 2.13.2", + "concurrent-queue", + "cursor-icon", + "dpi", + "js-sys", + "pin-project", + "raw-window-handle", + "smol_str 0.3.6", + "tracing", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "winit-core", +] + +[[package]] +name = "winit-win32" +version = "0.31.0-beta.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25fb480c41df01d351f656cbf3ad74013f8a9d22bb5b2dcab59f9167e7a0b6ec" +dependencies = [ + "bitflags 2.13.2", + "cursor-icon", + "dpi", + "raw-window-handle", + "smol_str 0.3.6", + "tracing", + "unicode-segmentation", + "url", + "windows-sys 0.61.2", + "winit-common", + "winit-core", +] + +[[package]] +name = "winit-x11" +version = "0.31.0-beta.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5746cd907e514b7895491a1c6f7d0cb70077f9e16928cd74ae6f987ffd96aa" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "calloop 0.14.4", + "cursor-icon", + "dpi", + "libc", + "percent-encoding", + "raw-window-handle", + "rustix 1.1.5", + "smol_str 0.3.6", + "tracing", + "winit-common", + "winit-core", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[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", + "once_cell", + "rustix 1.1.5", + "x11rb-protocol", + "xcursor", +] + +[[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 = "xkbcommon" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13867d259930edc7091a6c41b4ce6eee464328c6ff9659b7e4c668ca20d4c91e" +dependencies = [ + "libc", + "memmap2 0.8.0", + "xkeysym", +] + +[[package]] +name = "xkbcommon" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a974f48060a14e95705c01f24ad9c3345022f4d97441b8a36beb7ed5c4a02d" +dependencies = [ + "libc", + "memmap2 0.9.11", + "xkeysym", +] + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "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 = "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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +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.6", + "synstructure", +] + +[[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 = [ + "serde", + "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.6", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e34b4e9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "lark" +version = "0.1.0" +edition = "2021" +description = "Лёгкая среда рабочего стола на Rust для слабых устройств" +license = "MIT" + +[dependencies] +smithay = { git = "https://github.com/Smithay/smithay", features = ["backend_drm", "backend_egl", "backend_libinput", "backend_udev", "backend_winit", "backend_x11", "desktop", "use_system_lib", "xwayland"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +anyhow = "1" +libc = "0.2" +toml = "0.8" +serde = { version = "1", features = ["derive"] } +xkbcommon = "0.7" +fontdb = "0.22" +fontdue = "0.9" +fluent = "0.16" +unic-langid = "0.9" +dirs = "5" +wayland-server = "0.31" +wayland-protocols = { version = "0.32", features = ["server", "unstable", "staging"] } +winit = "0.30" + +[[bin]] +name = "lark" +path = "src/main.rs" diff --git a/README.md b/README.md new file mode 100644 index 0000000..db529c0 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# Lark DE + +Лёгкая среда рабочего стола на Rust для слабых устройств. + +## Что уже работает + +- **Композитор Wayland** на smithay: сокет, `xdg-shell`, вывод, ввод, + рендеринг через OpenGL ES, отслеживание повреждений +- **Окон в стиле Windows 11**: заголовок с названием и кнопками, + перетаскивание, изменение размера за края и углы +- **Панель задач**: кнопка «Пуск» с логотипом, подписи окон, часы, + переключатель рабочих областей +- **Меню «Пуск»**: список приложений и поиск, набираемый с клавиатуры + в текущей раскладке +- **Управление окнами**: свернуть, развернуть, закрыть, `Alt+Tab` +- **Рабочие области**: четыре по умолчанию, переключение с клавиатуры +- **Свой растровщик текста** на `fontdb` + `fontdue` +- **Приложения запускаются в Lark**, а не во внешней сессии + +Чего пока нет — XWayland, бэкенда DRM/KMS, индикаторов трея и приложений +Фазы 4. Подробности в [дорожной карте](docs/ROADMAP.md). + +## Целевое железо + +Оптимизация под слабые устройства (например, icl bi1011): +- Intel Celeron/Pentium (N-серия) +- 4–8 ГБ RAM +- Встроенная Intel UHD Graphics +- eMMC/SSD + +## Технологический стек + +| Компонент | Крейт | Состояние | +|-----------|-------|-----------| +| Композитор Wayland | smithay | используется | +| Рендеринг | smithay (`GlesRenderer`) | используется | +| Ввод и раскладка | smithay + xkbcommon | используется | +| Текст | fontdb + fontdue | используется | +| Конфигурация | toml + serde | используется | +| Логирование | tracing | используется | +| Мультиязычность | fluent | используется | + +## Структура проекта + +``` +lark/ +├── src/ +│ ├── compositor/ # Wayland композитор, ввод, декорации, раскладка +│ ├── shell/ # Панель, меню «Пуск», переключатель окон, часы +│ ├── services/ # Фоновые службы (заготовки) +│ ├── apps/ # Свои приложения (заготовки) +│ ├── text.rs # Растровщик текста +│ └── config.rs # Конфигурация +├── locales/ # Переводы (.ftl) +├── docs/ # Документация +├── scripts/ # Проверка на виртуальном дисплее +└── tests/ # Интеграционные тесты +``` + +## Быстрый старт + +```bash +cargo build --release +./target/release/lark +``` + +Проверка без дисплея: + +```bash +./scripts/dev-run.sh # поднимает Xvfb и делает снимок экрана +``` + +Подробности — в [сборке и запуске](docs/BUILD.md). + +## Документация + +- [Архитектура](docs/ARCHITECTURE.md) +- [Дорожная карта](docs/ROADMAP.md) +- [Сборка и запуск](docs/BUILD.md) + +## Лицензия + +MIT diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..369c800 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,6 @@ +# Пороги clippy, согласованные с целями проекта. +# +# Композитор устроен как цикл событий, поэтому функции с большим числом +# аргументов закономерны: состояние, рендерер, выход, трекер повреждений. + +too-many-arguments-threshold = 10 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..8cc77b0 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,146 @@ +# Архитектура Lark DE + +## Обзор + +Lark DE — среда рабочего стола на Rust для слабых устройств. Интерфейс +повторяет Windows 11 при минимальном потреблении ресурсов. + +Документ описывает и то, что уже работает, и то, что запланировано. Разница +отмечена явно: иначе план легко принять за текущее состояние. + +## Компоненты + +### 1. Композитор + +Ядро системы — Wayland-композитор на smithay. + +**Реализовано:** + +- Сессия Wayland: сокет создаётся сам, окружение наследуется потомками +- Управление окнами: создание, фокус, подъём, закрытие, очистка реестра +- Рендеринг через `GlesRenderer` smithay с отслеживанием повреждений +- Ввод: клавиатура, указатель, прокрутка; раскладка через xkbcommon +- `xdg-shell` для приложений, `xdg-decoration`, `viewporter`, + `presentation-time` +- Декорации в стиле Windows 11: заголовок с названием, кнопки, + перетаскивание, изменение размера за края и углы +- Рабочие области и переключатель окон по `Alt+Tab` + +**План:** + +- XWayland для приложений X11 +- Бэкенд DRM/KMS для запуска без окна winit +- Эффекты: тени, анимации + +**Ключевые оптимизации:** + +- Ленивая композиция (damage tracking) +- Кэш растеризованных глифов +- Переиспользование буферов + +### 2. Shell + +Интерфейс пользователя в стиле Windows 11. + +**Реализовано:** + +- **Панель** (~48px): кнопка «Пуск» с логотипом, подписи открытых окон, + часы, переключатель рабочих областей +- **Меню «Пуск»**: список приложений, поиск с клавиатуры +- **Переключатель окон**: `Alt+Tab` со списком названий + +**План:** + +- Системный трей: сеть, звук, батарея +- Центр уведомлений +- **Рабочий стол**: иконки, обои, контекстное меню + +### 3. Службы + +Фоновые службы для системной функциональности. Сейчас это заготовки: +потоки запускаются и завершаются, ничего не делая. + +| Служба | Ответственность | Состояние | +|--------|-----------------|-----------| +| powerd | Питание, яркость, сон, батарея | заготовка | +| networkd | Сетевой апплет (NetworkManager) | заготовка | +| audiod | Аудио-микшер (PipeWire) | заготовка | +| sessiond | Автозапуск, блокировка, выход | заготовка | +| notificationd | Очередь уведомлений | заготовка | + +Сетевой и аудио-апплеты требуют D-Bus. В тестовой среде этих служб нет, +поэтому заглушки показывали бы неверные значения — это хуже, чем ничего. + +### 4. Приложения + +Собственные приложения на Rust. Пока не написаны: пункты меню «Пуск» +запускают системные программы (`thunar`, `alacritty` и подобные), перебирая +кандидатов до первого найденного. + +**План:** + +- Файловый менеджер +- Терминал +- Центр настроек + +### 5. i18n + +Мультиязычность через Fluent (`.ftl`). + +- Языки: русский (ru-RU), английский (en-US) +- Структура: `locales/{lang}/app.ftl` + +## Схема взаимодействия + +``` +┌──────────────────────────────────────────┐ +│ Lark (один процесс) │ +│ │ +│ ┌────────────┐ ┌───────────────┐ │ +│ │ Shell │ │ Композитор │ │ +│ │ панель, │◄──────►│ smithay, │ │ +│ │ меню, часы│ │ GLES, ввод │ │ +│ └────────────┘ └───────┬───────┘ │ +│ ▲ │ │ +│ └──── растеризация ────┘ │ +│ текста (fontdue) │ +└──────────────────┬───────────────────────┘ + │ xdg-shell + ▼ + ┌─────────────┐ + │ Wayland │ + │ клиенты │ + └─────────────┘ +``` + +Оболочка и композитор живут в одном процессе и обмениваются состоянием +напрямую: общий сокет, общий растровщик текста, готовая раскладка панели. +Отдельного канала между ними нет — это осознанное решение в пользу +простоты на текущем этапе. + +## Рендеринг + +Сейчас — `GlesRenderer` smithay поверх EGL. Тот же путь работает и на +llvmpipe программной растеризации, поэтому композитор запускается в +виртуальной машине без видеокарты. + +Планировавшийся ранее wgpu не используется: smithay даёт готовый рендерер +с отслеживанием повреждений, а второй графический стек только удвоил бы +зависимости. + +Свои элементы сцены (панель, подписи, декорации) рисуются как текстуры: +сплошная заливка — один буфер, текст — растр, построенный `fontdue`. + +### Порядок элементов + +Элементы сцены отрисовываются **в обратном порядке списка**: последний +элемент списка оказывается ниже всех. Поэтому фон панели добавляется +последним, а мелкие детали (логотип, точки рабочих областей) — раньше своей +заливки. На это есть тесты: ошибка здесь не видна в коде, только на экране. + +## Конфигурация + +- Формат: TOML +- Путь: `~/.config/lark/config.toml` +- Секции: `[compositor]`, `[desktop]`, `[panel]`, `[theme]`, + `[decoration]`, `[language]`, `[keyboard]`, `[input]`, `[seat]` diff --git a/docs/BUILD.md b/docs/BUILD.md new file mode 100644 index 0000000..112d2b1 --- /dev/null +++ b/docs/BUILD.md @@ -0,0 +1,295 @@ +# Сборка и запуск Lark DE + +## Требования + +### Системные зависимости + +```bash +# Debian/Ubuntu +sudo apt install \ + libwayland-dev \ + libxkbcommon-dev \ + libdrm-dev \ + libgbm-dev \ + libpipewire-0.3-dev \ + libdbus-1-dev \ + libudev-dev \ + libinput-dev + +# Fedora +sudo dnf install \ + wayland-devel \ + libxkbcommon-devel \ + libdrm-devel \ + mesa-libgbm-devel \ + pipewire-devel \ + dbus-devel \ + libudev-devel \ + libinput-devel +``` + +### Rust + +```bash +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +``` + +## Сборка + +```bash +cargo build --release +``` + +## Запуск + +Композитор открывает окно через winit, поэтому запускается и внутри +существующей X11/Wayland-сессии (удобно для разработки), и поверх DRM/KMS +на целевом устройстве. + +Требуется переменная `XDG_RUNTIME_DIR`: сокет Wayland создаётся в ней. + +```bash +# Вложенный запуск в текущей сессии +./target/release/lark +``` + +Композитор сам занимает свободный сокет `wayland-N` и сообщает его при +старте. Дочерние процессы (меню «Пуск», терминал) наследуют `WAYLAND_DISPLAY`, +поэтому запускать их нужно после инициализации композитора. + +Завершение: `Ctrl+Esc` в окне композитора либо `SIGINT`/`SIGTERM`. + +### Журнал + +По умолчанию показываются сообщения Lark и предупреждения библиотек: +фильтр `lark=info,warn`. Без него smithay печатает на несколько экранов +список расширений OpenGL и параметры инициализации — это вытесняет всё, что +действительно нужно читать. + +```bash +RUST_LOG=debug ./target/release/lark # подробно, включая smithay +RUST_LOG=lark=trace ./target/release/lark # только свой код, максимум +``` + +Вывод запущенных приложений в консоль композитора не попадает: иначе +приложение вместе со своей шиной D-Bus и запущенными ею службами засыпал +бы терминал сообщениями об ошибках, которые к Lark отношения не имеют. + +## Горячие клавиши + +| Сочетание | Действие | +|-----------|----------| +| `Ctrl+Esc` | Выход из сессии | +| `Super` | Свернуть активное окно | +| `Super+Shift` | Развернуть окно / вернуть к прежнему размеру | +| `Super+Delete` | Закрыть активное окно | +| `Super+1` … `Super+4` | Перейти на рабочую область | +| `Super+Shift+1` … `Super+Shift+4` | Перенести активное окно на область | +| `Ctrl+Super+←` / `Ctrl+Super+→` | Шаг по рабочим областям по кругу | +| `Alt+Tab` | Переключение окон (список, пока `Alt` удержан) | +| `Alt+Shift+Tab` | Переключение окон в обратную сторону | +| `Escape` (в переключателе) | Отменить переключение | + +Клик по кнопке окна на панели задач переводит на него фокус, повторный +клик по активному окну сворачивает его, клик по свёрнутому — возвращает. +Ни то, ни другое не двигает окно по экрану. + +### Переключение окон + +`Alt+Tab` показывает список открытых окон текущей рабочей области. `Tab` +перебирает их по кругу, `Alt+Shift+Tab` — в обратную сторону. Фокус +переводится только при отпускании `Alt`: иначе быстрый пролёт по списку +переключал бы окна на каждом шаге и приложения мигали бы. `Escape` отменяет +переключение, клик по пункту списка выбирает его сразу. + +Список строится по z-порядку сверху вниз, поэтому первым идёт текущее окно, +а следующее за ним — то, на которое переключится первое нажатие `Tab`. + +## Окна + +Заголовок рисует композитор (`[decoration] server_side = true`). В нём +показывается название окна, а кнопки справа сворачивают, разворачивают и +закрывают окно. Окно нельзя утащить за пределы рабочей области, поэтому +заголовок всегда остаётся на экране. + +Окно меняет размер мышью за край рамки и за углы. Верх заголовка тянет размер +по вертикали, середина — перетаскивает окно; правый верхний угол отдан +кнопке закрытия, как в Windows 11. + +## Панель задач + +Панель содержит переключатель рабочих областей (слева), кнопку «Пуск» с +логотипом, кнопки открытых окон с названиями и часы (справа). + +Кнопки окон подстраиваются под длину названия. Если подписи перестают +помещаться, они отбрасываются и остаются значки; если не помещаются сами +кнопки, лишние окна сворачиваются в счётчик `+N` — нажатие на счётчик +не поддерживается, окна всё равно доступны горячими клавишами. + +Панель задач показывает только окна текущей рабочей области. + +## Меню «Пуск» + +Открывается кликом по кнопке «Пуск» или по `Super`. Содержит строку поиска и +список приложений. + +| Действие | Результат | +|----------|-----------| +| Ввод символов | поиск по названию, без учёта регистра | +| `Backspace` | удалить символ; на пустой строке уходит в окно | +| `↑` / `↓` | перемещение выделения | +| `Enter` | запустить выделенное приложение | +| `Esc` | закрыть меню | +| Клик мимо меню | закрыть меню | + +Приложения: + +| Пункт | Что запускается | +|-------|-----------------| +| `Файлы` | `nautilus`, `thunar`, `pcmanfm`, `dolphin` — первый найденный; иначе `xdg-open ~/` | +| `Терминал` | `foot`, `alacritty`, `kitty`, `gnome-terminal`, `konsole`, `xfce4-terminal`, `xterm` — первый найденный | +| `Завершение сессии` | завершает Lark | + +Пункт, у которого нет ни одного кандидата, в меню не показывается: лучше +отсутствие, чем ошибка по нажатию. + +### Почему приложения запускаются через `dbus-run-session` + +Lark часто запускают вложенно — внутри другой сессии (niri, GNOME). Окружение +композитора наследуется правильно, но этого мало: однопользовательские +программы (`thunar`, `gedo`, файловые менеджеры) через GApplication ищут уже +запущенный экземпляр и передают ему свои аргументы. Найденный экземпляр +принадлежит внешней сессии, и окно открывается **не в Lark**, а в ней. + +Поэтому каждое приложение запускается в отдельной сессии D-Bus: + +``` +dbus-run-session -- thunar +``` + +Собственная шина отрезает приложение от внешней: запущенного там экземпляра +оно не находит и поднимает своё — уже в Lark. Если `dbus-run-session` в +системе нет, приложение запускается напрямую (тогда возможен возврат к +поведению выше). + +## Рабочие области + +Количество задаётся `[desktop] workspaces` (по умолчанию 4). Окно открывается +в текущей области; перенесённое окно остаётся открытым, но не показывается +на панели и не попадает на экран до возврата на его область. + +## Текст и шрифты + +Подписи рисует собственный растровщик на `fontdb` + `fontdue`. Шрифт +выбирается автоматически: перебираются системные гарнитуры и выбирается +первая, умеющая нарисовать кириллицу — иначе русский интерфейс превратился +бы в пустые прямоугольники. Моноширинные и знаковые шрифты пропускаются. + +## Поддерживаемые протоколы + +Помимо `wl_compositor`, `wl_subcompositor`, `wl_shm`, `xdg_wm_base`, +`wl_seat`, `wl_output` и `xdg-output`, регистрируются: + +- `wp_viewporter` — рисование в поверхности меньше окна +- `wp_presentation` — обратная связь о времени показа кадра +- `xdg-decoration` — согласование декораций с клиентом + +Не реализовано: XWayland и DRM/KMS, см. `docs/ROADMAP.md`. + +### Проверка без дисплея + +Скрипт поднимает Xvfb, запускает композитор с тестовым клиентом и делает +снимок экрана: + +```bash +sudo apt install xvfb x11-apps imagemagick mesa-utils # eglgears_wayland +./scripts/dev-run.sh +``` + +Снимок сохраняется в `/tmp/lark-shot.png`. Скрипт сообщает, работает ли +клиент: если клиент завершился (например, не смог инициализировать графику), +окна на снимке не будет, и это не ошибка композитора. + +Полезные переменные окружения: + +| Переменная | Назначение | +|------------|------------| +| `LARK_TEST_DISPLAY` | дисплей Xvfb (по умолчанию `:78`) | +| `LARK_TEST_CLIENT` | тестовый клиент (по умолчанию `eglgears_wayland`) | +| `LARK_TEST_WAIT` | секунд до снимка (по умолчанию `6`) | +| `LARK_TEST_SHOT` | путь к снимку | +| `RUST_LOG` | уровень журналирования | + +При проверке ввода с клавиатуры под Xvfb окно нужно сфокусировать: без +оконного менеджера фокус не ставится автоматически. + +```bash +DISPLAY=:78 xdotool search --name "" | while read w; do xdotool windowfocus $w; done +``` + +### На реальном железе + +```bash +# Из TTY, без графической сессии +./target/release/lark +``` + +## Тесты + +```bash +cargo test +``` + +Тесты покрывают обработку ввода и трейты `KeyboardKeyEvent`, +`PointerButtonEvent`, распознавание сочетаний клавиш, геометрию декораций и +зон изменения размера, раскладку панели задач и меню «Пуск», переключатель +рабочих областей, растровщик текста и раскладку клавиатуры. + +## Конфигурация + +Файл конфигурации: `~/.config/lark/config.toml`. Создаётся автоматически +при первом запуске с значениями по умолчанию. + +```toml +[compositor] +backend = "auto" # auto, vulkan, gl, cpu +socket = "wayland-1" # запасное имя сокета, если auto не подошёл + +[desktop] +workspaces = 4 # число рабочих областей +background = "#0F0F17" + +[panel] +position = "bottom" # bottom, top +centered = true +height = 48 +transparent = true + +[theme] +name = "dark" # light, dark +accent = "#0078D4" + +[decoration] +server_side = true # заголовок рисует композитор +titlebar_height = 32 +button_width = 46 + +[language] +code = "ru-RU" # ru-RU, en-US + +[keyboard] +xkb_layout = "us" # us, ru, de, ... +xkb_variant = "" +xkb_model = "pc105" +xkb_rules = "evdev" +xkb_options = "" # например "grp:alt_shift_toggle" +repeat_delay = 25 # мс до автоповтора +repeat_rate = 600 # символов в секунду + +[input] +touchpad = true +scroll_speed = 1.0 + +[seat] +name = "seat0" +``` diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..e76d147 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,94 @@ +# Дорожная карта Lark DE + +## Фаза 1: Фундамент (композитор) + +- [x] Инициализация Wayland сессии (сокет, xdg-output, проверка вложенным клиентом) +- [x] Обработка ввода: клавиатура, мышь, прокрутка +- [x] Протокол xdg-shell для приложений (создание, конфигурирование, коммиты) +- [x] Базовый рендеринг окон (GLES через smithay, damage tracking) +- [x] Управление окнами: каскадное размещение, фокус по клику, очистка при закрытии +- [x] Панель задач с кнопками окон и переключением фокуса +- [x] Сворачивание, разворачивание и закрытие окон + (Super, Super+Shift, Super+Delete; кнопки заголовка и панели) +- [x] Декорации окон в стиле Windows 11 (CSD): заголовок с подписью, кнопки, + перетаскивание +- [x] Изменение размера окон мышью за край рамки и за углы +- [x] Переключение окон по `Alt+Tab` со списком и подтверждением +- [x] Переключение рабочих областей (Super+1..4, Super+Shift+цифра, Ctrl+Super+стрелки) +- [x] Протоколы presentation-time, viewporter, xdg-decoration +- [ ] Интеграция XWayland +- [ ] Бэкенд DRM/KMS для запуска без окна winit + +Не сделано в Фазе 1 и почему: + +- **XWayland.** Требует отдельного запуска `Xwayland`-сервера, прокси поверхностей + X11 в сцену smithay и синхронизации окон двух протоколов. Это объём больше, + чем весь сделанный композитор, и проверить его можно только на X11-клиенте, + которого в тестовой системе нет. +- **DRM/KMS.** Бэкенд нужен для запуска без окна winit — то есть на «голом» + железе. В тестовой среде нет ни DRM-устройства, ни KMS-карты: код пришлось бы + писать вслепую и нечем было бы проверить. Требуется реальная машина с + видеокартой. + +## Фаза 2: Shell (панель и меню) + +- [x] Панель задач: кнопка «Пуск», кнопки окон, выбор по клику +- [x] Отрисовка подписей: собственный растровщик текста на fontdb + fontdue + (подписи окон, заголовков, часов, меню) +- [x] Меню Пуск со списком приложений и поиском + (поиск набирается с клавиатуры, учитывает раскладку) +- [x] Запуск приложений в Lark, а не во внешней сессии + (каждое приложение получает свою шину D-Bus) +- [x] Часы в системном трее (время и дата, локальная зона) +- [x] Переключатель рабочих областей на панели +- [ ] Системный трей: сеть, звук, батарея +- [ ] Уведомления + +Не сделано в Фазе 2 и почему: + +- **Сеть, звук, батарея.** Нужен доступ к NetworkManager/D-Bus, PulseAudio и + `/sys/class/power_supply`. В тестовой системе этих служб нет, а заглушки + показали бы неверные значения, что хуже отсутствия индикатора. +- **Уведомления.** Требует протокола `org.freedesktop.Notifications` и меню + уведомлений; в текущем виде это отдельный экран с историей, то есть + примерно половина Фазы 2. + +## Фаза 3: Сервисы + +- [ ] Управление питанием (яркость, сон, батарея) +- [ ] Сетевой апплет +- [ ] Аудио-микшер +- [ ] Автозапуск и управление сессией + +## Фаза 4: Приложения + +- [ ] Файловый менеджер +- [ ] Терминал +- [ ] Центр настроек + +Приложения перечислены в меню «Пуск» и запускаются командами `xdg-open`, +`lark-terminal`, `lark-settings`. Сами приложения ещё не написаны. + +## Фаза 5: Полировка + +- [ ] Анимации и переходы +- [ ] Темы оформления (светлая/тёмная) +- [ ] Обои рабочего стола +- [x] Горячие клавиши + (см. таблицу в `BUILD.md`) +- [ ] Мультиязычность (ru, en) + +Горячие клавиши отмечены выполненными в том смысле, что механизм и набор +сочетаний есть и работают. Назначение их пользователем в настройках — отдельная +задача. + +## Стратегия тестирования + +- Проверка композитора на виртуальном дисплее: `scripts/dev-run.sh` + поднимает Xvfb, запускает композитор с клиентом и сохраняет снимок экрана +- Модульные тесты: разбор событий ввода, распознавание сочетаний клавиш, + раскладка панели задач, меню «Пуск», геометрия декораций и зон изменения + размера, растровщик текста, раскладка клавиатуры — `cargo test` +- Разработка и тестирование в виртуальной машине (QEMU/KVM с virtio-gpu или llvmpipe) +- CI на слабых конфигурациях (2 vCPU, 2 ГБ RAM) +- Профилирование через perf и tracing diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl new file mode 100644 index 0000000..60cda53 --- /dev/null +++ b/locales/en-US/app.ftl @@ -0,0 +1,53 @@ +# Lark DE — English locale + +app-name = Lark DE + +# Taskbar +panel-start = Start +panel-search = Search +panel-taskview = Task view +panel-widgets = Widgets +panel-settings = Settings + +# System tray +tray-network = Network +tray-volume = Volume +tray-battery = Battery +tray-clock = Clock + +# Start Menu +start-pinned = Pinned +start-all-apps = All apps +start-recommended = Recommended +start-search-placeholder = Type to search +start-power = Power +start-shutdown = Shut down +start-restart = Restart +start-sleep = Sleep +start-lock = Lock +start-signout = Sign out + +# Notifications +notifications-title = Notifications +notifications-clear-all = Clear all +notifications-empty = No notifications + +# Settings +settings-title = Settings +settings-appearance = Appearance +settings-theme = Theme +settings-theme-light = Light +settings-theme-dark = Dark +settings-language = Language +settings-compositor = Compositor +settings-animations = Animations +settings-shadows = Shadows +settings-transparency = Transparency + +# Common +common-ok = OK +common-cancel = Cancel +common-apply = Apply +common-close = Close +common-back = Back +common-search = Search diff --git a/locales/ru-RU/app.ftl b/locales/ru-RU/app.ftl new file mode 100644 index 0000000..e03f2f6 --- /dev/null +++ b/locales/ru-RU/app.ftl @@ -0,0 +1,53 @@ +# Lark DE — Русская локаль + +app-name = Lark DE + +# Панель задач +panel-start = Пуск +panel-search = Поиск +panel-taskview = Представление задач +panel-widgets = Виджеты +panel-settings = Настройки + +# Системный трей +tray-network = Сеть +tray-volume = Громкость +tray-battery = Батарея +tray-clock = Часы + +# Меню Пуск +start-pinned = Закреплено +start-all-apps = Все приложения +start-recommended = Рекомендуемые +start-search-placeholder = Введите для поиска +start-power = Питание +start-shutdown = Завершение работы +start-restart = Перезагрузка +start-sleep = Сон +start-lock = Заблокировать +start-signout = Выйти + +# Уведомления +notifications-title = Уведомления +notifications-clear-all = Очистить все +notifications-empty = Нет уведомлений + +# Настройки +settings-title = Настройки +settings-appearance = Оформление +settings-theme = Тема +settings-theme-light = Светлая +settings-theme-dark = Тёмная +settings-language = Язык +settings-compositor = Композитор +settings-animations = Анимации +settings-shadows = Тени +settings-transparency = Прозрачность + +# Общие +common-ok = OK +common-cancel = Отмена +common-apply = Применить +common-close = Закрыть +common-back = Назад +common-search = Поиск diff --git a/scripts/dev-run.sh b/scripts/dev-run.sh new file mode 100755 index 0000000..88bd8f5 --- /dev/null +++ b/scripts/dev-run.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Запускает Lark DE на виртуальном X-сервере и проверяет, что клиентское +# окно появилось: поднимает Xvfb, стартует `lark`, запускает клиента и +# делает снимок экрана. +# +# Переменные окружения: +# LARK_TEST_DISPLAY дисплей Xvfb (по умолчанию :78) +# LARK_TEST_RUNTIME каталог для сокета (по умолчанию /tmp/lark-test-run) +# LARK_TEST_SHOT путь к снимку (по умолчанию /tmp/lark-shot.png) +# LARK_TEST_CLIENT клиент (по умолчанию eglgears_wayland) +# LARK_TEST_WAIT секунд до снимка (по умолчанию 6) +# RUST_LOG уровень журналирования +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DISPLAY_NUM="${LARK_TEST_DISPLAY:-:78}" +RUNTIME_DIR="${LARK_TEST_RUNTIME:-/tmp/lark-test-run}" +SCREENSHOT="${LARK_TEST_SHOT:-/tmp/lark-shot.png}" +CLIENT="${LARK_TEST_CLIENT:-eglgears_wayland}" +WAIT_SECONDS="${LARK_TEST_WAIT:-6}" +LOG="${LARK_TEST_LOG:-/tmp/lark-dev-run.log}" + +BIN="$ROOT/target/debug/lark" +if [[ ! -x "$BIN" ]]; then + echo "Сборка не найдена, выполните: cargo build" >&2 + exit 1 +fi + +cleanup() { + [[ -n "${CLIENT_PID:-}" ]] && kill "$CLIENT_PID" 2>/dev/null + [[ -n "${LARK_PID:-}" ]] && kill "$LARK_PID" 2>/dev/null + wait 2>/dev/null + return 0 +} +trap cleanup EXIT + +# --- Виртуальный дисплей ----------------------------------------------------- +if [[ ! -S "/tmp/.X11-unix/X${DISPLAY_NUM#:}" ]]; then + if ! command -v Xvfb >/dev/null 2>&1; then + echo "Xvfb не установлен: apt install xvfb" >&2 + exit 1 + fi + rm -f "/tmp/.X${DISPLAY_NUM#:}-lock" + Xvfb "$DISPLAY_NUM" -screen 0 1280x800x24 -nolisten tcp >/tmp/lark-xvfb.log 2>&1 & + for _ in $(seq 1 40); do + [[ -S "/tmp/.X11-unix/X${DISPLAY_NUM#:}" ]] && break + sleep 0.25 + done +fi + +# --- Каталог для сокета ------------------------------------------------------ +mkdir -p "$RUNTIME_DIR" +chmod 700 "$RUNTIME_DIR" +rm -f "$RUNTIME_DIR"/wayland-* + +# --- Композитор -------------------------------------------------------------- +: >"$LOG" +DISPLAY="$DISPLAY_NUM" \ +WAYLAND_DISPLAY= \ +XDG_RUNTIME_DIR="$RUNTIME_DIR" \ +RUST_LOG="${RUST_LOG:-lark=info}" \ + "$BIN" >"$LOG" 2>&1 & +LARK_PID=$! + +SOCKET="" +for _ in $(seq 1 60); do + SOCKET=$(ls "$RUNTIME_DIR" 2>/dev/null | grep -E '^wayland-[0-9]+$' | head -1) + [[ -n "$SOCKET" ]] && break + sleep 0.25 +done + +if [[ -z "$SOCKET" ]]; then + echo "Lark DE не создал сокет. Журнал:" >&2 + cat "$LOG" >&2 + exit 1 +fi + +echo "Lark DE слушает: $RUNTIME_DIR/$SOCKET" + +# --- Клиент ------------------------------------------------------------------ +CLIENT_LOG=/tmp/lark-client.log +: >"$CLIENT_LOG" + +if ! command -v "$CLIENT" >/dev/null 2>&1; then + echo "Клиент $CLIENT не найден, снимок будет без окна" >&2 +else + XDG_RUNTIME_DIR="$RUNTIME_DIR" WAYLAND_DISPLAY="$SOCKET" \ + "$CLIENT" >"$CLIENT_LOG" 2>&1 & + CLIENT_PID=$! + + # Клиент может завершиться сразу, если не смог инициализировать + # графику. Это стоит сообщить явно, иначе пустой снимок выглядит как + # ошибка композитора. + sleep "$WAIT_SECONDS" + if kill -0 "$CLIENT_PID" 2>/dev/null; then + echo "Клиент $CLIENT работает" + else + echo "ВНИМАНИЕ: клиент $CLIENT завершился, окно на снимке может отсутствовать" >&2 + fi +fi + +# --- Снимок ------------------------------------------------------------------ +if command -v import >/dev/null 2>&1; then + if DISPLAY="$DISPLAY_NUM" import -window root "png:$SCREENSHOT" 2>/dev/null; then + echo "Снимок: $SCREENSHOT" + else + echo "Не удалось сделать снимок экрана" >&2 + fi +fi + +echo "Журнал Lark: $LOG" +if grep -qE '^Error' "$LOG"; then + echo "--- Журнал Lark ---" >&2 + tail -20 "$LOG" >&2 +fi + +if [[ -s "$CLIENT_LOG" ]]; then + echo "--- Журнал клиента ---" >&2 + tail -10 "$CLIENT_LOG" >&2 +fi diff --git a/src/apps/files.rs b/src/apps/files.rs new file mode 100644 index 0000000..a61e65e --- /dev/null +++ b/src/apps/files.rs @@ -0,0 +1,27 @@ +//! Файловый менеджер. + +/// Файловый менеджер. +pub struct FileManager { + current_path: String, +} + +impl FileManager { + /// Создаёт новый файловый менеджер. + pub fn new() -> Self { + Self { + current_path: std::env::home_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "/".to_string()), + } + } + + /// Переходит в директорию. + pub fn navigate_to(&mut self, path: impl Into) { + self.current_path = path.into(); + } + + /// Возвращает текущий путь. + pub fn current_path(&self) -> &str { + &self.current_path + } +} diff --git a/src/apps/mod.rs b/src/apps/mod.rs new file mode 100644 index 0000000..26edf8c --- /dev/null +++ b/src/apps/mod.rs @@ -0,0 +1,5 @@ +//! Lark Apps — собственные приложения. + +pub mod files; +pub mod terminal; +pub mod settings; diff --git a/src/apps/settings.rs b/src/apps/settings.rs new file mode 100644 index 0000000..2f8397d --- /dev/null +++ b/src/apps/settings.rs @@ -0,0 +1,13 @@ +//! Центр настроек. + +/// Центр настроек. +pub struct Settings { + // TODO: Разделы настроек +} + +impl Settings { + /// Создаёт новый центр настроек. + pub fn new() -> Self { + Self {} + } +} diff --git a/src/apps/terminal.rs b/src/apps/terminal.rs new file mode 100644 index 0000000..d1de179 --- /dev/null +++ b/src/apps/terminal.rs @@ -0,0 +1,13 @@ +//! Эмулятор терминала. + +/// Терминал. +pub struct Terminal { + // TODO: PTY, буфер, рендеринг текста +} + +impl Terminal { + /// Создаёт новый терминал. + pub fn new() -> Self { + Self {} + } +} diff --git a/src/compositor/decoration.rs b/src/compositor/decoration.rs new file mode 100644 index 0000000..3fda985 --- /dev/null +++ b/src/compositor/decoration.rs @@ -0,0 +1,718 @@ +//! Декорации окон в стиле Windows 11 (CSD). +//! +//! Заголовок рисует композитор, а не клиент: окно в Wayland не имеет +//! «собственной рамки», поэтому без этого заголовка перетаскивать и +//! сворачивать окно было бы нечем. +//! +//! Поверхность клиента занимает всё окно целиком, а заголовок располагается +//! **над** ней. Поэтому рамка окна по высоте на заголовок больше, чем +//! bounding box поверхности. + +use crate::config::DecorationConfig; +use smithay::backend::renderer::Color32F; +use smithay::utils::{Logical, Point, Rectangle, Size}; + +/// Ширина полосы захвата для изменения размера окна. +const RESIZE_BORDER: i32 = 6; + +/// Действие, доступное по клику на декорацию. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DecorationAction { + /// Заголовок без кнопки: окно следует перетаскивать. + Drag, + /// Свернуть окно. + Minimize, + /// Развернуть или вернуть окно. + ToggleMaximize, + /// Закрыть окно. + Close, + /// Изменить размер за левый край. + ResizeWest, + /// Изменить размер за правый край. + ResizeEast, + /// Изменить размер за верхний край. + ResizeNorth, + /// Изменить размер за нижний край. + ResizeSouth, + /// Изменить размер за левый верхний угол. + ResizeNorthWest, + /// Изменить размер за правый верхний угол. + ResizeNorthEast, + /// Изменить размер за левый нижний угол. + ResizeSouthWest, + /// Изменить размер за правый нижний угол. + ResizeSouthEast, +} + +impl DecorationAction { + /// `true`, если действие меняет размер окна. + pub fn is_resize(self) -> bool { + matches!( + self, + DecorationAction::ResizeWest + | DecorationAction::ResizeEast + | DecorationAction::ResizeNorth + | DecorationAction::ResizeSouth + | DecorationAction::ResizeNorthWest + | DecorationAction::ResizeNorthEast + | DecorationAction::ResizeSouthWest + | DecorationAction::ResizeSouthEast + ) + } +} + +/// Размеры и цвета декораций. +#[derive(Debug, Clone)] +pub struct DecorationStyle { + /// Высота заголовка. + pub titlebar_height: i32, + /// Ширина кнопки заголовка. + pub button_width: i32, + /// Цвет фона заголовка активного окна. + pub active: Color32F, + /// Цвет фона заголовка неактивного окна. + pub inactive: Color32F, + /// Цвет кнопок заголовка. + pub button: Color32F, +} + +impl DecorationStyle { + /// Строит стиль из настроек конфигурации. + pub fn from_config(config: &DecorationConfig, accent: Color32F, light: bool) -> Self { + // Светлая тема требует других оттенков по умолчанию: тёмный + // заголовок поверх светлого содержимого выглядит как ошибка. + let (active, inactive, button) = if light { + ( + Color32F::new(0.93, 0.93, 0.95, 1.0), + Color32F::new(0.88, 0.88, 0.91, 1.0), + Color32F::new(0.80, 0.80, 0.84, 1.0), + ) + } else { + ( + Color32F::new(0.17, 0.17, 0.21, 1.0), + Color32F::new(0.13, 0.13, 0.16, 1.0), + Color32F::new(0.22, 0.22, 0.27, 1.0), + ) + }; + + let _ = accent; + + Self { + titlebar_height: config.titlebar_height as i32, + button_width: config.button_width as i32, + active, + inactive, + button, + } + } +} + +/// Прямоугольник заголовка для окна с указанным bounding box. +/// +/// Заголовок располагается над поверхностью, поэтому рамка окна +/// оказывается на `titlebar_height` выше, чем сам bounding box. +pub fn title_bar_rect(window: Rectangle, style: &DecorationStyle) -> Rectangle { + let height = style.titlebar_height.max(1); + + Rectangle::new( + window.loc - Point::new(0, height), + Size::new(window.size.w, height), + ) +} + +/// Прямоугольники кнопок заголовка слева направо. +/// +/// Windows 11 размещает кнопки справа: сначала сворачивание, затем +/// разворачивание, затем закрытие. +pub fn button_rects( + title_bar: Rectangle, + style: &DecorationStyle, +) -> [(DecorationAction, Rectangle); 3] { + let width = style.button_width.max(1); + let actions = [ + DecorationAction::Minimize, + DecorationAction::ToggleMaximize, + DecorationAction::Close, + ]; + + // Кнопки выравниваются по правому краю заголовка. + let total = width * actions.len() as i32; + let mut left = title_bar.loc.x + title_bar.size.w - total; + + std::array::from_fn(|index| { + let action = actions[index]; + let rect = Rectangle::new( + Point::new(left, title_bar.loc.y), + Size::new(width, title_bar.size.h), + ); + left += width; + (action, rect) + }) +} + +/// Прямоугольник, который окно занимает вместе с заголовком. +/// +/// Поверхность клиента начинается ниже заголовка, поэтому рамка шире окна +/// на высоту заголовка. +pub fn frame_rect( + window: Rectangle, + style: &DecorationStyle, +) -> Rectangle { + let height = style.titlebar_height.max(1); + Rectangle::new( + window.loc - Point::new(0, height), + Size::new(window.size.w, window.size.h + height), + ) +} + +/// Определяет, что находится под точкой в декорациях окна. +/// +/// Возвращает `None`, если точка не попала ни в кнопку, ни в заголовок, +/// ни в полосу изменения размера. +pub fn hit_test( + window: Rectangle, + point: Point, + style: &DecorationStyle, +) -> Option { + let title_bar = title_bar_rect(window, style); + let p = point.to_i32_round(); + + // Кнопки проверяются первыми: они лежат поверх заголовка. + for (action, rect) in button_rects(title_bar, style) { + if rect.contains(p) { + return Some(action); + } + } + + // Зоны изменения размера проверяются раньше заголовка: заголовок + // занимает верх рамки целиком, и без этого порядка верхний край окна + // был бы нерастягиваемым. + // + // Границы берутся от рамки, а не от поверхности клиента: рамка выше на + // заголовок, и по поверхности верхний край просто не существует. + let frame = frame_rect(window, style); + + if let Some(action) = corner_action(frame, p) { + return Some(action); + } + if let Some(action) = edge_action(frame, p) { + return Some(action); + } + + if title_bar.contains(p) { + return Some(DecorationAction::Drag); + } + + None +} + +/// Определяет изменение размера за углом рамки. +fn corner_action( + window: Rectangle, + point: Point, +) -> Option { + let far = Point::::new( + window.loc.x + window.size.w - 1, + window.loc.y + window.size.h - 1, + ); + + let west = (point.x - window.loc.x).abs() <= RESIZE_BORDER; + let east = (far.x - point.x).abs() <= RESIZE_BORDER; + let north = (point.y - window.loc.y).abs() <= RESIZE_BORDER; + let south = (far.y - point.y).abs() <= RESIZE_BORDER; + + // Условия написаны явно для каждого угла: проверка «слева И сверху» + // без проверки самих углов срабатывала бы на любом краю. + if west + && north + && point.x <= window.loc.x + RESIZE_BORDER + && point.y <= window.loc.y + RESIZE_BORDER + { + return Some(DecorationAction::ResizeNorthWest); + } + if east + && north + && point.x >= far.x - RESIZE_BORDER + && point.y <= window.loc.y + RESIZE_BORDER + { + return Some(DecorationAction::ResizeNorthEast); + } + if west + && south + && point.x <= window.loc.x + RESIZE_BORDER + && point.y >= far.y - RESIZE_BORDER + { + return Some(DecorationAction::ResizeSouthWest); + } + if east + && south + && point.x >= far.x - RESIZE_BORDER + && point.y >= far.y - RESIZE_BORDER + { + return Some(DecorationAction::ResizeSouthEast); + } + + None +} + +/// Определяет изменение размера за стороной рамки. +fn edge_action( + window: Rectangle, + point: Point, +) -> Option { + let far = Point::::new( + window.loc.x + window.size.w - 1, + window.loc.y + window.size.h - 1, + ); + + // За пределами окна краёв нет: иначе зона захвата уходила бы на фон + // рабочего стола и окно цеплялось указателем само по себе. + if point.x < window.loc.x + || point.x > far.x + || point.y < window.loc.y + || point.y > far.y + { + return None; + } + + if point.x - window.loc.x <= RESIZE_BORDER && (point.y - window.loc.y) > RESIZE_BORDER + && (far.y - point.y) > RESIZE_BORDER + { + return Some(DecorationAction::ResizeWest); + } + if far.x - point.x <= RESIZE_BORDER && (point.y - window.loc.y) > RESIZE_BORDER + && (far.y - point.y) > RESIZE_BORDER + { + return Some(DecorationAction::ResizeEast); + } + if point.y - window.loc.y <= RESIZE_BORDER && (point.x - window.loc.x) > RESIZE_BORDER + && (far.x - point.x) > RESIZE_BORDER + { + return Some(DecorationAction::ResizeNorth); + } + if far.y - point.y <= RESIZE_BORDER && (point.x - window.loc.x) > RESIZE_BORDER + && (far.x - point.x) > RESIZE_BORDER + { + return Some(DecorationAction::ResizeSouth); + } + + None +} + +/// Место под подпись заголовка окна. +/// +/// Сам текст приходит отдельно: он зависит от окна, а геометрия — только +/// от стиля. +#[derive(Debug, Clone, Copy)] +pub struct TitleLabel { + /// Левый край подписи в логических координатах. + pub x: i32, + /// Верхний край подписи в логических координатах. + pub y: i32, + /// Доступная ширина подписи: длинные заголовки обрезаются. + pub max_width: i32, +} + +/// Список прямоугольников для отрисовки вместе с цветами. +pub fn paint_list( + window: Rectangle, + active: bool, + style: &DecorationStyle, +) -> Vec<(Rectangle, Color32F)> { + let title_bar = title_bar_rect(window, style); + let background = if active { style.active } else { style.inactive }; + + let mut paints = Vec::with_capacity(4); + + // Кнопки идут первыми: элементы сцены рисуются в обратном порядке, + // поэтому заголовок, поставленный первым, перекрыл бы кнопки. + for (_, rect) in button_rects(title_bar, style) { + paints.push((rect, style.button)); + } + paints.push((title_bar, background)); + + paints +} + +/// Место под заголовок окна внутри рамки. +/// +/// Кнопки занимают правый край, поэтому подписи остаётся всё остальное. +pub fn title_label( + window: Rectangle, + style: &DecorationStyle, +) -> TitleLabel { + let title_bar = title_bar_rect(window, style); + let buttons = button_rects(title_bar, style); + let left = buttons + .first() + .map(|(_, rect)| rect.loc.x) + .unwrap_or(title_bar.loc.x + title_bar.size.w); + + // Небольшой отступ от краёв, иначе подпись упирается в рамку. + let inset = style.titlebar_height / 3; + + TitleLabel { + x: title_bar.loc.x + inset, + y: title_bar.loc.y, + max_width: (left - title_bar.loc.x - inset * 2).max(0), + } +} + +/// Тесты геометрии декораций. +#[cfg(test)] +mod tests { + use super::*; + + fn style() -> DecorationStyle { + DecorationStyle::from_config(&DecorationConfig::default(), Color32F::new(0.0, 0.5, 1.0, 1.0), false) + } + + fn window() -> Rectangle { + Rectangle::new( + Point::new(100, 200), + Size::new(800, 600), + ) + } + + /// Заголовок располагается над поверхностью окна. + #[test] + fn title_bar_sits_above_surface() { + let style = style(); + let bar = title_bar_rect(window(), &style); + + assert_eq!(bar.loc.x, 100, "заголовок выровнен по левому краю окна"); + assert_eq!(bar.size.w, 800, "заголовок на всю ширину окна"); + assert_eq!(bar.size.h, style.titlebar_height); + assert_eq!(bar.loc.y, 200 - style.titlebar_height); + } + + /// Кнопки идут справа налево в порядке «свернуть, развернуть, закрыть». + #[test] + fn buttons_are_on_the_right_in_windows_order() { + let style = style(); + let bar = title_bar_rect(window(), &style); + let buttons = button_rects(bar, &style); + + assert_eq!(buttons[0].0, DecorationAction::Minimize); + assert_eq!(buttons[1].0, DecorationAction::ToggleMaximize); + assert_eq!(buttons[2].0, DecorationAction::Close); + + // Самая правая кнопка — закрытие. + assert_eq!( + buttons[2].1.loc.x + buttons[2].1.size.w, + bar.loc.x + bar.size.w + ); + + // Кнопки не пересекаются и идут подряд. + assert_eq!(buttons[0].1.loc.x + buttons[0].1.size.w, buttons[1].1.loc.x); + assert_eq!(buttons[1].1.loc.x + buttons[1].1.size.w, buttons[2].1.loc.x); + } + + /// Клик по свободной части заголовка означает перетаскивание. + #[test] + fn title_bar_click_starts_drag() { + let style = style(); + let point = Point::new(200.0, 190.0); + + assert_eq!( + hit_test(window(), point, &style), + Some(DecorationAction::Drag) + ); + } + + /// Клик по кнопке возвращает её действие, а не перетаскивание. + #[test] + fn button_click_returns_its_action() { + let style = style(); + let bar = title_bar_rect(window(), &style); + let buttons = button_rects(bar, &style); + + let close = buttons[2].1; + let center = Point::new( + (close.loc.x + close.size.w / 2) as f64, + (close.loc.y + close.size.h / 2) as f64, + ); + + assert_eq!( + hit_test(window(), center, &style), + Some(DecorationAction::Close) + ); + } + + /// Клик по содержимому окна не попадает в декорации. + #[test] + fn click_on_content_is_not_a_decoration() { + let style = style(); + // Центр окна — это уже содержимое. + assert_eq!( + hit_test(window(), Point::new(500.0, 500.0), &style), + None + ); + } + + /// Клик выше заголовка не попадает в декорации. + #[test] + fn click_above_title_bar_misses() { + let style = style(); + let bar = title_bar_rect(window(), &style); + let above = Point::new(200.0, (bar.loc.y - 5) as f64); + + assert_eq!(hit_test(window(), above, &style), None); + } + + /// Отрисовка покрывает заголовок и все три кнопки. + /// + /// Заголовок обязан идти последним: элементы сцены рисуются в обратном + /// порядке, иначе он перекроет кнопки. + #[test] + fn paint_list_covers_title_bar_and_buttons() { + let style = style(); + let paints = paint_list(window(), true, &style); + + assert_eq!(paints.len(), 4, "три кнопки и заголовок"); + + let (title_rect, title_color) = paints.last().expect("заголовок"); + assert_eq!(title_color, &style.active, "активное окно"); + assert_eq!(title_rect.size.h, style.titlebar_height); + + for (rect, color) in &paints[..3] { + assert_eq!(rect.size.h, style.titlebar_height, "кнопка во всю высоту"); + assert_eq!(color, &style.button); + } + } + + /// Неактивное окно окрашивается иначе. + #[test] + fn inactive_window_has_other_color() { + let style = style(); + let active = paint_list(window(), true, &style); + let inactive = paint_list(window(), false, &style); + + assert_ne!(active.last().unwrap().1, inactive.last().unwrap().1); + assert_eq!(inactive.last().unwrap().1, style.inactive); + } + + /// Подпись размещается слева и не заезжает под кнопки. + #[test] + fn title_label_avoids_buttons() { + let style = style(); + let label = title_label(window(), &style); + let bar = title_bar_rect(window(), &style); + let buttons = button_rects(bar, &style); + + let buttons_left = buttons[0].1.loc.x; + + assert!(label.x > bar.loc.x, "подпись не у левого края рамки"); + assert!( + label.x + label.max_width <= buttons_left, + "подпись доходит до кнопок: {} > {buttons_left}", + label.x + label.max_width + ); + assert!(label.max_width > 0, "для подписи есть место"); + assert_eq!(label.y, bar.loc.y, "подпись в заголовке"); + } + + /// Узкое окно оставляет подписи меньше места, но не отрицательной. + #[test] + fn narrow_window_gives_no_room_for_title() { + let style = style(); + let narrow = Rectangle::new( + Point::new(0, 0), + Size::new(style.button_width * 3, 100), + ); + + let label = title_label(narrow, &style); + + assert_eq!(label.max_width, 0, "мест для подписи нет"); + assert!(label.x >= 0, "подпись не уходит в минус"); + } + + /// Края окна предлагают изменение размера. + /// + /// Проверяются все восемь направлений: без них окно нельзя было бы + /// растянуть ни в одну сторону. + #[test] + fn window_edges_resize() { + let style = style(); + let frame = frame_rect(window(), &style); + let (left, top) = (frame.loc.x as f64, frame.loc.y as f64); + let (right, bottom) = ( + (frame.loc.x + frame.size.w - 1) as f64, + (frame.loc.y + frame.size.h - 1) as f64, + ); + let middle_x = (left + right) / 2.0; + let middle_y = (top + bottom) / 2.0; + + let cases = [ + (Point::new(left, middle_y), DecorationAction::ResizeWest), + (Point::new(right, middle_y), DecorationAction::ResizeEast), + (Point::new(middle_x, top), DecorationAction::ResizeNorth), + (Point::new(middle_x, bottom), DecorationAction::ResizeSouth), + ]; + + for (point, expected) in cases { + assert_eq!( + hit_test(window(), point, &style), + Some(expected), + "точка {point:?}" + ); + } + } + + /// Верх заголовка тянет размер, а его середина — окно. + /// + /// Заголовок занимает верх рамки целиком, поэтому порядок проверок + /// решает: без приоритета у зон размера окно нельзя было бы растянуть + /// вверх, а без приоритета у заголовка его нельзя было бы двигать. + #[test] + fn titlebar_splits_between_resize_and_drag() { + let style = style(); + let frame = frame_rect(window(), &style); + let top = frame.loc.y as f64; + let middle_x = (frame.loc.x + frame.size.w / 2) as f64; + + assert_eq!( + hit_test(window(), Point::new(middle_x, top), &style), + Some(DecorationAction::ResizeNorth), + "верх заголовка должен тянуть размер" + ); + + // Середина заголовка — обычное перетаскивание. + let bar_middle = top + style.titlebar_height as f64 / 2.0; + assert_eq!( + hit_test(window(), Point::new(middle_x, bar_middle), &style), + Some(DecorationAction::Drag), + "середина заголовка должна перетаскивать окно" + ); + } + + /// Углы тянутся по диагонали. + /// + /// Правый верхний угол не проверяется: в стиле Windows 11 там кнопка + /// закрытия, и кнопка обязана выигрывать у зоны изменения размера. + #[test] + fn window_corners_resize_diagonally() { + let style = style(); + let frame = frame_rect(window(), &style); + let (left, top) = (frame.loc.x as f64, frame.loc.y as f64); + let (right, bottom) = ( + (frame.loc.x + frame.size.w - 1) as f64, + (frame.loc.y + frame.size.h - 1) as f64, + ); + + let cases = [ + (Point::new(left, top), DecorationAction::ResizeNorthWest), + (Point::new(left, bottom), DecorationAction::ResizeSouthWest), + (Point::new(right, bottom), DecorationAction::ResizeSouthEast), + ]; + + for (point, expected) in cases { + assert_eq!( + hit_test(window(), point, &style), + Some(expected), + "точка {point:?}" + ); + } + } + + /// Правый верхний угол принадлежит кнопке закрытия. + /// + /// Это осознанное решение в пользу стиля Windows 11: без него кнопка + /// закрытия была бы недоступна мышью. + #[test] + fn top_right_corner_belongs_to_close_button() { + let style = style(); + let frame = frame_rect(window(), &style); + let corner = Point::new( + (frame.loc.x + frame.size.w - 1) as f64, + frame.loc.y as f64, + ); + + assert_eq!( + hit_test(window(), corner, &style), + Some(DecorationAction::Close), + "угол должен принадлежать кнопке закрытия" + ); + } + + /// Углы важнее рёбер, иначе тянуть по диагонали было бы нечем. + #[test] + fn corners_take_priority_over_edges() { + let style = style(); + let frame = frame_rect(window(), &style); + + // Угол рамки: и сверху и слева одновременно. + let corner = Point::new(frame.loc.x as f64, frame.loc.y as f64); + let action = hit_test(window(), corner, &style).expect("угол должен ловиться"); + + assert!(action.is_resize(), "угол должен менять размер"); + assert_eq!(action, DecorationAction::ResizeNorthWest); + } + + /// Зона захвата не выходит за пределы окна. + /// + /// Иначе указатель цеплял бы окно за его пределами и окно «прыгало» бы + /// при простом прохождении мимо. + #[test] + fn resize_zone_stays_inside_the_window() { + let style = style(); + let frame = frame_rect(window(), &style); + + // На два пикселя левее рамки. + let outside = Point::new(frame.loc.x as f64 - 2.0, frame.loc.y as f64 + 200.0); + assert_eq!(hit_test(window(), outside, &style), None); + + // На два пикселя ниже рамки. + let below = Point::new( + (frame.loc.x + frame.size.w / 2) as f64, + (frame.loc.y + frame.size.h + 2) as f64, + ); + assert_eq!(hit_test(window(), below, &style), None); + } + + /// Содержимое окна не считается зоной изменения размера. + #[test] + fn content_is_not_a_resize_zone() { + let style = style(); + let center = Point::new(500.0, 500.0); + + assert_eq!(hit_test(window(), center, &style), None); + } + + /// Все действия изменения размера распознаются как таковые. + #[test] + fn resize_actions_are_marked() { + for action in [ + DecorationAction::ResizeWest, + DecorationAction::ResizeEast, + DecorationAction::ResizeNorth, + DecorationAction::ResizeSouth, + DecorationAction::ResizeNorthWest, + DecorationAction::ResizeNorthEast, + DecorationAction::ResizeSouthWest, + DecorationAction::ResizeSouthEast, + ] { + assert!(action.is_resize(), "{action:?} должен быть изменением размера"); + } + + for action in [ + DecorationAction::Drag, + DecorationAction::Minimize, + DecorationAction::ToggleMaximize, + DecorationAction::Close, + ] { + assert!(!action.is_resize(), "{action:?} не должен быть изменением размера"); + } + } + + /// Рамка включает заголовок, поэтому она выше поверхности клиента. + #[test] + fn frame_includes_titlebar() { + let style = style(); + let frame = frame_rect(window(), &style); + + assert_eq!(frame.loc.y, window().loc.y - style.titlebar_height); + assert_eq!(frame.size.h, window().size.h + style.titlebar_height); + assert_eq!(frame.size.w, window().size.w); + } +} diff --git a/src/compositor/input.rs b/src/compositor/input.rs new file mode 100644 index 0000000..f064f9f --- /dev/null +++ b/src/compositor/input.rs @@ -0,0 +1,418 @@ +//! Обработка ввода: клавиатура, мышь, тачпад. +//! +//! Фактическое преобразование событий в протоколы Wayland выполняет +//! [`crate::compositor::App`]; здесь хранится состояние ввода, которое нужно +//! для горячих клавиш и оболочки. + +use std::collections::HashSet; + +use smithay::backend::input::Keycode; +use smithay::input::keyboard::ModifiersState; + +/// Смещение между scancode winit и `Keycode` smithay. +const KEYCODE_OFFSET: u32 = 8; + +/// Scancode клавиш-модификаторов в evdev. +const SCANCODE_LEFTCTRL: u32 = 29; +const SCANCODE_RIGHTCTRL: u32 = 97; +const SCANCODE_LEFTALT: u32 = 56; +const SCANCODE_RIGHTALT: u32 = 100; +const SCANCODE_LEFTSHIFT: u32 = 42; +const SCANCODE_RIGHTSHIFT: u32 = 54; +const SCANCODE_LEFTMETA: u32 = 125; +const SCANCODE_RIGHTMETA: u32 = 126; + +/// Состояние клавиши. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyState { + Pressed, + Released, +} + +/// Состояние кнопки мыши. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ButtonState { + Pressed, + Released, +} + +/// Комбинация клавиш (горячая клавиша). +pub type Shortcut = HashSet; + +/// Действие, привязанное к горячей клавише. +pub type ShortcutAction = Box; + +/// Действие, применяемое к активному окну. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WindowShortcut { + /// Свернуть активное окно. + Minimize, + /// Развернуть активное окно либо вернуть его к прежнему размеру. + ToggleMaximize, + /// Закрыть активное окно. + Close, +} + +/// Результат обработки нажатия клавиши. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShortcutResult { + /// Сработала глобальная горячая клавиша. + Global, + /// Сработало сочетание для активного окна. + Window(WindowShortcut), +} + +/// Менеджер ввода: хранит нажатые клавиши и зарегистрированные сочетания. +pub struct InputManager { + /// Клавиши, сейчас находящиеся в нажатом состоянии. + pressed: Shortcut, + /// Зарегистрированные сочетания и связанные с ними действия. + shortcuts: Vec<(Shortcut, ShortcutAction)>, + /// Сочетания, действующие на активное окно. + window_shortcuts: Vec<(Shortcut, WindowShortcut)>, + /// Сочетание, совпавшее при нажатии, но ещё не отпущенное. + fired: Option, + /// Сочетание, на набор клавиш которого мы нацелены. + /// + /// Обнуляется, если нажата клавиша, не входящая ни в одно сочетание: + /// это признак того, что пользователь набрал что-то другое. + armed: Option, +} + +/// Вид совпавшего сочетания. +#[derive(Clone, Copy)] +enum ShortcutKind { + /// Глобальная горячая клавиша, заданная индексом в списке. + Global(usize), + /// Действие над активным окном. + Window(WindowShortcut), +} + +impl Default for InputManager { + fn default() -> Self { + Self::new() + } +} + +impl InputManager { + /// Создаёт новый менеджер ввода. + pub fn new() -> Self { + Self { + pressed: HashSet::new(), + shortcuts: Vec::new(), + window_shortcuts: Vec::new(), + fired: None, + armed: None, + } + } + + /// Регистрирует сочетание, действующее на активное окно. + pub fn register_window_shortcut( + &mut self, + binding: impl IntoIterator, + action: WindowShortcut, + ) { + self.window_shortcuts + .push((binding.into_iter().collect(), action)); + } + + /// Регистрирует горячую клавишу и действие для неё. + pub fn register_shortcut( + &mut self, + binding: impl IntoIterator, + action: impl Fn() + Send + Sync + 'static, + ) { + self.shortcuts + .push((binding.into_iter().collect(), Box::new(action))); + } + + /// Состояние клавиш-модификаторов по нажатым клавишам. + /// + /// Отдельный доступ к модификаторам у `KeyboardHandle` отсутствует, но + /// набор нажатых клавиш здесь уже есть, и он в точности их описывает. + pub fn modifiers(&self) -> ModifiersState { + let held = |scancode: u32| { + self.pressed + .contains(&Keycode::from(scancode + KEYCODE_OFFSET)) + }; + + ModifiersState { + ctrl: held(SCANCODE_LEFTCTRL) || held(SCANCODE_RIGHTCTRL), + alt: held(SCANCODE_LEFTALT) || held(SCANCODE_RIGHTALT), + shift: held(SCANCODE_LEFTSHIFT) || held(SCANCODE_RIGHTSHIFT), + logo: held(SCANCODE_LEFTMETA) || held(SCANCODE_RIGHTMETA), + ..ModifiersState::default() + } + } + + /// Обрабатывает нажатие или отпускание клавиши. + /// + /// Возвращает действие, сработавшее по сочетанию клавиш. + /// + /// Сочетание срабатывает в момент **отпускания** последней клавиши, а не + /// нажатия: иначе Super сработает раньше, чем пользователь успеет + /// добавить Shift, и сочетание Super+Shift развалилось бы на Super. + pub fn handle_key(&mut self, key: Keycode, state: KeyState) -> Option { + match state { + KeyState::Pressed => { + self.pressed.insert(key); + + // Сочетание «собирается» только пока набор нажатых входит + // хотя бы в одно зарегистрированное сочетание. Сторонняя + // клавиша обрывает сборку: иначе Super+A сворачивал бы окно. + self.armed = self + .longest_superset() + .map(|(binding, _)| binding); + + None + } + KeyState::Released => { + let already_fired = self.fired.clone(); + // `armed` проверяется только на предмет «помехи»: если + // нажата посторонняя клавиша, сочетание не сработает вовсе. + let matched = self.armed.as_ref().and_then(|_| { + self.longest_match().filter(|(binding, _)| { + // После срабатывания Super+Shift отпускание Super + // не должно выполнить более короткое сочетание. + already_fired.as_ref().is_none_or(|fired| fired == binding) + }) + }); + + let result = match matched { + Some((binding, ShortcutKind::Global(index))) => { + self.fired = Some(binding); + (self.shortcuts[index].1)(); + Some(ShortcutResult::Global) + } + Some((binding, ShortcutKind::Window(action))) => { + self.fired = Some(binding); + Some(ShortcutResult::Window(action)) + } + None => None, + }; + + self.armed = None; + self.pressed.remove(&key); + + // Сочетание считается завершённым, когда отпущены все его + // клавиши: после этого новые сочетания снова допустимы. + if self + .fired + .as_ref() + .is_some_and(|binding| binding.iter().all(|k| !self.pressed.contains(k))) + { + self.fired = None; + } + + result + } + } + } + + /// Возвращает самое длинное сочетание, содержащее все нажатые клавиши. + /// + /// `None` означает, что текущий набор не может стать ни одним из + /// зарегистрированных сочетаний. + fn longest_superset(&self) -> Option<(Shortcut, ShortcutKind)> { + let mut best: Option<(Shortcut, ShortcutKind)> = None; + + let consider = |best: &mut Option<(Shortcut, ShortcutKind)>, + binding: &Shortcut, + kind: ShortcutKind| { + let fits = self.pressed.iter().all(|key| binding.contains(key)); + if fits && best.as_ref().is_none_or(|(b, _)| b.len() < binding.len()) { + *best = Some((binding.clone(), kind)); + } + }; + + for (index, (binding, _)) in self.shortcuts.iter().enumerate() { + consider(&mut best, binding, ShortcutKind::Global(index)); + } + for (binding, action) in &self.window_shortcuts { + consider(&mut best, binding, ShortcutKind::Window(*action)); + } + + best + } + + /// Возвращает самое длинное зарегистрированное сочетание, совпадающее с + /// текущим набором нажатых клавиш. + /// + /// Длина сравнивается первой, иначе `Super` перехватил бы `Super+Shift`. + fn longest_match(&self) -> Option<(Shortcut, ShortcutKind)> { + let mut best: Option<(Shortcut, ShortcutKind)> = None; + + for (index, (binding, _)) in self.shortcuts.iter().enumerate() { + if binding == &self.pressed + && best.as_ref().is_none_or(|(b, _)| b.len() < binding.len()) + { + best = Some((binding.clone(), ShortcutKind::Global(index))); + } + } + + for (binding, action) in &self.window_shortcuts { + if binding == &self.pressed + && best.as_ref().is_none_or(|(b, _)| b.len() < binding.len()) + { + best = Some((binding.clone(), ShortcutKind::Window(*action))); + } + } + + best + } + + /// Обрабатывает движение мыши. + pub fn handle_pointer_motion(&mut self, _x: f64, _y: f64) { + // TODO: учёт области отслеживания указателя и ускорения + } + + /// Обрабатывает нажатие или отпускание кнопки мыши. + pub fn handle_pointer_button(&mut self, _button: u32, _state: ButtonState) { + // TODO: перетаскивание окон + } + + /// Освобождает все нажатые клавиши. + /// + /// Вызывается при потере фокуса окна, иначе клиенты увидят «залипшие» + /// нажатия после возвращения фокуса. + pub fn release_all(&mut self) { + self.pressed.clear(); + self.fired = None; + self.armed = None; + } +} + +/// Тесты распознавания сочетаний клавиш. +/// +/// Логика нетривиальна: сочетание должно срабатывать при отпускании клавиш, +/// причём одно сочетание не должно срабатывать дважды. +#[cfg(test)] +mod tests { + use super::*; + + fn key_super() -> Keycode { + Keycode::from(133u32) + } + fn shift() -> Keycode { + Keycode::from(50u32) + } + fn delete() -> Keycode { + Keycode::from(119u32) + } + fn key_a() -> Keycode { + Keycode::from(46u32) + } + + fn manager() -> InputManager { + let mut manager = InputManager::new(); + manager.register_window_shortcut([key_super()], WindowShortcut::Minimize); + manager.register_window_shortcut([key_super(), shift()], WindowShortcut::ToggleMaximize); + manager.register_window_shortcut([key_super(), delete()], WindowShortcut::Close); + manager + } + + /// Нажатие и отпускание Super должно свернуть окно. + #[test] + fn super_minimizes() { + let mut m = manager(); + assert_eq!(m.handle_key(key_super(), KeyState::Pressed), None); + + let result = m.handle_key(key_super(), KeyState::Released); + assert_eq!(result, Some(ShortcutResult::Window(WindowShortcut::Minimize))); + } + + /// Super+Shift должен разворачивать, а не сворачивать. + #[test] + fn super_shift_toggles_maximize() { + let mut m = manager(); + m.handle_key(key_super(), KeyState::Pressed); + m.handle_key(shift(), KeyState::Pressed); + + // Отпускание Shift завершает сочетание. + let result = m.handle_key(shift(), KeyState::Released); + assert_eq!( + result, + Some(ShortcutResult::Window(WindowShortcut::ToggleMaximize)) + ); + + // Отпускание Super не должно тут же выполнить короткое сочетание. + assert_eq!(m.handle_key(key_super(), KeyState::Released), None); + } + + /// Super+Delete должен закрывать окно. + #[test] + fn super_delete_closes() { + let mut m = manager(); + m.handle_key(key_super(), KeyState::Pressed); + m.handle_key(delete(), KeyState::Pressed); + + let result = m.handle_key(delete(), KeyState::Released); + assert_eq!(result, Some(ShortcutResult::Window(WindowShortcut::Close))); + } + + /// После завершения сочетания следующее нажатие работает как обычно. + #[test] + fn shortcut_can_be_repeated() { + let mut m = manager(); + for _ in 0..2 { + m.handle_key(key_super(), KeyState::Pressed); + assert_eq!( + m.handle_key(key_super(), KeyState::Released), + Some(ShortcutResult::Window(WindowShortcut::Minimize)) + ); + } + } + + /// Одиночная клавиша не должна вызывать ни одного сочетания. + #[test] + fn plain_key_does_not_trigger() { + let mut m = manager(); + m.handle_key(key_a(), KeyState::Pressed); + assert_eq!(m.handle_key(key_a(), KeyState::Released), None); + } + + /// Super с посторонней клавишей не должен срабатывать. + #[test] + fn super_with_unrelated_key_does_not_trigger() { + let mut m = manager(); + m.handle_key(key_super(), KeyState::Pressed); + m.handle_key(key_a(), KeyState::Pressed); + + // Ни при отпускании A, ни при отпускании Super. + assert_eq!(m.handle_key(key_a(), KeyState::Released), None); + assert_eq!(m.handle_key(key_super(), KeyState::Released), None); + } + + /// Глобальные сочетаения выполняются и возвращают свой тип. + #[test] + fn global_shortcut_runs_action() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + let counter = Arc::new(AtomicUsize::new(0)); + let mut m = InputManager::new(); + let c = Arc::clone(&counter); + m.register_shortcut([key_super(), delete()], move || { + c.fetch_add(1, Ordering::SeqCst); + }); + + m.handle_key(key_super(), KeyState::Pressed); + m.handle_key(delete(), KeyState::Pressed); + assert_eq!(counter.load(Ordering::SeqCst), 0, "сработать должно при отпускании"); + + assert_eq!(m.handle_key(delete(), KeyState::Released), Some(ShortcutResult::Global)); + assert_eq!(counter.load(Ordering::SeqCst), 1); + } + + /// Потеря фокуса сбрасывает нажатые клавиши, чтобы сочетание не + /// «залипало» и не сработало позже само по себе. + #[test] + fn release_all_clears_state() { + let mut m = manager(); + m.handle_key(key_super(), KeyState::Pressed); + m.release_all(); + + // Отпускание Super после сброса уже ничего не запускает. + assert_eq!(m.handle_key(key_super(), KeyState::Released), None); + } +} diff --git a/src/compositor/keys.rs b/src/compositor/keys.rs new file mode 100644 index 0000000..cdebe9e --- /dev/null +++ b/src/compositor/keys.rs @@ -0,0 +1,225 @@ +//! Раскладка клавиатуры: код клавиши → текст. +//! +//! Compositor получает от winit коды клавиш, а не символы. Символ зависит от +//! раскладки, поэтому без перевода нельзя ни набрать текст в строке поиска, +//! ни отправить символ окну: русская раскладка превратилась бы в латиницу. +//! +//! Раскладка собирается из тех же настроек, что и `wl_seat.keymap`, поэтому +//! ввод в интерфейс и в окно всегда в одной раскладке. + +use crate::config::KeyboardConfig; +use smithay::input::keyboard::ModifiersState; +use smithay::input::keyboard::Keycode; + +/// Смещение между кодом клавиши в evdev и в xkbcommon. +#[cfg(test)] +const XKB_OFFSET: u32 = 8; + +/// Раскладка клавиатуры. +pub struct KeyboardLayout { + /// Контекст xkbcommon: держит правила раскладок. + #[allow(dead_code, reason = "нужен для времени жизни keymap")] + context: xkbcommon::xkb::Context, + /// Карта соответствия кодов клавиш и символов. + keymap: xkbcommon::xkb::Keymap, +} + +impl KeyboardLayout { + /// Собирает раскладку по настройкам пользователя. + /// + /// При любой ошибке (нет правил, неизвестная раскладка) берётся + /// стандартная `us`: пустой ввод хуже ввода в неверной раскладке. + pub fn new(config: &KeyboardConfig) -> Self { + let context = xkbcommon::xkb::Context::new(0); + + let layout = config.xkb_layout.clone().unwrap_or_default(); + let variant = config.xkb_variant.clone().unwrap_or_default(); + let options = config.xkb_options.clone(); + let model = config.xkb_model.clone().unwrap_or_default(); + let rules = config.xkb_rules.clone().unwrap_or_default(); + + let compiled = xkbcommon::xkb::Keymap::new_from_names( + &context, + &rules, + &model, + &layout, + &variant, + options, + xkbcommon::xkb::KEYMAP_COMPILE_NO_FLAGS, + ); + + match compiled { + Some(keymap) => Self { context, keymap }, + None => { + tracing::warn!("раскладка {layout:?} не собрана, беру us"); + Self::fallback() + } + } + } + + /// Раскладка по умолчанию, когда пользовательская недоступна. + fn fallback() -> Self { + let context = xkbcommon::xkb::Context::new(0); + let keymap = xkbcommon::xkb::Keymap::new_from_names( + &context, + "", + "", + "us", + "", + None, + xkbcommon::xkb::KEYMAP_COMPILE_NO_FLAGS, + ) + .expect("раскладка us собирается всегда"); + + Self { context, keymap } + } + + /// Сколько уровней (вариантов символа) у клавиши. + /// + /// Уровни выше нуля появляются у раскладок с несколькими группами + /// (`grp:…`). + fn levels(&self, keycode: xkbcommon::xkb::Keycode) -> u32 { + self.keymap.num_levels_for_key(keycode, 0).max(1) + } + + /// Текст, который набирается клавишей. + /// + /// `None` означает, что клавиша не печатает символ: служебная, стрелка + /// или сочетание с Ctrl/Alt/Super. + pub fn text(&self, keycode: Keycode, modifiers: ModifiersState) -> Option { + // Сочетания с Ctrl и Alt печатают управляющие символы, а не текст: + // их символы нельзя вставлять в строку поиска. + if modifiers.ctrl || modifiers.alt || modifiers.logo { + return None; + } + + // smithay и xkbcommon используют один и тот же формат кода: оба + // считают от evdev плюс смещение. + let code = xkbcommon::xkb::Keycode::new(u32::from(keycode)); + + let levels = self.levels(code); + let level = if modifiers.shift && levels > 1 { 1 } else { 0 }; + + let syms = self.keymap.key_get_syms_by_level(code, 0, level); + if syms.is_empty() { + return None; + } + + let text: String = syms + .iter() + .filter_map(|sym| char::from_u32(xkbcommon::xkb::keysym_to_utf32(*sym))) + .collect(); + + // Управляющие символы (Enter, Tab, Backspace) возвращаются непустой + // строкой, но вставлять их как текст нельзя. + (!text.is_empty() && text.chars().all(|ch| !ch.is_control())).then_some(text) + } +} + +/// Код клавиши по её номеру в evdev: используется в тестах. +#[cfg(test)] +fn evdev(number: u32) -> Keycode { + Keycode::from(number + XKB_OFFSET) +} + +/// Тесты раскладки. +#[cfg(test)] +mod tests { + use super::*; + + fn layout(layout_name: &str) -> KeyboardLayout { + KeyboardLayout::new(&KeyboardConfig { + xkb_layout: Some(layout_name.to_string()), + ..KeyboardConfig::default() + }) + } + + /// Буква в базовой раскладке даёт символ. + #[test] + fn latin_letter_produces_text() { + // evdev 30 — клавиша `A`: в русской раскладке на ней «ф». + assert_eq!( + layout("us").text(evdev(30), ModifiersState::default()).as_deref(), + Some("a"), + "раскладка us" + ); + } + + /// Shift даёт заглавную букву. + #[test] + fn shift_gives_uppercase() { + let modifiers = ModifiersState { + shift: true, + ..ModifiersState::default() + }; + + assert_eq!( + layout("us").text(evdev(30), modifiers).as_deref(), + Some("A"), + "сдвинутая буква" + ); + } + + /// Русская раскладка даёт кириллицу для той же клавиши. + #[test] + fn cyrillic_layout_produces_cyrillic() { + assert_eq!( + layout("ru").text(evdev(30), ModifiersState::default()).as_deref(), + Some("ф"), + "раскладка ru" + ); + } + + /// Пробел печатает пробел. + #[test] + fn space_produces_space() { + assert_eq!( + layout("us").text(evdev(57), ModifiersState::default()).as_deref(), + Some(" ") + ); + } + + /// Стрелки не печатают символов. + #[test] + fn arrow_keys_produce_no_text() { + // evdev 103 — стрелка вверх. + assert_eq!(layout("us").text(evdev(103), ModifiersState::default()), None); + } + + /// Escape не печатает символов. + #[test] + fn escape_produces_no_text() { + // evdev 1 — Escape. + assert_eq!(layout("us").text(evdev(1), ModifiersState::default()), None); + } + + /// Enter не печатает символов. + #[test] + fn enter_produces_no_text() { + // evdev 28 — Enter. + assert_eq!(layout("us").text(evdev(28), ModifiersState::default()), None); + } + + /// Сочетание с Ctrl не вставляется как текст. + #[test] + fn control_modifier_suppresses_text() { + let modifiers = ModifiersState { + ctrl: true, + ..ModifiersState::default() + }; + + assert_eq!(layout("us").text(evdev(30), modifiers), None); + } + + /// Неизвестная раскладка не ломает ввод: берётся запасная. + #[test] + fn unknown_layout_falls_back() { + assert_eq!( + layout("такой-раскладки-точно-нет") + .text(evdev(30), ModifiersState::default()) + .as_deref(), + Some("a"), + "нужна запасная раскладка" + ); + } +} diff --git a/src/compositor/mod.rs b/src/compositor/mod.rs new file mode 100644 index 0000000..73b4015 --- /dev/null +++ b/src/compositor/mod.rs @@ -0,0 +1,2919 @@ +//! Lark Compositor — Wayland-композитор на базе smithay. +//! +//! Бэкенд отображения и ввода — окно winit, поэтому Lark DE может запускаться +//! как вложенный композитор внутри обычной X11/Wayland-сессии (что удобно для +//! разработки), так и поверх DRM/KMS на целевом устройстве. + +pub mod decoration; +mod input; +mod keys; + +use crate::config::Config; +use crate::shell::{LarkShell, TaskbarEntry, solid_color_buffer, start_menu, switcher}; +use crate::compositor::decoration::{DecorationAction, DecorationStyle}; +use crate::text::{FontRenderer, TextColor, TextElement}; +use anyhow::{Context, Result}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use smithay::backend::SwapBuffersError; +use smithay::backend::input::{ + AbsolutePositionEvent, Axis, AxisSource, ButtonState, Event, InputEvent, InputTime, KeyState, + Keycode, KeyboardKeyEvent, PointerAxisEvent, PointerButtonEvent, +}; +use smithay::backend::renderer::damage::{Error as OutputDamageTrackerError, OutputDamageTracker}; +use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement; +use smithay::backend::renderer::element::surface::WaylandSurfaceRenderElement; +use smithay::backend::renderer::element::{Element, Id as ElementId, Kind, RenderElement}; +use smithay::backend::renderer::gles::{GlesError, GlesRenderer}; +use smithay::backend::renderer::utils::{CommitCounter, on_commit_buffer_handler}; +use smithay::backend::renderer::RendererSuper; +use smithay::backend::renderer::ImportMemWl; +use smithay::backend::winit::{self, WinitEvent, WinitGraphicsBackend, WinitInput}; +use smithay::desktop::space::{self, Space, space_render_elements}; +use smithay::desktop::{PopupKind, PopupManager, Window}; +use smithay::input::Seat; +use smithay::input::keyboard::{FilterResult, KeyboardHandle, LedState, XkbConfig}; +use smithay::input::pointer::{AxisFrame, ButtonEvent, MotionEvent, PointerHandle}; +use smithay::input::{SeatHandler, SeatState}; +use smithay::output::{Mode, Output, PhysicalProperties, Subpixel}; +use smithay::reexports::calloop::{ + EventLoop, Interest, Mode as CalloopMode, PostAction, generic::Generic, +}; +use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel; +use smithay::reexports::wayland_server::protocol::{wl_seat, wl_surface::WlSurface}; +use smithay::reexports::wayland_server::{ + Client, Display, DisplayHandle, + backend::{ClientData, ClientId, DisconnectReason}, + protocol::wl_buffer, +}; +use smithay::utils::user_data::UserDataMap; +use smithay::utils::{ + Buffer, Clock, IsAlive, Logical, Monotonic, Physical, Point, Rectangle, Scale, SERIAL_COUNTER, + Serial, Size, Transform, +}; +use smithay::wayland::buffer::BufferHandler; +use smithay::wayland::compositor::{ + CompositorClientState, CompositorHandler, CompositorState, SurfaceAttributes, TraversalAction, + with_surface_tree_downward, +}; +use smithay::wayland::output::{OutputHandler, OutputManagerState}; +use smithay::wayland::pointer_constraints::PointerConstraintsHandler; +use smithay::wayland::selection::{ + SelectionHandler, + data_device::{DataDeviceHandler, DataDeviceState, WaylandDndGrabHandler, set_data_device_focus}, + primary_selection::{PrimarySelectionHandler, PrimarySelectionState, set_primary_focus}, +}; +use smithay::reexports::wayland_server::Resource; +use smithay::wayland::seat::WaylandFocus; +use smithay::wayland::shell::xdg::{ + Configure, PopupSurface, PositionerState, ShellClient, ToplevelState, ToplevelSurface, + XdgShellHandler, XdgShellState, +}; +use smithay::wayland::shm::{ShmHandler, ShmState}; +use smithay::wayland::socket::ListeningSocketSource; +use smithay::wayland::presentation::PresentationState; +use smithay::wayland::shell::xdg::decoration::{ + XdgDecorationHandler, XdgDecorationState, +}; +use smithay::wayland::viewporter::ViewporterState; +use smithay::reexports::wayland_protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode as DecorationMode; + +use std::sync::atomic::{AtomicBool, AtomicI32, AtomicUsize, Ordering}; + +/// Имя выхода, публикуемого клиентам через `wl_output`. +const OUTPUT_NAME: &str = "lark-0"; +/// Размер окна, предлагаемый клиенту в первом `configure`. +const DEFAULT_WINDOW_SIZE: (i32, i32) = (800, 600); +/// Частота обновления выхода, мГц. +const OUTPUT_REFRESH_MHZ: i32 = 60_000; +/// Смещение между scancode winit и `Keycode` smithay. +/// +/// Бэкенд winit получает `PhysicalKey::to_scancode()`, то есть код из +/// linux/input-event-codes.h, и приводит его к `Keycode` прибавлением +/// `XKB_KEYCODE_OFFSET`. Повторяем это преобразование, чтобы горячие клавиши +/// сравнивались с теми же значениями, что приходят из событий ввода. +const KEYCODE_OFFSET: u32 = 8; + +/// Код клавиши Escape в linux/input-event-codes.h. +const SCANCODE_ESC: u32 = 1; +const SCANCODE_BACKSPACE: u32 = 14; +const SCANCODE_TAB: u32 = 15; +/// Клавиши управления меню «Пуск». +const SCANCODE_UP: u32 = 103; +const SCANCODE_DOWN: u32 = 108; +const SCANCODE_RETURN: u32 = 28; +const SCANCODE_KPENTER: u32 = 96; +/// Код клавиши Left Ctrl в linux/input-event-codes.h. +const SCANCODE_LEFTCTRL: u32 = 29; +/// Код клавиши Left Meta (Super) в linux/input-event-codes.h. +const SCANCODE_LEFTMETA: u32 = 125; +/// Код клавиши Left Shift в linux/input-event-codes.h. +const SCANCODE_LEFTSHIFT: u32 = 42; +/// Код клавиши Delete в linux/input-event-codes.h. +const SCANCODE_DELETE: u32 = 111; + +/// Код левой кнопки мыши в linux/input-event-codes.h. +/// +/// Бэкенд winit для X11 преобразует индекс кнопки в этот код, поэтому +/// сравнение с ним корректно на обоих бэкендах. +const BTN_LEFT: u32 = 0x110; + +/// Главный композитор Lark DE. +pub struct LarkCompositor { + config: Arc, + running: Arc, +} + +impl LarkCompositor { + /// Создаёт и инициализирует композитор. + pub fn new(config: &Config) -> Result { + tracing::info!("Инициализация композитора..."); + + Ok(Self { + config: Arc::new(config.clone()), + running: Arc::new(AtomicBool::new(true)), + }) + } + + /// Возвращает флаг, управляющий завершением главного цикла. + /// + /// Позволяет остановить композитор из другого потока (например, по + /// сигналу или из оболочки), не прерывая цикл изнутри. + pub fn running_flag(&self) -> Arc { + Arc::clone(&self.running) + } + + /// Запускает главный цикл композитора. + pub fn run(&mut self, shell: &mut LarkShell) -> Result<()> { + tracing::info!("Композитор запущен, главный цикл начат"); + + let config = Arc::clone(&self.config); + run_event_loop(config, Arc::clone(&self.running), shell) + .context("главный цикл композитора прерван") + } +} + +/// Регистрирует сочетания клавиш: выход из сессии и управление окнами. +fn register_default_shortcuts(state: &mut App, running: &Arc) { + let flag = Arc::clone(running); + let binding = [ + Keycode::from(SCANCODE_LEFTCTRL + KEYCODE_OFFSET), + Keycode::from(SCANCODE_ESC + KEYCODE_OFFSET), + ]; + + state.input.register_shortcut(binding, move || { + tracing::info!("получен запрос выхода (Ctrl+Esc)"); + flag.store(false, Ordering::SeqCst); + }); + + // Super сворачивает активное окно, Shift+Super разворачивает. + // Комбинации проверяются в `handle_key` после учёта ввода в + // `InputManager`, чтобы не дублировать логику отслеживания нажатий. + let super_key = Keycode::from(SCANCODE_LEFTMETA + KEYCODE_OFFSET); + let shift_key = Keycode::from(SCANCODE_LEFTSHIFT + KEYCODE_OFFSET); + + state.input.register_window_shortcut( + [super_key, shift_key], + input::WindowShortcut::ToggleMaximize, + ); + state + .input + .register_window_shortcut([super_key], input::WindowShortcut::Minimize); + state + .input + .register_window_shortcut([super_key, close_key()], input::WindowShortcut::Close); + + // Переключение рабочих областей: Super+1..4 — переход на область, + // Ctrl+Super+стрелки — шаг по кругу. Обработчикам нужен `&mut App`, + // а замыкания горячих клавиш его не получают, поэтому они записывают + // запрос в общие счётчики, а цикл событий его выполняет. + let request = Arc::clone(&state.workspace_request); + let step = Arc::clone(&state.workspace_step); + + let move_request = Arc::clone(&state.window_move_request); + + for digit in 1..=WORKSPACE_KEYS { + let key = workspace_key(digit); + + let request = Arc::clone(&request); + state + .input + .register_shortcut([super_key, key], move || { + request.store(digit, Ordering::SeqCst); + }); + + // Super+Shift+цифра уводит активное окно на другую область. + let move_request = Arc::clone(&move_request); + state + .input + .register_shortcut([super_key, shift_key, key], move || { + move_request.store(digit, Ordering::SeqCst); + }); + } + + // Шаг по кругу задаётся отдельным счётчиком со знаком: смешивать его с + // номером области в одном значении неудобно читать. + // Коды evdev: 105 — влево, 106 — вправо. + for (arrow_code, direction) in [(105u32, -1i32), (106u32, 1i32)] { + let ctrl = Keycode::from(SCANCODE_LEFTCTRL + KEYCODE_OFFSET); + let arrow = Keycode::from(arrow_code + KEYCODE_OFFSET); + let step = Arc::clone(&step); + + state + .input + .register_shortcut([ctrl, super_key, arrow], move || { + step.store(direction, Ordering::SeqCst); + }); + } +} + +/// Клавиша закрытия окна (Delete в сочетании с Super). +fn close_key() -> Keycode { + Keycode::from(SCANCODE_DELETE + KEYCODE_OFFSET) +} + +/// Обрабатывает клавиши переключателя окон по `Alt+Tab`. +/// +/// Возвращает `true`, если клавиша поглощена и в приложение уходить не +/// должна. +/// +/// Логика повторяет поведение Windows: `Tab` при удержанном `Alt` открывает +/// список и перебирает окна, отпускание `Alt` подтверждает выбор, `Escape` +/// отменяет. Фокус переводится только при подтверждении — иначе быстрый +/// пролёт по списку переключал бы окна на каждом шаге. +fn handle_window_switcher( + state: &mut App, + shell: &mut LarkShell, + keycode: Keycode, + key_state: KeyState, +) -> bool { + let pressed = key_state == KeyState::Pressed; + let modifiers = state.input.modifiers(); + + // `Tab` при удержанном `Alt`: открыть список или перейти дальше. + // Сочетания с `Ctrl` и `Super` не перехватываются — они принадлежат + // приложениям и композитору. + if keycode == Keycode::from(SCANCODE_TAB + KEYCODE_OFFSET) + && modifiers.alt + && !modifiers.ctrl + && !modifiers.logo + { + if !pressed { + // Отпускание `Tab` ничего не делает: перебор идёт по нажатию, + // иначе каждое окно пропускалось бы через одно. + return true; + } + + if shell.switcher_open() { + if modifiers.shift { + shell.switcher_previous(); + } else { + shell.switcher_next(); + } + } else { + let entries = state.switcher_entries(); + shell.open_switcher(entries); + + // Первое нажатие сразу уводит выбор с текущего окна на + // следующее: иначе до соседнего окна нужно было бы два `Tab`. + if modifiers.shift { + shell.switcher_previous(); + } else { + shell.switcher_next(); + } + } + + return true; + } + + if !shell.switcher_open() { + return false; + } + + // Отпускание `Alt` подтверждает выбор. + if !pressed && (keycode == alt_keycode(false) || keycode == alt_keycode(true)) { + match shell.close_switcher() { + Some(id) => { + tracing::info!("переключение на окно {id}"); + state.focus_window(id); + } + None => tracing::debug!("переключатель закрыт без выбора"), + } + + return true; + } + + // `Escape` отменяет переключение. + if pressed && keycode == Keycode::from(SCANCODE_ESC + KEYCODE_OFFSET) { + shell.close_switcher(); + return true; + } + + // Пока переключатель открыт, прочие клавиши уходят приложению: список + // закроет отпускание `Alt`. + false +} + +/// Код клавиши `Alt`: `false` — левый, `true` — правый. +fn alt_keycode(right: bool) -> Keycode { + const SCANCODE_LEFTALT: u32 = 56; + const SCANCODE_RIGHTALT: u32 = 100; + + let code = if right { SCANCODE_RIGHTALT } else { SCANCODE_LEFTALT }; + Keycode::from(code + KEYCODE_OFFSET) +} + +/// Применяет результат запуска из меню «Пуск». +/// +/// Часть пунктов меню выполняется самим композитором, а не внешней +/// программой: завершение сессии нельзя поручать отдельной утилите, которая +/// может быть не установлена. +fn apply_launch_outcome(outcome: start_menu::LaunchOutcome, running: &Arc) { + match outcome { + start_menu::LaunchOutcome::Logout => { + tracing::info!("завершение сессии из меню «Пуск»"); + running.store(false, Ordering::SeqCst); + } + start_menu::LaunchOutcome::NotFound => { + tracing::warn!("выбранное приложение не найдено в системе"); + } + start_menu::LaunchOutcome::Started | start_menu::LaunchOutcome::Nothing => {} + } +} + +/// Передаёт нажатие клавиши в меню «Пуск». +/// +/// Возвращает `false`, если клавишу нужно передать окну: например, Backspace +/// при пустой строке поиска должен удалять символ в приложении. +fn handle_start_menu_key( + state: &mut App, + shell: &mut LarkShell, + keycode: Keycode, + key_state: KeyState, +) -> bool { + let running = Some(Arc::clone(&state.running)); + // Символы добавляются только по нажатию: обработка отпускания вставила + // бы каждый символ дважды. + if key_state != KeyState::Pressed { + return true; + } + + // Управление выделением: стрелки и Enter. + if keycode == Keycode::from(SCANCODE_UP + KEYCODE_OFFSET) { + shell.start_menu_select_previous(); + return true; + } + if keycode == Keycode::from(SCANCODE_DOWN + KEYCODE_OFFSET) { + shell.start_menu_select_next(); + return true; + } + if keycode == Keycode::from(SCANCODE_ESC + KEYCODE_OFFSET) { + shell.close_start_menu(); + return true; + } + // Backspace обрабатывается по коду клавиши, а не по символу: он не + // печатается, и поиск по раскладке вернул бы пустое. + if keycode == Keycode::from(SCANCODE_BACKSPACE + KEYCODE_OFFSET) { + return shell.start_menu_backspace(); + } + if keycode == Keycode::from(SCANCODE_RETURN + KEYCODE_OFFSET) + || keycode == Keycode::from(SCANCODE_KPENTER + KEYCODE_OFFSET) + { + let outcome = shell.launch_selected_app(); + if let Some(running) = running { + apply_launch_outcome(outcome, &running); + } + return true; + } + + + // Символ читается раскладкой сессии: иначе русская раскладка набиралась + // бы латиницей. + let Some(layout) = state.keyboard_layout.as_ref() else { + return true; + }; + + let modifiers = state.input.modifiers(); + + match layout.text(keycode, modifiers) { + Some(text) => shell.start_menu_key(&text), + // Клавиша не печатает символ: она уже обработана выше или уходит + // мимо меню. + None => true, + } +} + +/// Минимальный размер окна при изменении мышью. +const MIN_WINDOW_SIZE: i32 = 120; + +/// Сколько рабочих областей переключается цифрами. +const WORKSPACE_KEYS: usize = 4; + +/// Значение счётчика, означающее «запроса нет». +const WORKSPACE_NONE: usize = usize::MAX; + +/// Клавиша цифры для перехода на рабочую область. +/// +/// Цифры начинаются с `SCANCODE_1`, поэтому у первой области код 2. +fn workspace_key(digit: usize) -> Keycode { + // Код клавиши «1» в evdev. + const SCANCODE_1: u32 = 2; + Keycode::from(SCANCODE_1 + (digit as u32 - 1) + KEYCODE_OFFSET) +} + +/// Состояние изменения размера окна за край рамки. +#[derive(Debug, Clone)] +pub struct ResizeState { + /// Окно, размер которого меняется. + pub window: WlSurface, + /// Какие края тянутся. + pub action: DecorationAction, + /// Точка, с которой начат жест. + pub start_point: Point, + /// Прямоугольник окна в момент начала жеста. + pub start_rect: Rectangle, +} + +/// Состояние перетаскивания окна за заголовок. +#[derive(Debug, Clone)] +pub struct DragState { + /// Окно, которое перетаскивают. + pub window: WlSurface, + /// Смещение точки захвата относительно левого верхнего угла окна. + pub grab_offset: Point, + /// Точка, с которой начался жест. + pub start_point: Point, + /// Позиция окна в момент начала жеста. + pub start_origin: Point, + /// Перетаскивание началось, а не было случайным кликом. + pub moved: bool, +} + +/// Состояние показа окна. +/// +/// Хранится отдельно от `Window`, потому что `Space` хранит только текущую +/// геометрию, а для возврата из свёрнутого или развёрнутого состояния нужна +/// та, на которой окно было до изменения. +#[derive(Debug, Clone, Default)] +pub struct WindowState { + /// Окно свёрнуто и убрано со сцены. + pub minimized: bool, + /// Окно развёрнуто на всю рабочую область. + pub maximized: bool, + /// Геометрия до разворачивания, для возврата по кнопке. + pub restore_rect: Option>, + /// Рабочая область, в которой открыто окно. + pub workspace: usize, +} + +impl WindowState { + /// Окно показано в обычном виде. + #[allow(dead_code, reason = "нужно для отображения состояния в панели")] + fn is_normal(&self) -> bool { + !self.minimized && !self.maximized + } +} + +/// Ошибка отдельного кадра рендеринга. +enum FrameError { + /// Контекст OpenGL потерян — восстановление невозможно, цикл надо завершить. + ContextLost(anyhow::Error), + /// Прочая ошибка: кадр можно пропустить и продолжить работу. + Transient(anyhow::Error), +} + +impl From> for FrameError { + fn from(err: OutputDamageTrackerError) -> Self { + match err { + OutputDamageTrackerError::Rendering(err) => FrameError::Transient(anyhow::anyhow!(err)), + other => FrameError::Transient(anyhow::anyhow!(other)), + } + } +} + +/// Поднимает Wayland-сервер, выход и устройства ввода, затем крутит цикл событий. +fn run_event_loop(config: Arc, running: Arc, shell: &mut LarkShell) -> Result<()> { + // --- Графический бэкенд и окно ------------------------------------------ + let (mut backend, mut winit) = winit::init::() + .map_err(|e| anyhow::anyhow!("не удалось инициализировать winit: {e}"))?; + + // --- Цикл событий ------------------------------------------------------- + let mut event_loop = EventLoop::try_new().context("не удалось создать цикл событий")?; + + // --- Wayland-сервер и его глобалы -------------------------------------- + let display: Display = Display::new().context("не удалось создать Wayland-дисплей")?; + let dh = display.handle(); + + // Часы композитора: их идентификатор передаётся в `wp_presentation`. + let clock = Clock::new(); + let clock_id = clock.id() as u32; + + let compositor_state = CompositorState::new::(&dh); + let xdg_shell_state = XdgShellState::new::(&dh); + let shm_state = ShmState::new::(&dh, vec![]); + let output_manager_state = OutputManagerState::new_with_xdg_output::(&dh); + let data_device_state = DataDeviceState::new::(&dh); + let primary_selection_state = PrimarySelectionState::new::(&dh); + + // Расширения протоколов. `viewporter` нужен клиентам, чтобы рисовать + // в поверхности меньше окна, `presentation_time` — чтобы знать, когда + // кадр показан, `xdg-decoration` — чтобы согласовать декорации с + // композитором. + let viewporter_state = ViewporterState::new::(&dh); + let decoration_state = XdgDecorationState::new::(&dh); + + // --- Выход -------------------------------------------------------------- + let initial_mode = Mode { + size: backend.window_size(), + refresh: OUTPUT_REFRESH_MHZ, + }; + let output = Output::new( + OUTPUT_NAME.to_string(), + PhysicalProperties { + size: (0, 0).into(), + subpixel: Subpixel::Unknown, + make: "Lark".into(), + model: "Lark DE".into(), + serial_number: OUTPUT_NAME.to_string(), + }, + ); + // Глобал намеренно живёт до конца функции: он deregistered при drop. + let _output_global = output.create_global::(&dh); + output.change_current_state( + Some(initial_mode), + Some(Transform::Flipped180), + None, + Some((0, 0).into()), + ); + output.set_preferred(initial_mode); + + // --- Устройства ввода --------------------------------------------------- + let mut seat_state = SeatState::new(); + let seat = seat_state.new_wl_seat(&dh, &config.seat.name); + + // `presentation` требует идентификатор часов: по нему клиент понимает, + // к какому источнику времени относится обратная связь. + let presentation_state = PresentationState::new::(&dh, clock_id); + let mut state = App { + display_handle: dh.clone(), + clock, + running: Arc::clone(&running), + output, + output_refresh: OUTPUT_REFRESH_MHZ, + space: Space::default(), + popups: PopupManager::default(), + compositor_state, + xdg_shell_state, + shm_state, + _output_manager_state: output_manager_state, + data_device_state, + primary_selection_state, + viewporter_state, + presentation_state, + decoration_state, + seat_state, + seat, + toplevels: HashMap::new(), + window_ids: HashMap::new(), + window_states: HashMap::new(), + next_window_id: 1, + taskbar_target: None, + workspace_target: None, + panel_height: config.panel.height as i32, + panel_position: config.panel.position.clone(), + server_side_decorations: config.decoration.server_side, + drag: None, + resize: None, + keyboard_layout: Some(keys::KeyboardLayout::new(&config.keyboard)), + workspace_request: Arc::new(AtomicUsize::new(WORKSPACE_NONE)), + workspace_step: Arc::new(AtomicI32::new(0)), + window_move_request: Arc::new(AtomicUsize::new(WORKSPACE_NONE)), + current_workspace: 0, + workspace_count: config.desktop.workspaces.max(1) as usize, + decoration_style: DecorationStyle::from_config( + &config.decoration, + shell.accent_color(), + config.theme.name.eq_ignore_ascii_case("light"), + ), + input: input::InputManager::new(), + socket_name: String::new(), + needs_redraw: false, + // Первый кадр всегда рисуется полностью. + force_full_redraw: true, + }; + + state.space.map_output(&state.output, (0, 0)); + + let pointer = state.seat.add_pointer(); + let keyboard = state + .seat + .add_keyboard(keyboard_xkb_config(&config), 25, 600) + .map_err(|e| anyhow::anyhow!("не удалось инициализировать клавиатуру: {e}"))?; + + // shm-форматы известны только после создания рендерера. + state.shm_state.update_formats(backend.renderer().shm_formats()); + + // --- Источники событий -------------------------------------------------- + // Сокет создаётся в `XDG_RUNTIME_DIR`, поэтому каталог должен существовать + // и принадлежать текущему пользователю. + let runtime_dir = std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .ok_or_else(|| anyhow::anyhow!("не задана переменная окружения XDG_RUNTIME_DIR"))?; + + if !runtime_dir.is_dir() { + std::fs::create_dir_all(&runtime_dir) + .with_context(|| format!("не удалось создать {}", runtime_dir.display()))?; + } + + // Каталог доступен только владельцу: иначе другие пользователи смогут + // подключиться к нашей сессии. + restrict_runtime_dir(&runtime_dir); + + let source = ListeningSocketSource::new_auto() + .with_context(|| format!("не удалось занять сокет в {}", runtime_dir.display()))?; + state.socket_name = source.socket_name().to_string_lossy().into_owned(); + + register_default_shortcuts(&mut state, &running); + + event_loop + .handle() + .insert_source(source, |stream, _, state: &mut App| { + if let Err(err) = state + .display_handle + .insert_client(stream, Arc::new(ClientState::default())) + { + tracing::warn!("не удалось подключить Wayland-клиента: {err}"); + } + }) + .context("не удалось зарегистрировать источник Wayland-сокета")?; + + event_loop + .handle() + .insert_source( + Generic::new(display, Interest::READ, CalloopMode::Level), + |_, display, state| { + // SAFETY: `display` живёт дольше этого источника событий — + // он перемещён в calloop и удаляется только после выхода из цикла. + let display = unsafe { display.get_mut() }; + if let Err(err) = display.dispatch_clients(state) { + tracing::warn!("ошибка обработки запросов клиентов: {err}"); + } + Ok(PostAction::Continue) + }, + ) + .context("не удалось зарегистрировать источник Wayland-сервера")?; + + // Дочерние процессы (shell, терминал, файловый менеджер) наследуют + // окружение, поэтому выставляем его сразу после создания сокета. + unsafe { + std::env::set_var("WAYLAND_DISPLAY", &state.socket_name); + } + + tracing::info!( + socket = %state.socket_name, + "Lark DE запущена, вход в цикл событий" + ); + + // --- Главный цикл ------------------------------------------------------- + while state.running.load(Ordering::SeqCst) { + // События накапливаются в замыкании, потому что `state` уже занят + // заимствованием и передать его в `dispatch_new_events` нельзя. + let mut events = Vec::new(); + let status = winit.dispatch_new_events(|event| events.push(event)); + + if matches!( + status, + smithay::reexports::winit::event_loop::pump_events::PumpStatus::Exit(_) + ) { + tracing::info!("окно winit закрыто, завершаем работу"); + break; + } + + for event in events { + handle_winit_event(event, &mut state, shell, &keyboard, &pointer); + } + + // Запросы рабочих областей выполняются тут же: обработчики горячих + // клавиш работают через общие счётчики и не имеют доступа к App. + state.apply_workspace_requests(); + + // Панель задач получает список окон перед расчётом попаданий, иначе + // клики обрабатывались бы по устаревшим данным. + shell.set_windows(state.taskbar_entries()); + + // Переключатель рабочих областей получает счётчики окон, чтобы + // показать, где что открыто, даже если областей не видно на экране. + shell.set_workspaces( + state.current_workspace, + state.workspace_count, + (0..state.workspace_count) + .map(|index| state.workspace_window_count(index)) + .collect(), + ); + shell.update(); + + // Метка времени для кадровых колбэков клиентов (в мс от загрузки + // монотонных часов, как это ожидает протокол). + let frame_time_ms = state.clock.now().as_millis(); + + if let Err(err) = draw_frame(&mut state, &mut backend, shell, frame_time_ms) { + match err { + FrameError::ContextLost(err) => { + tracing::error!("контекст рендеринга потерян: {err}"); + break; + } + FrameError::Transient(err) => { + tracing::warn!("кадр пропущен: {err}"); + } + } + } + + state.space.refresh(); + state.popups.cleanup(); + + // Окна могли быть уничтожены клиентом: без очистки они остались бы + // в реестре и в сцене навсегда. + state.cleanup_dead_windows(); + + if let Err(err) = state.display_handle.flush_clients() { + tracing::warn!("не удалось отправить данные клиентам: {err}"); + break; + } + + // Небольшой таймаут ограничивает частоту кадров и даёт event loop + // обрабатывать источники, даже если окно неактивно. + event_loop + .dispatch(Some(frame_interval(&state)), &mut state) + .context("ошибка цикла событий")?; + } + + tracing::info!("композитор остановлен"); + Ok(()) +} + +/// Ограничивает права на каталог `XDG_RUNTIME_DIR` владельцем и группой. +/// +/// Ошибки игнорируются: каталог мог быть создан системой с более широкими +/// правами, и это не повод не запускать сессию. +fn restrict_runtime_dir(path: &std::path::Path) { + use std::os::unix::fs::PermissionsExt; + + let metadata = match std::fs::metadata(path) { + Ok(metadata) => metadata, + Err(err) => { + tracing::debug!("не удалось прочитать права {}: {err}", path.display()); + return; + } + }; + + let mode = metadata.permissions().mode() & 0o777; + let wanted = 0o700; + + if mode != wanted { + let mut permissions = metadata.permissions(); + permissions.set_mode(wanted); + if let Err(err) = std::fs::set_permissions(path, permissions) { + tracing::debug!("не удалось ограничить права {}: {err}", path.display()); + } + } +} + +/// Интервал между кадрами по текущей частоте обновления выхода. +fn frame_interval(state: &App) -> Duration { + let refresh = state.output_refresh.max(1); + Duration::from_secs_f64(1_000.0 / f64::from(refresh)) +} + +/// Строит конфигурацию xkbcommon из настроек пользователя. +fn keyboard_xkb_config(config: &Config) -> XkbConfig<'_> { + let keyboard = &config.keyboard; + + XkbConfig { + rules: keyboard.xkb_rules.as_deref().unwrap_or("evdev"), + model: keyboard.xkb_model.as_deref().unwrap_or("pc105"), + layout: keyboard.xkb_layout.as_deref().unwrap_or("us"), + variant: keyboard.xkb_variant.as_deref().unwrap_or(""), + options: keyboard.xkb_options.clone(), + } +} + +/// Обрабатывает событие окна/ввода winit. +fn handle_winit_event( + event: WinitEvent, + state: &mut App, + shell: &mut LarkShell, + keyboard: &KeyboardHandle, + pointer: &PointerHandle, +) { + match event { + WinitEvent::Input(input) => handle_input_event(input, state, shell, keyboard, pointer), + WinitEvent::Resized { size, .. } => { + let mode = Mode { + size, + refresh: state.output_refresh, + }; + state.output.change_current_state(Some(mode), None, None, None); + state.output.set_preferred(mode); + tracing::debug!("размер окна изменён на {size:?}"); + } + WinitEvent::CloseRequested => { + tracing::info!("получен запрос закрытия окна"); + state.running.store(false, Ordering::SeqCst); + } + WinitEvent::Focus(focused) => { + if !focused { + // Окно потеряло фокус: отпускаем клавиши, иначе приложение + // получит «залипшее» нажатие после возврата фокуса. + if let Some(keyboard) = state.seat.get_keyboard() { + for keycode in keyboard.pressed_keys() { + keyboard.input::<(), _>( + state, + keycode, + KeyState::Released, + SERIAL_COUNTER.next_serial(), + InputTime::now(), + |_, _, _| FilterResult::Forward, + ); + } + } + state.input.release_all(); + } + } + WinitEvent::Redraw => state.needs_redraw = true, + } +} + +/// Маршрутизирует событие ввода на соответствующее устройство. +fn handle_input_event( + event: InputEvent, + state: &mut App, + shell: &mut LarkShell, + keyboard: &KeyboardHandle, + pointer: &PointerHandle, +) { + match event { + InputEvent::Keyboard { event } => { + let keycode = event.key_code(); + let key_state = event.state(); + + // Учитываем нажатие для горячих клавиш и отпускания всех клавиш + // при отмене комбинации. + let lark_state = match key_state { + KeyState::Pressed => input::KeyState::Pressed, + KeyState::Released => input::KeyState::Released, + }; + let shortcut = state.input.handle_key(keycode, lark_state); + + // Сочетание для окна перехватывает клавишу: приложение не должно + // получать Super или Shift, нажатые для управления окном. + if let Some(input::ShortcutResult::Window(action)) = shortcut { + state.apply_window_shortcut(action); + return; + } + + // Переключатель окон по `Alt+Tab` забирает клавиши себе: `Tab` + // и отпускание `Alt` не должны уходить в приложение. + if handle_window_switcher(state, shell, keycode, key_state) { + return; + } + + // Открытое меню «Пуск» забирает клавиатуру себе: символы идут в + // строку поиска, а не в окно под ним. Комбинации клавиш при + // этом работают — они обработаны выше. + if shell.start_menu_open() && handle_start_menu_key(state, shell, keycode, key_state) { + return; + } + + // `key_code`/`state` приходят из трейта `KeyboardKeyEvent`, + // который реализует `WinitKeyboardInputEvent`. + keyboard.input::<(), _>( + state, + keycode, + key_state, + SERIAL_COUNTER.next_serial(), + event.time(), + |_, _, _| FilterResult::Forward, + ); + } + InputEvent::PointerMotionAbsolute { event } => { + let time = event.time(); + // `position_transformed` переводит относительные координаты окна + // (0..1) в логические пикели выхода. + let size = state + .output + .current_mode() + .map(|mode| mode.size) + .unwrap_or_else(|| Size::new(1, 1)); + // `position_transformed` принимает целочисленный масштаб, поэтому + // дробный приводим к ближайшему целому. + let scale = state.output.current_scale().fractional_scale().round() as i32; + let location = event.position_transformed(size.to_logical(scale)); + let serial = SERIAL_COUNTER.next_serial(); + + state.input.handle_pointer_motion(location.x, location.y); + + let under = surface_under(state, location); + let keyboard_target = under.as_ref().map(|(surface, _)| surface.clone()); + + // Панель задач перехватывает указатель целиком: окно под ней не + // должно получать фокус по наведению, а клик по переключателю + // рабочих областей или часам — доходить до клиента. + // Пока открыт переключатель окон, указатель принадлежит ему. + if shell.switcher_open() { + pointer.motion( + state, + None, + &MotionEvent { + location, + serial, + time, + }, + ); + pointer.frame(state); + return; + } + + let on_panel = shell.on_panel(location, state.output_size()); + let over_menu = shell.start_menu_open() && shell.start_menu_contains(location, state.output_size()); + if on_panel || over_menu { + state.taskbar_target = shell.window_at(location); + state.workspace_target = shell.workspace_at(location); + if let Some(keyboard) = state.seat.get_keyboard() { + if keyboard.current_focus().is_some() { + keyboard.set_focus(state, None, serial); + } + } + pointer.motion( + state, + None, + &MotionEvent { + location, + serial, + time, + }, + ); + pointer.frame(state); + return; + } + state.taskbar_target = None; + state.workspace_target = None; + + // Пока идёт перетаскивание или изменение размера, окно следует + // за указателем, а содержимое под ним не получает событий мыши. + if state.is_dragging() || state.is_resizing() { + state.update_drag(location); + state.update_resize(location); + + pointer.motion( + state, + None, + &MotionEvent { + location, + serial, + time, + }, + ); + pointer.frame(state); + return; + } + + // Декорации принадлежат композитору: приложение под указателем + // не должно получать события мыши вместо них. + if state.decoration_at(location).is_some() { + pointer.motion( + state, + None, + &MotionEvent { + location, + serial, + time, + }, + ); + pointer.frame(state); + return; + } + + pointer.motion( + state, + under, + &MotionEvent { + location, + serial, + time, + }, + ); + pointer.frame(state); + + // Фокус клавиатуры следует за указателем, иначе ввод уходит не туда. + sync_keyboard_focus(state, &keyboard_target, serial); + } + InputEvent::PointerButton { event } => { + let time = event.time(); + let state_code: ButtonState = event.state(); + + state.input.handle_pointer_button( + event.button_code(), + match state_code { + ButtonState::Pressed => input::ButtonState::Pressed, + ButtonState::Released => input::ButtonState::Released, + }, + ); + + // Клик по панели задач переводит фокус на соответствующее окно. + // Обрабатывается до передачи события клиенту: панель принадлежит + // композитору, а не приложению под курсором. + if state_code == ButtonState::Pressed && event.button_code() == BTN_LEFT { + if shell.switcher_open() { + shell.switcher_click(pointer.current_location(), state.output_size()); + } else if let Some(outcome) = + shell.start_menu_click(pointer.current_location(), state.output_size()) + { + apply_launch_outcome(outcome, &state.running); + } else if shell.start_button_hit(pointer.current_location()) { + shell.toggle_start_menu(); + } else if shell.start_menu_open() { + // Клик мимо меню закрывает его, но не должен попасть в + // окно под ним. + shell.close_start_menu(); + } else if let Some(index) = state.workspace_target { + state.switch_workspace(index); + } else if let Some(id) = state.taskbar_target { + state.activate_from_taskbar(id); + } else if let Some((id, action)) = state.decoration_at(pointer.current_location()) { + // Заголовок без кнопки запускает перетаскивание, остальное + // выполняется сразу. + if action == DecorationAction::Drag { + state.begin_drag(id, pointer.current_location()); + } else { + state.begin_decoration_action(id, action, pointer.current_location()); + } + } + } + + // Отпускание кнопки завершает жест с рамкой. + if state_code == ButtonState::Released { + if state.is_dragging() { + state.end_drag(); + } + if state.is_resizing() { + state.end_resize(); + } + } + + pointer.button( + state, + &ButtonEvent { + button: event.button_code(), + state: state_code, + serial: SERIAL_COUNTER.next_serial(), + time, + }, + ); + pointer.frame(state); + pointer.frame(state); + } + InputEvent::PointerAxis { event } => { + let time = event.time(); + // Непрерывная величина, если бэкенд её сообщает, иначе v120/120. + let horizontal = event + .amount(Axis::Horizontal) + .unwrap_or_else(|| event.amount_v120(Axis::Horizontal).unwrap_or(0.0) * 15.0 / 120.0); + let vertical = event + .amount(Axis::Vertical) + .unwrap_or_else(|| event.amount_v120(Axis::Vertical).unwrap_or(0.0) * 15.0 / 120.0); + + let mut frame = AxisFrame::new(time).source(event.source()); + + if horizontal != 0.0 { + frame = frame + .relative_direction(Axis::Horizontal, event.relative_direction(Axis::Horizontal)) + .value(Axis::Horizontal, horizontal); + if let Some(discrete) = event.amount_v120(Axis::Horizontal) { + frame = frame.v120(Axis::Horizontal, discrete as i32); + } + } + if vertical != 0.0 { + frame = frame + .relative_direction(Axis::Vertical, event.relative_direction(Axis::Vertical)) + .value(Axis::Vertical, vertical); + if let Some(discrete) = event.amount_v120(Axis::Vertical) { + frame = frame.v120(Axis::Vertical, discrete as i32); + } + } + + // Для тачпада нули означают конец жеста, а не отсутствие события. + if event.source() == AxisSource::Finger { + if event.amount(Axis::Horizontal) == Some(0.0) { + frame = frame.stop(Axis::Horizontal); + } + if event.amount(Axis::Vertical) == Some(0.0) { + frame = frame.stop(Axis::Vertical); + } + } + + pointer.axis(state, frame); + pointer.frame(state); + } + InputEvent::DeviceAdded { .. } => { + tracing::info!("обнаружено новое устройство ввода"); + } + _ => {} + } +} + +/// Возвращает поверхность верхнего окна под точкой `location` вместе с +/// координатами этой точки в системе координат поверхности. +fn surface_under( + state: &App, + location: Point, +) -> Option<(WlSurface, Point)> { + let (window, window_loc) = state.space.element_under(location)?; + let surface = window.wl_surface()?.into_owned(); + + // `element_under` уже вернул позицию окна; переводим точку в координаты + // поверхности с учётом геометрии клиента. + let geometry = window.geometry(); + let local = location - window_loc.to_f64() - geometry.loc.to_f64(); + Some((surface, local)) +} + +/// Ставит клавиатурный фокус на окно под указателем. +fn sync_keyboard_focus( + state: &mut App, + target: &Option, + serial: Serial, +) { + let Some(surface) = target else { + return; + }; + + let Some(keyboard) = state.seat.get_keyboard() else { + return; + }; + + if keyboard.current_focus() != Some(surface.clone()) { + keyboard.set_focus(state, Some(surface.clone()), serial); + } +} + +/// Элемент сцены Lark: поверхность окна, декорация или слой интерфейса. +/// +/// Агрегация в один тип нужна, потому что `space_render_elements` возвращает +/// элементы одного типа, а панель задач должна рисоваться поверх окон. +#[derive(Debug)] +pub enum LarkRenderElement { + /// Поверхность окна Wayland. + Surface(space::SpaceRenderElements>), + /// Декорация окна: заголовок и кнопки. + Decoration(MemoryRenderBufferRenderElement), + /// Слой интерфейса shell (панель задач). + Shell(MemoryRenderBufferRenderElement), +} + +impl Element for LarkRenderElement { + fn id(&self) -> &ElementId { + match self { + LarkRenderElement::Surface(elem) => elem.id(), + LarkRenderElement::Decoration(elem) | LarkRenderElement::Shell(elem) => elem.id(), + } + } + + fn kind(&self) -> Kind { + match self { + LarkRenderElement::Surface(elem) => elem.kind(), + LarkRenderElement::Decoration(elem) | LarkRenderElement::Shell(elem) => elem.kind(), + } + } + + fn src(&self) -> Rectangle { + match self { + LarkRenderElement::Surface(elem) => elem.src(), + LarkRenderElement::Decoration(elem) | LarkRenderElement::Shell(elem) => elem.src(), + } + } + + fn geometry(&self, scale: Scale) -> Rectangle { + match self { + LarkRenderElement::Surface(elem) => elem.geometry(scale), + LarkRenderElement::Decoration(elem) | LarkRenderElement::Shell(elem) => { + elem.geometry(scale) + } + } + } + + fn current_commit(&self) -> CommitCounter { + match self { + LarkRenderElement::Surface(elem) => elem.current_commit(), + LarkRenderElement::Decoration(elem) | LarkRenderElement::Shell(elem) => { + elem.current_commit() + } + } + } +} + +impl RenderElement for LarkRenderElement { + fn draw( + &self, + frame: &mut ::Frame<'_, '_>, + buffer_pos: Rectangle, + clip: Rectangle, + damage: &[Rectangle], + opaque_regions: &[Rectangle], + cache: Option<&UserDataMap>, + ) -> Result<(), ::Error> { + match self { + LarkRenderElement::Surface(elem) => { + elem.draw(frame, buffer_pos, clip, damage, opaque_regions, cache) + } + LarkRenderElement::Decoration(elem) | LarkRenderElement::Shell(elem) => { + elem.draw(frame, buffer_pos, clip, damage, opaque_regions, cache) + } + } + } +} + +/// Рисует один кадр и отправляет буфер в окно. +fn draw_frame( + state: &mut App, + backend: &mut WinitGraphicsBackend, + shell: &mut LarkShell, + frame_time_ms: u32, +) -> Result<(), FrameError> { + let output = state.output.clone(); + let size = output + .current_mode() + .map(|mode| mode.size) + .ok_or_else(|| FrameError::Transient(anyhow::anyhow!("выход без активного режима")))?; + + let mut damage_tracker = OutputDamageTracker::from_output(&output); + + // `buffer_age` отражает возраст текущего back buffer. Он валиден только + // сразу после `bind`, поэтому читается здесь, до отрисовки. + // + // Если предыдущий кадр не был отправлен (damage tracking решил, что + // перерисовывать нечего), содержимое буфера уже не актуально, и нужно + // рисовать полностью — иначе damage tracker будет считать, что экран + // по-прежнему показывает верную картинку. + let age = if state.force_full_redraw { + 0 + } else { + backend.buffer_age().unwrap_or(0) + }; + + // `framebuffer` держит изменяемое заимствование `backend`, поэтому сбор + // элементов, рендеринг и `submit` должны идти внутри одной области + // видимости. + let damage_list = { + let (renderer, mut framebuffer) = backend.bind().map_err(classify_swap_error)?; + + // Окна отрисовываются из `Space`: он отдаёт элементы своего типа, + // а панель задач добавляется поверх отдельным слоем. + let space_elements = + space_render_elements::(renderer, [&state.space], &output, 1.0) + .map_err(|err| FrameError::Transient(anyhow::anyhow!("{err}")))?; + + let shell_elements = shell.render_elements(renderer, size); + let decoration_elements = state.render_decorations(renderer, &mut shell.font); + + // Порядок важен: элементы сцены отрисовываются **в обратном + // порядке** списка, поэтому верхние слои ставятся в начало. + let elements = shell_elements + .into_iter() + .map(LarkRenderElement::Shell) + .chain(decoration_elements.into_iter().map(LarkRenderElement::Decoration)) + .chain(space_elements.into_iter().map(LarkRenderElement::Surface)) + .collect::>(); + + let clear_color = shell.background_color(); + + // Результат заимствует damage-трекер, поэтому список повреждений + // копируется, а буфер освобождается до вызова `submit`. + let rendered = + damage_tracker.render_output(renderer, &mut framebuffer, age, &elements, clear_color); + + match rendered { + Ok(result) => result.damage.map(|damage| damage.to_vec()), + Err(err) => return Err(FrameError::from(err)), + } + }; + + match damage_list { + Some(damage) => { + // Кадр показываем только если что-то изменилось. + state.send_frames(frame_time_ms); + backend.submit(Some(&damage)).map_err(classify_swap_error)?; + state.force_full_redraw = false; + } + None => { + // Ничего не отправили: back buffer остался прежним, поэтому + // следующий кадр обязан быть полным. + state.force_full_redraw = true; + tracing::trace!("повреждений нет, буфер не отправляется"); + } + } + + state.needs_redraw = false; + Ok(()) +} + +/// Классифицирует ошибку обмена буферами: потеря контекста завершает цикл. +fn classify_swap_error(err: SwapBuffersError) -> FrameError { + match err { + SwapBuffersError::ContextLost(err) => FrameError::ContextLost(anyhow::anyhow!(err)), + other => FrameError::Transient(anyhow::anyhow!("{other}")), + } +} + +/// Внутреннее состояние композитора. +pub struct App { + /// Хендл дисплея: нужен для `insert_client`, `get_client` и flush. + pub display_handle: DisplayHandle, + /// Монотонные часы для временных меток кадров. + pub clock: Clock, + /// Флаг завершения главного цикла. + pub running: Arc, + /// Имя Wayland-сокета, на котором слушает композитор. + pub socket_name: String, + + /// Единственный выход, отображаемый в окне. + pub output: Output, + /// Текущая частота обновления выхода, мГц. + pub output_refresh: i32, + /// Пространство окон: позиционирование, z-порядок и попадания указателя. + pub space: Space, + /// Отслеживание всплывающих окон xdg-shell. + pub popups: PopupManager, + + /// Состояние протоколов Wayland. + pub compositor_state: CompositorState, + pub xdg_shell_state: XdgShellState, + pub shm_state: ShmState, + /// Хранится, чтобы глобал `wl_output`/`xdg-output` оставался зарегистрированным. + pub _output_manager_state: OutputManagerState, + pub data_device_state: DataDeviceState, + /// Состояние `wp_viewporter`. + pub viewporter_state: ViewporterState, + /// Состояние `wp_presentation`. + pub presentation_state: PresentationState, + /// Состояние `xdg-decoration`. + pub decoration_state: XdgDecorationState, + pub primary_selection_state: PrimarySelectionState, + + /// Сессия ввода. + pub seat_state: SeatState, + pub seat: Seat, + + /// Реестр окон верхнего уровня: корневая `wl_surface` → окно. + /// + /// Нужен потому, что окно временно отсутствует в `space` (скрыто + /// клиентом через attach пустого буфера), но его состояние и позиция + /// должны сохраняться. + pub toplevels: HashMap, + + /// Идентификаторы окон для панели задач: `wl_surface` → номер. + /// + /// Номера выдаются по порядку создания и не меняются, чтобы панель могла + /// ссылаться на окно между кадрами. + pub window_ids: HashMap, + + /// Состояние показа окон: свёрнуто, развёрнуто, геометрия для возврата. + pub window_states: HashMap, + + /// Отслеживание нажатых клавиш и горячих клавиш. + pub input: input::InputManager, + + /// Установлен ли обработчик `Redraw` от winit. + pub needs_redraw: bool, + + /// Номер, выдаваемый следующему окну. + pub next_window_id: u64, + + /// Окно панели задач под указателем, если он над панелью. + pub taskbar_target: Option, + + /// Рабочая область панели задач под указателем, если он над переключателем. + pub workspace_target: Option, + + /// Высота панели задач в логических пикселях. + pub panel_height: i32, + + /// Положение панели задач: `top` или `bottom`. + pub panel_position: String, + + /// Стиль декораций окон (заголовок и кнопки). + pub decoration_style: DecorationStyle, + + /// Рисуются ли декорации силами композитора. + pub server_side_decorations: bool, + + /// Текущее перетаскивание окна, если оно идёт. + pub drag: Option, + + /// Текущее изменение размера окна, если оно идёт. + pub resize: Option, + + /// Запрос перехода на рабочую область из горячей клавиши. + pub workspace_request: Arc, + + /// Запрос сдвига по рабочим областям: плюс или минус. + pub workspace_step: Arc, + + /// Запрос переноса активного окна на другую рабочую область. + pub window_move_request: Arc, + + /// Раскладка клавиатуры сессии: по ней символы читаются из кодов клавиш. + pub keyboard_layout: Option, + + /// Номер текущей рабочей области, с нуля. + pub current_workspace: usize, + + /// Сколько рабочих областей доступно. + pub workspace_count: usize, + + /// Нужно ли принудительно перерисовать весь выход. + /// + /// Ставится, когда кадр не был отправлен: back buffer остался старым, + /// но damage tracker об этом не знает. + pub force_full_redraw: bool, +} + +impl App { + /// Прямоугольник, который окно занимает на экране. + /// + /// Берётся геометрия, а не bounding box: bbox включает поля вокруг + /// поверхности, и заголовок по нему получился бы шире окна. + fn frame_rect(&self, window: &Window) -> Option> { + self.space + .element_geometry(window) + .or_else(|| self.space.element_bbox(window)) + } + + /// Формирует элементы декораций всех видимых окон. + /// + /// Декорации рисуются поверх окон и под панелью задач: заголовок должен + /// закрывать верхний край окна, а не наоборот. + fn render_decorations( + &self, + renderer: &mut GlesRenderer, + font: &mut FontRenderer, + ) -> Vec> { + if !self.server_side_decorations { + return Vec::new(); + } + + let focused = self.seat.get_keyboard().and_then(|k| k.current_focus()); + let mut elements = Vec::new(); + + // Список поверхностей собирается заранее: обход окна внутри обхода + // окна приводит к повторному захвату мьютекса состояния поверхности. + let windows: Vec<(Rectangle, bool, String)> = self + .space + .elements() + .filter_map(|window| { + let surface = window.wl_surface()?.into_owned(); + let frame = self.frame_rect(window)?; + let id = self.window_id(window).unwrap_or(0); + let title = window_title(&surface).unwrap_or_else(|| format!("Окно {id}")); + + Some((frame, focused.as_ref() == Some(&surface), title)) + }) + .collect(); + + // Подписи заголовков собираются отдельно и идут в начало списка: + // элементы сцены рисуются в обратном порядке, поэтому иначе текст + // оказался бы под заливкой заголовка. + for (frame, active, title) in &windows { + if title.is_empty() { + continue; + } + + let label = decoration::title_label(*frame, &self.decoration_style); + if label.max_width <= 0 { + continue; + } + + let size = self.titlebar_font_size(); + let raw = font.render_bold(title, size); + let text = raw.truncated(font, size, label.max_width as f32); + if text.is_empty() { + continue; + } + + let color = if *active { + TextColor::LIGHT + } else { + TextColor::MUTED + }; + + let Some(buffer) = TextElement { bitmap: text, color }.into_buffer() else { + continue; + }; + + // Подпись центрируется по вертикали заголовка. + let ascent_offset = font.ascent(self.titlebar_font_size()).round() as i32; + + match MemoryRenderBufferRenderElement::from_buffer( + renderer, + Point::::new( + label.x as f64, + (label.y + (self.titlebar_height() - ascent_offset) / 2) as f64, + ), + &buffer, + None, + None, + None, + Kind::Unspecified, + ) { + Ok(element) => elements.push(element), + Err(err) => tracing::warn!("не удалось создать подпись окна: {err}"), + } + } + + for (frame, active, _) in &windows { + for (rect, color) in decoration::paint_list(*frame, *active, &self.decoration_style) { + let buffer = match solid_color_buffer(rect.size.w, rect.size.h, color) { + Ok(buffer) => buffer, + Err(err) => { + tracing::warn!("не удалось построить буфер декорации: {err}"); + continue; + } + }; + + match MemoryRenderBufferRenderElement::from_buffer( + renderer, + Point::::new(rect.loc.x as f64, rect.loc.y as f64), + &buffer, + None, + None, + None, + Kind::Unspecified, + ) { + Ok(element) => elements.push(element), + Err(err) => tracing::warn!("не удалось создать элемент декорации: {err}"), + } + } + } + + elements + } + + /// Кегль подписи в заголовке окна. + fn titlebar_font_size(&self) -> f32 { + (self.decoration_style.titlebar_height as f32 * 0.45).clamp(9.0, 18.0) + } + + /// Начинает перетаскивание окна за заголовок. + /// + /// Запоминается точка, с которой начат жест, и позиция окна в этот + /// момент. Само перемещение происходит по движению указателя, поэтому + /// окно следует за курсором без накопления ошибки округления. + pub fn begin_drag(&mut self, id: u64, grab_point: Point) { + let Some(surface) = self.surface_by_id(id) else { + return; + }; + let Some(window) = self.toplevels.get(&surface).cloned() else { + return; + }; + + // Развёрнутое окно перед перетаскиванием возвращается к прежнему + // размеру: иначе его рамка останется на весь экран. + if self.window_state(&surface).maximized { + self.unmaximize_window(id); + } + + let Some(origin) = self.frame_rect(&window) else { + return; + }; + + self.drag = Some(DragState { + window: surface, + // Смещение внутри окна сохраняется, чтобы окно не прыгало под + // курсор при начале перетаскивания. + grab_offset: Point::new( + grab_point.x - origin.loc.x as f64, + grab_point.y - origin.loc.y as f64, + ), + start_point: grab_point, + start_origin: origin.loc, + moved: false, + }); + + self.focus_window(id); + } + + /// Продолжает перетаскивание окна за точку указателя. + pub fn update_drag(&mut self, location: Point) { + let Some(drag) = self.drag.clone() else { + return; + }; + + + // Движение считается настоящим только после заметного смещения: + // иначе случайный дрожащий клик считался бы перетаскиванием. + const DRAG_THRESHOLD: f64 = 3.0; + let delta = location - drag.start_point; + let travelled = (delta.x * delta.x + delta.y * delta.y).sqrt(); + if !drag.moved && travelled < DRAG_THRESHOLD { + return; + } + + let Some(window) = self.toplevels.get(&drag.window).cloned() else { + self.drag = None; + return; + }; + + let target = Point::new( + (location.x - drag.grab_offset.x) as i32, + (location.y - drag.grab_offset.y) as i32, + ); + + // Окно не выпускается за пределы рабочей области: иначе его можно + // было бы утащить за экран и потерять. + let target = self.clamp_to_work_area(target, window.bbox().size); + + self.space.relocate_element(&window, target); + self.clamp_drag_state(target); + + if let Some(drag) = self.drag.as_mut() { + drag.moved = true; + } + } + + /// Завершает перетаскивание. + pub fn end_drag(&mut self) { + self.drag = None; + } + + /// Возвращает `true`, если окно сейчас перетаскивается. + pub fn is_dragging(&self) -> bool { + self.drag.is_some() + } + + /// Ограничивает позицию окна рабочей областью. + fn clamp_to_work_area( + &self, + position: Point, + size: Size, + ) -> Point { + let area = self.work_area(); + + // Доступная часть окна вычисляется так, чтобы заголовок и кнопки + // оставались на экране. + let min_x = area.loc.x; + let max_x = area.loc.x + area.size.w - size.w; + let min_y = area.loc.y; + // Заголовок рисуется над окном, поэтому низ ограничивается целиком. + let max_y = area.loc.y + area.size.h - size.h; + + Point::new( + position.x.clamp(min_x.min(max_x), max_x.max(min_x)), + position.y.clamp(min_y.min(max_y), max_y.max(min_y)), + ) + } + + /// Обновляет сохранённое начало перетаскивания под новую позицию окна. + /// + /// Иначе ограничение края экрана накапливало бы ошибку: после каждого + /// шага смещение считалось бы от изначальной точки, а окно «отставало». + fn clamp_drag_state(&mut self, target: Point) { + if let Some(drag) = self.drag.as_mut() { + drag.start_point += Point::new( + (target.x - drag.start_origin.x) as f64, + (target.y - drag.start_origin.y) as f64, + ); + drag.start_origin = target; + } + } + + /// Обрабатывает нажатие на декорации окна. + fn begin_decoration_action( + &mut self, + id: u64, + action: DecorationAction, + grab_point: Point, + ) { + if action.is_resize() { + self.begin_resize(id, action, grab_point); + return; + } + + match action { + DecorationAction::Drag => {} + DecorationAction::Minimize => self.minimize_window(id), + DecorationAction::ToggleMaximize => self.toggle_maximize(id), + DecorationAction::Close => self.close_window(id), + _ => {} + } + } + + /// Начинает изменение размера окна за край рамки. + pub fn begin_resize( + &mut self, + id: u64, + action: DecorationAction, + grab_point: Point, + ) { + let Some(surface) = self.surface_by_id(id) else { + return; + }; + let Some(window) = self.toplevels.get(&surface).cloned() else { + return; + }; + let Some(rect) = self.frame_rect(&window) else { + return; + }; + + // Развёрнутое окно перед изменением размера возвращается к + // прежнему: растягивать окно во весь экран бессмысленно. + if self.window_state(&surface).maximized { + self.unmaximize_window(id); + let Some(rect) = self.frame_rect(&window) else { + return; + }; + self.resize = Some(ResizeState { + window: surface, + action, + start_point: grab_point, + start_rect: rect, + }); + self.focus_window(id); + return; + } + + self.resize = Some(ResizeState { + window: surface, + action, + start_point: grab_point, + start_rect: rect, + }); + self.focus_window(id); + } + + /// Продолжает изменение размера окна за точку указателя. + pub fn update_resize(&mut self, location: Point) { + let Some(resize) = self.resize.clone() else { + return; + }; + let Some(window) = self.toplevels.get(&resize.window).cloned() else { + self.resize = None; + return; + }; + + let delta = location - resize.start_point; + let area = self.work_area(); + + // Границы рамки двигаются независимо друг друга: левый и верхний + // края меняют положение окна, правый и нижний — размер. + let mut left = resize.start_rect.loc.x; + let mut top = resize.start_rect.loc.y; + let mut right = resize.start_rect.loc.x + resize.start_rect.size.w; + let mut bottom = resize.start_rect.loc.y + resize.start_rect.size.h; + + let west = matches!( + resize.action, + DecorationAction::ResizeWest | DecorationAction::ResizeNorthWest | DecorationAction::ResizeSouthWest + ); + let east = matches!( + resize.action, + DecorationAction::ResizeEast | DecorationAction::ResizeNorthEast | DecorationAction::ResizeSouthEast + ); + let north = matches!( + resize.action, + DecorationAction::ResizeNorth | DecorationAction::ResizeNorthWest | DecorationAction::ResizeNorthEast + ); + let south = matches!( + resize.action, + DecorationAction::ResizeSouth | DecorationAction::ResizeSouthWest | DecorationAction::ResizeSouthEast + ); + + if west { + left = (resize.start_rect.loc.x as f64 + delta.x).round() as i32; + } + if north { + top = (resize.start_rect.loc.y as f64 + delta.y).round() as i32; + } + if east { + right = (resize.start_rect.loc.x as f64 + resize.start_rect.size.w as f64 + delta.x) + .round() as i32; + } + if south { + bottom = (resize.start_rect.loc.y as f64 + resize.start_rect.size.h as f64 + delta.y) + .round() as i32; + } + + // Минимальный размер не даёт окну схлопнуться в ниточку. + if right - left < MIN_WINDOW_SIZE { + if west { + left = right - MIN_WINDOW_SIZE; + } else { + right = left + MIN_WINDOW_SIZE; + } + } + if bottom - top < MIN_WINDOW_SIZE { + if north { + top = bottom - MIN_WINDOW_SIZE; + } else { + bottom = top + MIN_WINDOW_SIZE; + } + } + + // Окно не выпускается за рабочую область: иначе его можно было бы + // растянуть так, что заголовок ушёл бы за экран. + if left < area.loc.x { + left = area.loc.x; + } + if top < area.loc.y { + top = area.loc.y; + } + if right > area.loc.x + area.size.w { + right = area.loc.x + area.size.w; + } + if bottom > area.loc.y + area.size.h { + bottom = area.loc.y + area.size.h; + } + + // Заголовок входит в рамку, но не входит в поверхность клиента. + let titlebar = self.titlebar_height(); + let client_origin = Point::new(left, top + titlebar); + let client_size: Size = Size::new( + (right - left).max(MIN_WINDOW_SIZE), + (bottom - top - titlebar).max(MIN_WINDOW_SIZE), + ); + + self.space.relocate_element(&window, client_origin); + self.request_resize(&resize.window, client_size.w, client_size.h); + } + + /// Завершает изменение размера окна. + pub fn end_resize(&mut self) { + self.resize = None; + } + + /// Возвращает `true`, если сейчас меняется размер окна. + pub fn is_resizing(&self) -> bool { + self.resize.is_some() + } + + /// Определяет декорацию под точкой и окно, которому она принадлежит. + /// + /// Окна проверяются сверху вниз, чтобы попадание досталось тому, что + /// нарисовано поверх остальных. + fn decoration_at(&self, point: Point) -> Option<(u64, DecorationAction)> { + if !self.server_side_decorations { + return None; + } + + for window in self.space.elements().rev() { + let id = self.window_id(window)?; + let frame = self.frame_rect(window)?; + + if let Some(action) = decoration::hit_test(frame, point, &self.decoration_style) { + return Some((id, action)); + } + } + + None + } + + /// Текущий размер выхода в физических пикселях. + /// + /// Используется, когда активного режима ещё нет: координаты ввода нужно + /// преобразовывать в любом случае. + fn output_size(&self) -> Size { + self.output + .current_mode() + .map(|mode| mode.size) + .unwrap_or_else(|| Size::new(1, 1)) + } + + /// Рассчитывает позицию окна по центру доступной области. + /// Логический размер рабочей области: весь выход минус панель задач. + /// + /// Окна размещаются только внутри неё, иначе панель перекрывала бы + /// содержимое окна, а наведение на панель отнимало бы фокус у окна, + /// расположенного под ней. + fn work_area(&self) -> Rectangle { + let output_size = self + .output + .current_mode() + .map(|mode| mode.size) + .unwrap_or(Size::new(DEFAULT_WINDOW_SIZE.0, DEFAULT_WINDOW_SIZE.1)); + let scale = self.output.current_scale().fractional_scale().round() as i32; + let logical = output_size.to_logical(scale); + + // Панель не должна занимать больше половины экрана: иначе для + // окон не останется места. + let panel_height = self.panel_height.clamp(0, logical.h / 2); + let (loc, height) = match self.panel_position.as_str() { + "top" => (Point::new(0, panel_height), logical.h - panel_height), + _ => (Point::new(0, 0), logical.h - panel_height), + }; + + Rectangle::new(loc, Size::new(logical.w, height)) + } + + /// Высота заголовка окна в логических пикселях. + /// + /// Равна нулю, если декорации рисует клиент: тогда заголовок не нужно + /// резервировать. + fn titlebar_height(&self) -> i32 { + if self.server_side_decorations { + self.decoration_style.titlebar_height.max(0) + } else { + 0 + } + } + + fn center_position(&self, size: (i32, i32)) -> Point { + let area = self.work_area(); + + // Окно выравнивается по верхнему краю рабочей области с отступом, + // а не по центру: иначе панель перекрывала бы нижнюю часть окна. + const MARGIN: i32 = 24; + let position = Point::new( + area.loc.x + (area.size.w - size.0) / 2, + // Заголовок рисуется над поверхностью, поэтому под него нужно + // оставить место, иначе он уехал бы за верхний край экрана. + area.loc.y + MARGIN + self.titlebar_height(), + ); + + // Каждое следующее окно сдвигается относительно уже размещённых: + // иначе все окна ложатся друг на друга и пользователь не может их + // различить. Сдвиг зацикливается, иначе окна уйдут за край экрана. + const CASCADE_STEP: i32 = 28; + const CASCADE_WRAP: i32 = 6; + let step = self.space.elements().count() as i32 % CASCADE_WRAP * CASCADE_STEP; + + position + Point::new(step, step) + } + + /// Отправляет кадровые колбэки всем поверхностям окон. + fn send_frames(&self, time_ms: u32) { + // Поверхности сначала собираются в список, и только потом + // обрабатываются: обход дерева внутри обхода дерева приводит к + // повторному захвату мьютекса состояния поверхности и зависанию. + let mut surfaces = Vec::new(); + for window in self.space.elements() { + if let Some(surface) = window.wl_surface() { + surfaces.push(surface.into_owned()); + } + } + + for surface in &surfaces { + send_frames_surface_tree(surface, time_ms); + } + } +} + +/// Согласование декораций с клиентами по `xdg-decoration`. +/// +/// Клиент сам сообщает, какие декорации он умеет рисовать. Композитор +/// отвечает, какие рисовать он, иначе окно оказалось бы в рамке, нарисованной +/// дважды: клиентом и композитором. +impl XdgDecorationHandler for App { + fn new_decoration(&mut self, toplevel: ToplevelSurface) { + self.apply_decoration_mode(&toplevel, DecorationMode::ServerSide); + } + + fn request_mode(&mut self, toplevel: ToplevelSurface, mode: DecorationMode) { + // Клиент без декораций вынужден принять серверные: иначе заголовок + // для перетаскивания просто нечего будет grab'ить. + let accepted = if self.server_side_decorations { + mode + } else { + DecorationMode::ClientSide + }; + + tracing::info!("клиент запросил декорации {mode:?}, принято {accepted:?}"); + self.apply_decoration_mode(&toplevel, accepted); + } + + fn unset_mode(&mut self, toplevel: ToplevelSurface) { + // Режим сброшен: остаётся тот, что выбрал композитор. + self.apply_decoration_mode(&toplevel, DecorationMode::ServerSide); + } +} + +impl XdgShellHandler for App { + fn xdg_shell_state(&mut self) -> &mut XdgShellState { + &mut self.xdg_shell_state + } + + fn new_toplevel(&mut self, surface: ToplevelSurface) { + tracing::info!("создано новое окно xdg-toplevel"); + + // Первое конфигурирование задаёт размер окна. Состояние Maximized + // здесь не выставляется: клиент не успевает применить конфигурацию + // до того, как композитор запросит размер, из-за чего окно + // разворачивается в нулевой размер. + surface.with_pending_state(|state| { + state.size = Some(DEFAULT_WINDOW_SIZE.into()); + }); + surface.send_configure(); + + let window = Window::new_wayland_window(surface); + let root = window.wl_surface().map(|s| s.into_owned()); + + // Окно попадёт в `space` при первом коммите с буфером, но реестр + // заполняется сразу, чтобы коммиты до этого не терялись. + if let Some(root) = root.clone() { + let id = self.next_window_id; + self.next_window_id += 1; + + self.window_ids.insert(root.clone(), id); + self.toplevels.insert(root.clone(), window.clone()); + + // Рабочая область записывается сразу: пока записи нет, номер + // области выводится по умолчанию, и после переключения окно + // ошибочно считалось бы открытым в текущей области. + self.window_states + .entry(root.clone()) + .or_default() + .workspace = self.current_workspace; + } + + // Клавиатурный фокус сразу на новом окне, иначе первые нажатия + // уйдут в никуда. + if let Some(root) = root { + if let Some(keyboard) = self.seat.get_keyboard() { + keyboard.set_focus(self, Some(root), SERIAL_COUNTER.next_serial()); + } + } + } + + fn new_popup(&mut self, surface: PopupSurface, _positioner: PositionerState) { + if let Err(err) = self.popups.track_popup(PopupKind::from(surface)) { + tracing::warn!("не удалось отследить всплывающее окно: {err}"); + } + } + + fn grab(&mut self, _surface: PopupSurface, _seat: wl_seat::WlSeat, _serial: Serial) {} + + fn reposition_request(&mut self, _surface: PopupSurface, _positioner: PositionerState, _token: u32) {} + + /// Клиент подтвердил конфигурацию окна. + /// + /// Позицию окна здесь менять нельзя: она уже задана композитором при + /// разворачивании или перетаскивании, и повторное центрирование по + /// `configure` возвращало бы окно на место. + fn ack_configure(&mut self, _surface: WlSurface, _configure: Configure) {} + + /// Окно верхнего уровня уничтожено клиентом. + /// + /// Без этого обработчика окно навсегда остаётся в реестре `toplevels` и + /// в `space`: реестр растёт вместе с числом открытых окон, а `space` + /// продолжает считать уничтоженное окно видимым. + fn toplevel_destroyed(&mut self, surface: ToplevelSurface) { + tracing::info!("окно xdg-toplevel уничтожено клиентом"); + self.forget_window(surface.wl_surface()); + } + + /// Клиент завершился: все его окна нужно убрать разом. + fn client_destroyed(&mut self, _client: ShellClient) { + tracing::info!("shell-клиент отключился, очищаем его окна"); + self.cleanup_dead_windows(); + } + +} + +impl App { + /// Убирает из реестра и сцены все окна, чьи поверхности уже не живы. + pub fn cleanup_dead_windows(&mut self) { + let dead: Vec = self + .toplevels + .keys() + .filter(|surface| !surface.alive()) + .cloned() + .collect(); + + for surface in dead { + self.forget_window(&surface); + } + } + + /// Полностью удаляет окно из реестра и сцены. + fn forget_window(&mut self, surface: &WlSurface) { + self.window_ids.remove(surface); + + let Some(window) = self.toplevels.remove(surface) else { + return; + }; + + self.space.unmap_elem(&window); + self.release_pointer_focus(&window); + self.release_keyboard_focus(&window); + } + + /// Снимает клавиатурный фокус, если он был на указанном окне. + fn release_keyboard_focus(&mut self, window: &Window) { + let target = window.wl_surface().map(|s| s.into_owned()); + let focused = self + .seat + .get_keyboard() + .and_then(|keyboard| keyboard.current_focus()); + + if focused != target { + return; + } + + // Фокус переносится на следующее видимое окно, иначе после закрытия + // приложения клавиатура окажется в никуда. + let next = self + .space + .elements() + .next_back() + .and_then(|w| w.wl_surface().map(|s| s.into_owned())); + + if let Some(keyboard) = self.seat.get_keyboard() { + keyboard.set_focus(self, next, SERIAL_COUNTER.next_serial()); + } + } + + /// Идентификатор окна по его корневой поверхности. + fn window_id(&self, window: &Window) -> Option { + let surface = window.wl_surface()?; + self.window_ids.get(surface.as_ref()).copied() + } + + /// Формирует список окон для панели задач, в порядке z-порядка. + /// + /// Список строится по реестру, а не по `space`: свёрнутые окна убраны из + /// сцены, но должны оставаться на панели, иначе их нельзя будет вернуть. + pub fn taskbar_entries(&self) -> Vec { + let focused = self.seat.get_keyboard().and_then(|k| k.current_focus()); + + // Порядок берётся из сцены: снизу вверх, чтобы верхнее окно было + // последним, как в Windows. + let mut order: Vec = Vec::new(); + for window in self.space.elements() { + if let Some(surface) = window.wl_surface() { + order.push(surface.into_owned()); + } + } + + // Свёрнутые окна дописываются в конце: они не на экране, но доступны + // для восстановления. + for surface in self.toplevels.keys() { + if !order.contains(surface) { + order.push(surface.clone()); + } + } + + order + .into_iter() + // Окна других рабочих областей на панели не показываются: иначе + // она перестала бы показывать, что открыто в текущей области. + .filter(|surface| self.window_workspace(surface) == self.current_workspace) + .filter_map(|surface| { + let window = self.toplevels.get(&surface)?; + let id = self.window_id(window)?; + let state = self.window_state(&surface); + + Some(TaskbarEntry { + id, + title: window_title(&surface).unwrap_or_else(|| format!("Окно {id}")), + active: focused.as_ref() == Some(&surface), + minimized: state.minimized, + }) + }) + .collect() + } + + /// Применяет сочетание клавиш к активному окну. + fn apply_window_shortcut(&mut self, action: input::WindowShortcut) { + let Some(id) = self.focused_window_id() else { + return; + }; + + match action { + input::WindowShortcut::Minimize => { + tracing::info!("окно {id} свёрнуто сочетанием клавиш"); + self.minimize_window(id); + } + input::WindowShortcut::ToggleMaximize => { + self.toggle_maximize(id); + } + input::WindowShortcut::Close => { + tracing::info!("окно {id} закрыто сочетанием клавиш"); + self.close_window(id); + } + } + } + + /// Идентификатор окна, в которое направлен клавиатурный ввод. + fn focused_window_id(&self) -> Option { + let surface = self + .seat + .get_keyboard() + .and_then(|keyboard| keyboard.current_focus())?; + + self.window_ids.get(&surface).copied() + } + + /// Просит клиента закрыть окно. + /// + /// Окно убирается со сцены сразу: клиент может не обработать `close` + /// вовсе, и оставлять его на экране было бы неверно. + pub fn close_window(&mut self, id: u64) { + let Some(surface) = self.surface_by_id(id) else { + return; + }; + + if let Some(window) = self.toplevels.get(&surface).cloned() { + self.space.unmap_elem(&window); + self.release_pointer_focus(&window); + self.release_keyboard_focus(&window); + } + + if let Some(toplevel) = self.toplevels.get(&surface).and_then(window_toplevel) { + toplevel.send_close(); + } + } + + /// Обрабатывает активацию окна кнопкой панели задач. + /// + /// Повторный клик по активному окну сворачивает его — как в Windows 11. + /// Клик по свёрнутому разворачивает обратно. + pub fn activate_from_taskbar(&mut self, id: u64) { + let Some(surface) = self.surface_by_id(id) else { + return; + }; + let Some(window) = self.toplevels.get(&surface).cloned() else { + return; + }; + + let state = self.window_state(&surface); + let focused = self + .seat + .get_keyboard() + .and_then(|keyboard| keyboard.current_focus()); + let is_active = focused.as_ref() == Some(&surface); + + match (state.minimized, is_active) { + // Свёрнутое окно разворачивается и получает фокус. + (true, _) => { + tracing::info!("окно {id} развёрнуто с панели задач"); + self.focus_window(id); + } + // Повторный клик по активному окну сворачивает его. + (false, true) => { + tracing::info!("окно {id} свёрнуто с панели задач"); + self.minimize_window(id); + } + // Обычный клик по неактивному окну просто даёт ему фокус. + (false, false) => self.focus_window(id), + } + + let _ = window; + } + + /// Переводит фокус на окно, выбранное на панели задач. + pub fn focus_window(&mut self, id: u64) { + let Some(surface) = self + .window_ids + .iter() + .find(|(_, window_id)| **window_id == id) + .map(|(surface, _)| surface.clone()) + else { + return; + }; + + let Some(window) = self.toplevels.get(&surface).cloned() else { + return; + }; + + // Свёрнутое окно сначала возвращается на экран, иначе фокус был бы + // у окна, которого пользователь не видит. Место при этом выбирает + // `restore_window`: у него сохранена прежняя геометрия. + if self.window_state(&surface).minimized { + self.restore_window(&surface); + } + + // Окно только поднимается, но не перемещается: смена фокуса не + // должна двигать окно по экрану. Пересчёт позиции здесь означал бы, + // что клик по кнопке панели задач или `Alt+Tab` переставляют окно. + self.space.raise_element(&window, true); + + if let Some(keyboard) = self.seat.get_keyboard() { + keyboard.set_focus(self, Some(surface), SERIAL_COUNTER.next_serial()); + } + } + + /// Сворачивает окно: убирает его со сцены и сообщает клиенту состояние. + pub fn minimize_window(&mut self, id: u64) { + let Some(surface) = self.surface_by_id(id) else { + return; + }; + let Some(window) = self.toplevels.get(&surface).cloned() else { + return; + }; + + self.space.unmap_elem(&window); + self.release_pointer_focus(&window); + self.release_keyboard_focus(&window); + + let state = self.window_states.entry(surface.clone()).or_default(); + state.minimized = true; + + // В xdg-shell нет состояния «свёрнуто»: композитор просто перестаёт + // показывать окно. Клиент продолжает рисовать в свой буфер, поэтому + // состояние `Activated` нужно снять — иначе приложение продолжит + // считать себя активным. + self.set_toplevel_state(&surface, |state| { + state.states.unset(xdg_toplevel::State::Activated); + }); + } + + /// Разворачивает окно на всю рабочую область. + pub fn maximize_window(&mut self, id: u64) { + let Some(surface) = self.surface_by_id(id) else { + return; + }; + let Some(window) = self.toplevels.get(&surface).cloned() else { + return; + }; + + let area = self.work_area(); + let titlebar = self.titlebar_height(); + let already = self + .window_states + .get(&surface) + .is_some_and(|state| state.maximized); + + if !already { + // Геометрия сохраняется до разворачивания: без неё кнопка + // «восстановить» не знает, куда вернуть окно. + let current = window.bbox(); + self.window_states + .entry(surface.clone()) + .or_default() + .restore_rect = Some(current); + } + + // Развёрнутое окно занимает всю рабочую область вместе с заголовком, + // поэтому клиенту достаётся область за вычетом заголовка. + let client_size: Size = Size::new(area.size.w, area.size.h - titlebar); + let frame_origin = Point::new(area.loc.x, area.loc.y + titlebar); + + self.space.map_element(window, frame_origin, true); + self.request_resize(&surface, client_size.w, client_size.h); + + self.window_states.entry(surface.clone()).or_default().maximized = true; + + self.set_toplevel_state(&surface, |state| { + state.states.set(xdg_toplevel::State::Maximized); + }); + } + + /// Возвращает окно из свёрнутого состояния на экран. + fn restore_window(&mut self, surface: &WlSurface) { + let Some(window) = self.toplevels.get(surface).cloned() else { + return; + }; + + let rect = self + .window_states + .get(surface) + .and_then(|state| state.restore_rect); + + let location = match rect { + Some(rect) => rect.loc, + None => self.center_position((window.bbox().size.w, window.bbox().size.h)), + }; + + self.space.map_element(window, location, true); + + let state = self.window_states.entry(surface.clone()).or_default(); + state.minimized = false; + + // Возвращаемое окно снова становится активным. + self.set_toplevel_state(surface, |state| { + state.states.set(xdg_toplevel::State::Activated); + }); + } + + /// Возвращает окно из развёрнутого состояния к прежнему размеру. + pub fn unmaximize_window(&mut self, id: u64) { + let Some(surface) = self.surface_by_id(id) else { + return; + }; + let Some(window) = self.toplevels.get(&surface).cloned() else { + return; + }; + + let rect = self + .window_states + .get(&surface) + .and_then(|state| state.restore_rect) + .unwrap_or_else(|| { + Rectangle::new( + self.center_position((window.bbox().size.w, window.bbox().size.h)), + Size::new(DEFAULT_WINDOW_SIZE.0, DEFAULT_WINDOW_SIZE.1), + ) + }); + + self.space.map_element(window, rect.loc, true); + self.request_resize(&surface, rect.size.w, rect.size.h); + + self.window_states.entry(surface.clone()).or_default().maximized = false; + + self.set_toplevel_state(&surface, |state| { + state.states.unset(xdg_toplevel::State::Maximized); + }); + } + + /// Переключает развёрнутое состояние окна. + pub fn toggle_maximize(&mut self, id: u64) { + let Some(surface) = self.surface_by_id(id) else { + return; + }; + + let maximized = self + .window_states + .get(&surface) + .is_some_and(|state| state.maximized); + + if maximized { + self.unmaximize_window(id); + } else { + self.maximize_window(id); + } + } + + /// Возвращает поверхность окна по его идентификатору. + fn surface_by_id(&self, id: u64) -> Option { + self.window_ids + .iter() + .find(|(_, window_id)| **window_id == id) + .map(|(surface, _)| surface.clone()) + } + + /// Просит клиента изменить размер окна. + /// + /// `configure` отправляется только для уже показанного окна: у скрытого + /// клиент всё равно не рисует, а конфигурация пришла бы в пустоту. + fn request_resize(&mut self, surface: &WlSurface, width: i32, height: i32) { + let Some(window) = self.toplevels.get(surface).cloned() else { + return; + }; + + if !self.space.elements().any(|w| w == &window) { + return; + } + + let Some(toplevel) = window_toplevel(&window) else { + return; + }; + + toplevel.with_pending_state(|state| { + state.size = Some(Size::new(width, height)); + }); + toplevel.send_configure(); + } + + /// Изменяет набор состояний окна и отправляет `configure`. + fn set_toplevel_state(&self, surface: &WlSurface, change: F) + where + F: FnOnce(&mut ToplevelState), + { + let Some(window) = self.toplevels.get(surface) else { + return; + }; + let Some(toplevel) = window_toplevel(window) else { + return; + }; + + toplevel.with_pending_state(change); + toplevel.send_configure(); + } + + /// Возвращает состояние показа окна. + pub fn window_state(&self, surface: &WlSurface) -> WindowState { + self.window_states.get(surface).cloned().unwrap_or_default() + } + + /// Список окон для переключателя, начиная с верхнего. + /// + /// Порядок обратен сцене: текущее окно должно оказаться первым, а + /// следующее за ним — тем, на которое переключится `Alt+Tab`. + pub fn switcher_entries(&self) -> Vec { + self.taskbar_entries() + .into_iter() + .rev() + .map(|entry| switcher::SwitcherEntry { + id: entry.id, + title: entry.title, + }) + .collect() + } + + /// Выполняет накопленные запросы переключения рабочих областей. + /// + /// Горячие клавиши работают через общие счётчики, потому что их + /// замыкания не получают `&mut App`. Счётчики сбрасываются сразу: иначе + /// один запрос переключил бы область несколько раз за кадр. + pub fn apply_workspace_requests(&mut self) { + let step = self.workspace_step.swap(0, Ordering::SeqCst); + if step > 0 { + self.next_workspace(); + } else if step < 0 { + self.previous_workspace(); + } + + let target = self.workspace_request.swap(WORKSPACE_NONE, Ordering::SeqCst); + if target != WORKSPACE_NONE { + self.switch_workspace(target); + } + + // Перенос окна выполняется после переключения: иначе окно оказалось + // бы в только что покинутой области и тут же вернулось на экран. + let target = self.window_move_request.swap(WORKSPACE_NONE, Ordering::SeqCst); + if target == WORKSPACE_NONE { + return; + } + + if let Some(id) = self.focused_window_id() { + tracing::info!("окно {id} перенесено на область {}", target + 1); + self.move_window_to_workspace(id, target); + } + } + + /// Сообщает окну выбранный режим декораций. + /// + /// Отправляется только после первой конфигурации: до неё состояние + /// ещё не применено, и повторная отправка ничего не меняет. + fn apply_decoration_mode(&mut self, toplevel: &ToplevelSurface, mode: DecorationMode) { + toplevel.with_pending_state(|state| state.decoration_mode = Some(mode)); + + if toplevel.is_initial_configure_sent() { + toplevel.send_pending_configure(); + } + } + + /// Рабочая область, в которой открыто окно. + /// + /// Окно без записи считается открытым в первой области. + /// + /// Подставлять текущую область нельзя: после переключения окно без + /// записи считалось бы открытым в новой области и осталось бы на панели. + pub fn window_workspace(&self, surface: &WlSurface) -> usize { + self.window_states + .get(surface) + .map(|state| state.workspace) + .unwrap_or(0) + } + + /// `true`, если окно должно быть видно на экране. + /// + /// Окно скрыто, если оно свёрнуто или открыто на другой рабочей области. + pub fn is_window_visible(&self, surface: &WlSurface) -> bool { + let state = self.window_state(surface); + !state.minimized && state.workspace == self.current_workspace + } + + /// Сколько окон открыто в рабочей области. + pub fn workspace_window_count(&self, index: usize) -> usize { + self.toplevels + .keys() + .filter(|surface| self.window_workspace(surface) == index) + .count() + } + + /// Переключает рабочую область. + /// + /// Окна прежней области убираются со сцены, окна новой возвращаются на + /// место. Фокус снимается со скрытого окна: клиент, потерявший окно, + /// не должен продолжать считаться активным. + pub fn switch_workspace(&mut self, index: usize) { + if index >= self.workspace_count || index == self.current_workspace { + return; + } + + // Фокус снимается до переключения: иначе он остался бы на окне, + // которого больше нет на экране, и клиент продолжил бы считать + // себя активным. Окна копируются заранее: обращение к фокусу + // занимает состояние поверхности, и обход сцены внутри него + // приводит к повторному захвату мьютекса. + let focused: Vec = self.space.elements().cloned().collect(); + for window in &focused { + self.release_pointer_focus(window); + self.release_keyboard_focus(window); + } + + self.current_workspace = index; + + let targets: Vec<(WlSurface, bool)> = self + .toplevels + .values() + .filter_map(|window| { + let surface = window.wl_surface()?.into_owned(); + let visible = self.is_window_visible(&surface); + Some((surface, visible)) + }) + .collect(); + + for (surface, visible) in targets { + let Some(window) = self.toplevels.get(&surface).cloned() else { + continue; + }; + + if !visible { + self.space.unmap_elem(&window); + self.release_pointer_focus(&window); + self.release_keyboard_focus(&window); + continue; + } + + // Окно возвращается на место, только если у клиента уже есть + // содержимое: иначе появилась бы рамка без содержимого. + if window.bbox().size.w == 0 || window.bbox().size.h == 0 { + continue; + } + + let size = (window.bbox().size.w, window.bbox().size.h); + let location = self.center_position(size); + self.space.map_element(window, location, true); + } + } + + /// Переключает на следующую рабочую область по кругу. + pub fn next_workspace(&mut self) { + let count = self.workspace_count.max(1); + self.switch_workspace((self.current_workspace + 1) % count); + } + + /// Переключает на предыдущую рабочую область по кругу. + pub fn previous_workspace(&mut self) { + let count = self.workspace_count.max(1); + self.switch_workspace((self.current_workspace + count - 1) % count); + } + + /// Перемещает окно на другую рабочую область. + pub fn move_window_to_workspace(&mut self, id: u64, index: usize) { + if index >= self.workspace_count { + return; + } + + let Some(surface) = self.surface_by_id(id) else { + return; + }; + let Some(window) = self.toplevels.get(&surface).cloned() else { + return; + }; + + self.window_states + .entry(surface) + .or_default() + .workspace = index; + + // Окно уходит со сцены, если новая область не текущая. + if index != self.current_workspace { + self.space.unmap_elem(&window); + self.release_pointer_focus(&window); + self.release_keyboard_focus(&window); + } + } +} + +/// Возвращает поверхность верхнего уровня, лежащую под окном. +/// +/// `Window` умеет держать и X11-поверхности, поэтому вариант X11 отсекается. +fn window_toplevel(window: &Window) -> Option { + match window.underlying_surface() { + smithay::desktop::WindowSurface::Wayland(toplevel) => Some(toplevel.clone()), + // Окна X11 в этой сборке не поддерживаются, но вариант есть в smithay, + // поэтому ветка обязательна. + _ => None, + } +} + +/// Возвращает заголовок окна, заданный клиентом через `xdg_toplevel.set_title`. +fn window_title(surface: &WlSurface) -> Option { + use smithay::wayland::compositor::with_states; + use smithay::wayland::shell::xdg::XdgToplevelSurfaceData; + + with_states(surface, |states| { + states + .data_map + .get::() + .and_then(|data| data.lock().ok()) + .and_then(|data| data.title.clone()) + }) +} + +impl App { + /// Обновляет состояние окна по коммиту его корневой поверхности. + fn commit_toplevel(&mut self, surface: &WlSurface) { + let Some(window) = self.toplevels.get(surface).cloned() else { + return; + }; + + // Bounding box считается по дереву поверхностей и их буферам. + window.on_commit(); + + let mapped = self.space.elements().any(|w| w == &window); + // `bbox` отражает реальное содержимое поверхности: у скрытого окна + // он пустой. Поле `SurfaceAttributes::buffer` для проверки не годится + // — оно агрегирует назначения между коммитами. + let visible = window.bbox().size.w > 0 && window.bbox().size.h > 0; + + // Свёрнутое окно не должно возвращаться на экран: приложение + // продолжает коммитить кадры в фоне, и без этой проверки окно + // тут же снова оказывалось бы в сцене. То же верно для окон на + // других рабочих областях. + if !self.is_window_visible(surface) { + if mapped { + self.space.unmap_elem(&window); + self.release_pointer_focus(&window); + self.release_keyboard_focus(&window); + } + return; + } + + match (mapped, visible) { + // Окно ещё не показывалось: первый коммит приходит без буфера. + (false, false) => {} + + // Появился первый буфер — размещаем окно на экране. + (false, true) => { + let size = (window.bbox().size.w, window.bbox().size.h); + let location = self.center_position(size); + self.space.map_element(window, location, true); + } + + // Клиент приложил пустой буфер (attach `None`) — окно скрыто, но + // остаётся в реестре, чтобы его можно было показать снова. + (true, false) => { + self.space.unmap_elem(&window); + self.release_pointer_focus(&window); + } + + // Обычный коммит с буфером. Позицию окна не трогаем: ею + // управляет композитор (перетаскивание, разворачивание), а + // пересчитывание позиции на каждом кадре клиента возвращало + // бы окно в центр и отменяло перетаскивание. + (true, true) => {} + } + } + + /// Снимает фокус указателя, если он был на указанном окне. + fn release_pointer_focus(&mut self, window: &Window) { + let Some(pointer) = self.pointer_handle() else { + return; + }; + + let target = window.wl_surface().map(|s| s.into_owned()); + let focused = pointer + .current_focus() + .and_then(|focus| focus.wl_surface().map(|s| s.into_owned())); + + if focused != target { + return; + } + + // Отдельного `set_focus` у указателя нет: фокус снимается движением + // с пустой целью. + pointer.motion( + self, + None, + &MotionEvent { + location: pointer.current_location(), + serial: SERIAL_COUNTER.next_serial(), + time: InputTime::now(), + }, + ); + } + + /// Возвращает хендл указателя, если он создан. + fn pointer_handle(&self) -> Option> { + self.seat.get_pointer() + } +} + + + +impl CompositorHandler for App { + fn compositor_state(&mut self) -> &mut CompositorState { + &mut self.compositor_state + } + + fn client_compositor_state<'a>(&self, client: &'a Client) -> &'a CompositorClientState { + &client + .get_data::() + .expect("клиент без ClientState") + .compositor_state + } + + fn commit(&mut self, surface: &WlSurface) { + // Синхронизируем кэш буферов в дереве поверхностей. + on_commit_buffer_handler::(surface); + + if self.is_toplevel_root(surface) { + self.commit_toplevel(surface); + } + } +} + +impl App { + /// Возвращает `true`, если поверхность — корень xdg_toplevel. + fn is_toplevel_root(&self, surface: &WlSurface) -> bool { + self.toplevels.contains_key(surface) + } +} + +impl BufferHandler for App { + fn buffer_destroyed(&mut self, _buffer: &wl_buffer::WlBuffer) {} +} + +impl ShmHandler for App { + fn shm_state(&self) -> &ShmState { + &self.shm_state + } +} + +impl PointerConstraintsHandler for App {} + +impl OutputHandler for App {} + +impl SelectionHandler for App { + type SelectionUserData = (); +} + +impl PrimarySelectionHandler for App { + fn primary_selection_state(&mut self) -> &mut PrimarySelectionState { + &mut self.primary_selection_state + } +} + +impl DataDeviceHandler for App { + fn data_device_state(&mut self) -> &mut DataDeviceState { + &mut self.data_device_state + } +} + +impl WaylandDndGrabHandler for App {} + +impl SeatHandler for App { + type KeyboardFocus = WlSurface; + type PointerFocus = WlSurface; + type TouchFocus = WlSurface; + + fn seat_state(&mut self) -> &mut SeatState { + &mut self.seat_state + } + + fn focus_changed(&mut self, seat: &Seat, focused: Option<&WlSurface>) { + // Буфер обмена и primary selection привязаны к клиенту, чьё окно + // получило клавиатурный фокус. + let client = focused.and_then(|surface| self.display_handle.get_client(surface.id()).ok()); + set_data_device_focus(&self.display_handle, seat, client.clone()); + set_primary_focus(&self.display_handle, seat, client); + } + + fn cursor_image(&mut self, _seat: &Seat, _image: smithay::input::pointer::CursorImageStatus) {} + + fn led_state_changed(&mut self, _seat: &Seat, led_state: LedState) { + tracing::debug!("состояние индикации клавиатуры: {led_state:?}"); + } +} + +/// Состояние клиента Wayland. +#[derive(Default)] +struct ClientState { + compositor_state: CompositorClientState, +} + +impl ClientData for ClientState { + fn initialized(&self, _client_id: ClientId) {} + fn disconnected(&self, _client_id: ClientId, _reason: DisconnectReason) {} +} + +smithay::delegate_dispatch2!(App); + +/// Отправляет кадровые колбэки в дереве поверхностей. +fn send_frames_surface_tree(surface: &WlSurface, time: u32) { + with_surface_tree_downward( + surface, + (), + |_, _, &()| TraversalAction::DoChildren(()), + |_surf, states, &()| { + // Колбэки нужно извлечь до вызова `done`, иначе возможна + // реентерабельность и паника «already borrowed». + let callbacks = states + .cached_state + .get::() + .current() + .frame_callbacks + .drain(..) + .collect::>(); + + for callback in callbacks { + callback.done(time); + } + }, + |_, _, &()| true, + ); +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..75a5dd8 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,369 @@ +//! Конфигурация Lark DE. + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Главная конфигурация Lark DE. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Config { + /// Настройки композитора. + #[serde(default)] + pub compositor: CompositorConfig, + /// Настройки панели. + #[serde(default)] + pub panel: PanelConfig, + /// Настройки темы. + #[serde(default)] + pub theme: ThemeConfig, + /// Настройки языка. + #[serde(default)] + pub language: LanguageConfig, + /// Настройки клавиатуры. + #[serde(default)] + pub keyboard: KeyboardConfig, + /// Настройки декораций окон. + #[serde(default)] + pub decoration: DecorationConfig, + /// Настройки рабочего стола. + #[serde(default)] + pub desktop: DesktopConfig, + /// Настройки ввода. + #[serde(default)] + pub input: InputConfig, + /// Настройки сессии ввода (имя wl_seat). + #[serde(default)] + pub seat: SeatConfig, +} + +/// Настройки композитора. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompositorConfig { + /// Бэкенд рендеринга: auto, vulkan, gl, cpu. + #[serde(default = "default_backend")] + pub backend: String, + /// Имя Wayland-сокета, который занимает композитор. + #[serde(default = "default_socket")] + pub socket: String, + /// Включить анимации. + #[serde(default = "default_true")] + pub animations: bool, + /// Включить тени окон. + #[serde(default = "default_true")] + pub shadows: bool, + /// Включить прозрачность. + #[serde(default = "default_true")] + pub transparency: bool, +} + +/// Настройки панели задач. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PanelConfig { + /// Позиция панели: bottom, top. + #[serde(default = "default_panel_position")] + pub position: String, + /// Центрировать иконки (как в Windows 11). + #[serde(default = "default_true")] + pub centered: bool, + /// Высота панели в пикселях. + #[serde(default = "default_panel_height")] + pub height: u32, + /// Прозрачность панели. + #[serde(default = "default_true")] + pub transparent: bool, +} + +/// Настройки клавиатуры и раскладки. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KeyboardConfig { + /// Файл правил xkbcommon (`evdev`, `base`). + #[serde(default)] + pub xkb_rules: Option, + /// Модель клавиатуры (`pc105`, `pc104`). + #[serde(default)] + pub xkb_model: Option, + /// Раскладка (`us`, `ru`, `de`, ...). + #[serde(default)] + pub xkb_layout: Option, + /// Вариант раскладки. + #[serde(default)] + pub xkb_variant: Option, + /// Дополнительные опции xkb (grp:alt_shift_toggle и т. п.). + #[serde(default)] + pub xkb_options: Option, + /// Задержка автоповтора, мс. + #[serde(default = "default_repeat_delay")] + pub repeat_delay: i32, + /// Частота автоповтора, символов/с. + #[serde(default = "default_repeat_rate")] + pub repeat_rate: i32, +} + +/// Настройки рабочего стола. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DesktopConfig { + /// Сколько рабочих областей доступно. + #[serde(default = "default_workspaces")] + pub workspaces: u32, + /// Цвет фона рабочего стола (hex). + #[serde(default = "default_desktop_color")] + pub background: String, +} + +impl Default for DesktopConfig { + fn default() -> Self { + Self { + workspaces: default_workspaces(), + background: default_desktop_color(), + } + } +} + +/// Настройки декораций окон в стиле Windows 11. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DecorationConfig { + /// Рисовать ли заголовок окна силами композитора. + /// + /// При `false` окна остаются без декораций: заголовок рисует сам клиент. + #[serde(default = "default_true")] + pub server_side: bool, + /// Высота заголовка в логических пикселях. + #[serde(default = "default_titlebar_height")] + pub titlebar_height: u32, + /// Ширина кнопки заголовка в логических пикселях. + #[serde(default = "default_titlebar_button_width")] + pub button_width: u32, +} + +impl Default for DecorationConfig { + fn default() -> Self { + Self { + server_side: true, + titlebar_height: default_titlebar_height(), + button_width: default_titlebar_button_width(), + } + } +} + +/// Настройки ввода. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InputConfig { + /// Включён ли тачпад. + #[serde(default = "default_true")] + pub touchpad: bool, + /// Скорость прокрутки. + #[serde(default = "default_scroll_speed")] + pub scroll_speed: f64, +} + +/// Настройки сессии ввода. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SeatConfig { + /// Имя устройства-седа (`wl_seat.name`). + #[serde(default = "default_seat_name")] + pub name: String, +} + +/// Настройки темы. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ThemeConfig { + /// Название темы: light, dark. + #[serde(default = "default_theme")] + pub name: String, + /// Акцентный цвет (hex). + #[serde(default = "default_accent")] + pub accent: String, +} + +/// Настройки языка. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LanguageConfig { + /// Код языка: ru-RU, en-US. + #[serde(default = "default_language")] + pub code: String, +} + +impl Default for CompositorConfig { + fn default() -> Self { + Self { + backend: default_backend(), + socket: default_socket(), + animations: true, + shadows: true, + transparency: true, + } + } +} + +impl Default for PanelConfig { + fn default() -> Self { + Self { + position: default_panel_position(), + centered: true, + height: default_panel_height(), + transparent: true, + } + } +} + +impl Default for ThemeConfig { + fn default() -> Self { + Self { + name: default_theme(), + accent: default_accent(), + } + } +} + +impl Default for LanguageConfig { + fn default() -> Self { + Self { + code: default_language(), + } + } +} + +impl Default for KeyboardConfig { + fn default() -> Self { + Self { + xkb_rules: Some(default_xkb_rules()), + xkb_model: Some(default_xkb_model()), + xkb_layout: Some(default_xkb_layout()), + xkb_variant: None, + xkb_options: None, + repeat_delay: default_repeat_delay(), + repeat_rate: default_repeat_rate(), + } + } +} + +impl Default for InputConfig { + fn default() -> Self { + Self { + touchpad: true, + scroll_speed: default_scroll_speed(), + } + } +} + +impl Default for SeatConfig { + fn default() -> Self { + Self { + name: default_seat_name(), + } + } +} + +impl Config { + /// Загружает конфигурацию из файла или создаёт дефолтную. + pub fn load() -> Result { + let path = Self::config_path(); + + if path.exists() { + let content = std::fs::read_to_string(&path)?; + let config: Config = toml::from_str(&content)?; + Ok(config) + } else { + let config = Config::default(); + config.save()?; + Ok(config) + } + } + + /// Сохраняет конфигурацию в файл. + pub fn save(&self) -> Result<()> { + let path = Self::config_path(); + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let content = toml::to_string_pretty(self)?; + std::fs::write(&path, content)?; + Ok(()) + } + + /// Возвращает путь к файлу конфигурации. + fn config_path() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("lark") + .join("config.toml") + } +} + +fn default_backend() -> String { + "auto".to_string() +} + +fn default_socket() -> String { + "wayland-1".to_string() +} + +fn default_panel_position() -> String { + "bottom".to_string() +} + +fn default_panel_height() -> u32 { + 48 +} + +fn default_theme() -> String { + "dark".to_string() +} + +fn default_accent() -> String { + "#0078D4".to_string() +} + +fn default_language() -> String { + "ru-RU".to_string() +} + +fn default_true() -> bool { + true +} + +fn default_workspaces() -> u32 { + 4 +} + +fn default_desktop_color() -> String { + "#0F0F17".to_string() +} + +fn default_titlebar_height() -> u32 { + 32 +} + +fn default_titlebar_button_width() -> u32 { + 46 +} + +fn default_xkb_rules() -> String { + "evdev".to_string() +} + +fn default_xkb_model() -> String { + "pc105".to_string() +} + +fn default_xkb_layout() -> String { + "us".to_string() +} + +fn default_repeat_delay() -> i32 { + 25 +} + +fn default_repeat_rate() -> i32 { + 600 +} + +fn default_scroll_speed() -> f64 { + 1.0 +} + +fn default_seat_name() -> String { + "seat0".to_string() +} diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..33c6b4a --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,98 @@ +//! Мультиязычная поддержка через Fluent. + +use anyhow::Result; +use fluent::{FluentBundle, FluentResource}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use unic_langid::LanguageIdentifier; + +/// Обёртка для FluentBundle, чтобы реализовать Sync. +struct SyncBundle(FluentBundle); + +// SAFETY: FluentBundle не Sync/Send из-за внутреннего TypeMap, но мы гарантируем, +// что доступ к бандлам происходит только через Mutex. +unsafe impl Sync for SyncBundle {} +unsafe impl Send for SyncBundle {} + +/// Глобальный хранилище бандлов локалей. +static BUNDLES: Mutex>>> = Mutex::new(None); + +/// Текущий язык. +static CURRENT_LANG: Mutex = Mutex::new(String::new()); + +/// Инициализирует систему локализации. +pub fn init(lang_code: &str) -> Result<()> { + let mut bundles = HashMap::new(); + + // Загрузка русской локали + let ru = load_bundle("ru-RU", include_str!("../locales/ru-RU/app.ftl"))?; + bundles.insert("ru-RU".to_string(), Arc::new(SyncBundle(ru))); + + // Загрузка английской локали + let en = load_bundle("en-US", include_str!("../locales/en-US/app.ftl"))?; + bundles.insert("en-US".to_string(), Arc::new(SyncBundle(en))); + + *BUNDLES.lock().unwrap() = Some(bundles); + *CURRENT_LANG.lock().unwrap() = lang_code.to_string(); + + Ok(()) +} + +/// Загружает бандл локали из .ftl строки. +fn load_bundle(lang: &str, ftl: &str) -> Result> { + let lang_id: LanguageIdentifier = lang.parse()?; + let resource = FluentResource::try_new(ftl.to_string()) + .map_err(|e| anyhow::anyhow!("Ошибка парсинга FTL: {:?}", e))?; + + let mut bundle = FluentBundle::new(vec![lang_id]); + bundle.add_resource(resource) + .map_err(|e| anyhow::anyhow!("Ошибка добавления ресурса: {:?}", e))?; + + Ok(bundle) +} + +/// Возвращает переведённую строку по ключу. +pub fn t(key: &str) -> String { + t_with_args(key, &[]) +} + +/// Возвращает переведённую строку с аргументами. +pub fn t_with_args(key: &str, args: &[(&str, &str)]) -> String { + let bundles = BUNDLES.lock().unwrap(); + let lang = CURRENT_LANG.lock().unwrap(); + + if let Some(map) = bundles.as_ref() { + if let Some(bundle) = map.get(lang.as_str()) { + let msg = bundle.0.get_message(key); + + if let Some(msg) = msg { + if let Some(pattern) = msg.value() { + let mut args_map = fluent::FluentArgs::new(); + for (k, v) in args { + args_map.set(*k, *v); + } + + let mut errors = vec![]; + let value = bundle.0.format_pattern(pattern, Some(&args_map), &mut errors); + + if errors.is_empty() { + return value.to_string(); + } + } + } + } + } + + // Fallback: вернуть ключ + key.to_string() +} + +/// Устанавливает текущий язык. +pub fn set_language(lang_code: &str) { + *CURRENT_LANG.lock().unwrap() = lang_code.to_string(); +} + +/// Возвращает список доступных языков. +pub fn available_languages() -> Vec<&'static str> { + vec!["ru-RU", "en-US"] +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..b3e2427 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,4 @@ +//! Lark DE — библиотека общих компонентов. + +pub mod config; +pub mod i18n; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..fd50d54 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,126 @@ +//! Lark DE — лёгкая среда рабочего стола на Rust для слабых устройств. +//! +//! Точка входа: инициализация логирования, запуск композитора и shell. + +mod compositor; +mod shell; +mod services; +mod config; +mod i18n; +mod text; + +use anyhow::Result; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::OnceLock; +use tracing::info; +use tracing_subscriber::EnvFilter; + +/// Фильтр журнала по умолчанию. +/// +/// Свой код показывается целиком, а чужие библиотеки — только +/// предупреждениями. При фильтре `info` smithay печатает полный список +/// расширений OpenGL и параметры каждой инициализации: это несколько +/// экранов текста, который никто не читает, и он вытесняет сообщения Lark. +const DEFAULT_LOG_FILTER: &str = "lark=info,warn"; + +fn main() -> Result<()> { + // Инициализация логирования + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new(DEFAULT_LOG_FILTER)), + ) + .init(); + + info!("Lark DE запускается..."); + + // Загрузка конфигурации + let config = config::Config::load()?; + info!("Конфигурация загружена: {}", config.compositor.backend); + + // Инициализация i18n + i18n::init(&config.language.code)?; + info!("Язык инициализирован: {}", config.language.code); + + // Запуск композитора + let mut compositor = compositor::LarkCompositor::new(&config)?; + info!("Композитор инициализирован"); + + // Запуск shell + let mut shell = shell::LarkShell::new(&config, &compositor)?; + info!("Shell инициализирован"); + + // Запуск сервисов + services::init(&config)?; + info!("Сервисы запущены"); + + // Обработка сигналов: композитор должен завершиться штатно, а не + // потеряв буферы с ошибкой. + let running = compositor.running_flag(); + install_signal_handler(running); + + // Главный цикл + info!("Lark DE запущена!"); + compositor.run(&mut shell)?; + + info!("Lark DE остановлена"); + Ok(()) +} + +/// Завершает композитор по SIGINT/SIGTERM. +/// +/// Флаг хранится в статическом хранилище, потому что обработчик сигнала +/// не имеет доступа к состоянию вызывающего кода. +fn install_signal_handler(running: Arc) { + static FLAG: OnceLock> = OnceLock::new(); + + if FLAG.set(running).is_err() { + // Обработчик уже установлен: повторная регистрация не нужна. + return; + } + + // SAFETY: обработчик только меняет атомарный флаг, что async-signal-safe. + // Приведение к `*const ()` нужно, потому что сигнатура обработчика в libc + // — просто указатель, а не `fn`. + unsafe { + let handler = handle_shutdown_signal as *const () as libc::sighandler_t; + libc::signal(libc::SIGINT, handler); + libc::signal(libc::SIGTERM, handler); + } + + fn handle_shutdown_signal(_signal: libc::c_int) { + if let Some(flag) = FLAG.get() { + flag.store(false, Ordering::SeqCst); + } + } +} + +/// Тесты журналирования. +#[cfg(test)] +mod tests { + use super::*; + + /// Фильтр по умолчанию разбирается и задаёт нужные уровни. + /// + /// Опечатка в строке фильтра молча отключила бы часть журнала, и это + /// заметно только при первом запуске — поэтому проверяется здесь. + #[test] + fn default_filter_is_valid() { + let filter = EnvFilter::try_new(DEFAULT_LOG_FILTER) + .expect("фильтр по умолчанию должен разбираться"); + + // Разобранный фильтр сохраняет директивы: строка не была молча + // отброшена как неверная. + let directives = filter.to_string(); + + assert!( + directives.contains("lark=info"), + "свой код должен попадать в журнал на уровне info: {directives}" + ); + assert!( + directives.contains("warn"), + "чужие библиотеки должны ограничиваться предупреждениями: {directives}" + ); + } +} diff --git a/src/services/audio.rs b/src/services/audio.rs new file mode 100644 index 0000000..4db5771 --- /dev/null +++ b/src/services/audio.rs @@ -0,0 +1,15 @@ +//! Аудио-сервис. + +use crate::config::Config; +use anyhow::Result; + +/// Инициализирует аудио-сервис. +pub fn init(_config: &Config) -> Result<()> { + tracing::info!("Аудио-сервис запущен"); + + // TODO: Интеграция с PipeWire через D-Bus + // - Управление громкостью + // - Переключение устройств вывода + + Ok(()) +} diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..7c268c4 --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1,23 @@ +//! Lark Services — фоновые демоны. + +mod power; +mod network; +mod audio; +mod session; +mod notification; + +use crate::config::Config; +use anyhow::Result; + +/// Инициализирует все сервисы. +pub fn init(config: &Config) -> Result<()> { + tracing::info!("Инициализация сервисов..."); + + power::init(config)?; + network::init(config)?; + audio::init(config)?; + session::init(config)?; + notification::init(config)?; + + Ok(()) +} diff --git a/src/services/network.rs b/src/services/network.rs new file mode 100644 index 0000000..648c379 --- /dev/null +++ b/src/services/network.rs @@ -0,0 +1,15 @@ +//! Сетевой сервис. + +use crate::config::Config; +use anyhow::Result; + +/// Инициализирует сетевой сервис. +pub fn init(_config: &Config) -> Result<()> { + tracing::info!("Сетевой сервис запущен"); + + // TODO: Интеграция с NetworkManager через D-Bus + // - Мониторинг состояния сети + // - Управление Wi-Fi/Ethernet + + Ok(()) +} diff --git a/src/services/notification.rs b/src/services/notification.rs new file mode 100644 index 0000000..1a6a9d0 --- /dev/null +++ b/src/services/notification.rs @@ -0,0 +1,16 @@ +//! Сервис уведомлений. + +use crate::config::Config; +use anyhow::Result; + +/// Инициализирует сервис уведомлений. +pub fn init(_config: &Config) -> Result<()> { + tracing::info!("Сервис уведомлений запущен"); + + // TODO: D-Bus сервис уведомлений (org.freedesktop.Notifications) + // - Приём уведомлений от приложений + // - Очередь уведомлений + // - Отправка в NotificationCenter + + Ok(()) +} diff --git a/src/services/power.rs b/src/services/power.rs new file mode 100644 index 0000000..3010cdb --- /dev/null +++ b/src/services/power.rs @@ -0,0 +1,16 @@ +//! Сервис управления питанием. + +use crate::config::Config; +use anyhow::Result; + +/// Инициализирует сервис питания. +pub fn init(_config: &Config) -> Result<()> { + tracing::info!("Сервис питания запущен"); + + // TODO: Интеграция с logind/upower через D-Bus + // - Мониторинг батареи + // - Управление яркостью + // - Управление сном/гибернацией + + Ok(()) +} diff --git a/src/services/session.rs b/src/services/session.rs new file mode 100644 index 0000000..b1d8294 --- /dev/null +++ b/src/services/session.rs @@ -0,0 +1,16 @@ +//! Сервис управления сессией. + +use crate::config::Config; +use anyhow::Result; + +/// Инициализирует сервис сессии. +pub fn init(_config: &Config) -> Result<()> { + tracing::info!("Сервис сессии запущен"); + + // TODO: Управление сессией + // - Автозапуск приложений + // - Блокировка экрана + // - Выход/перезагрузка/завершение работы + + Ok(()) +} diff --git a/src/shell/clock.rs b/src/shell/clock.rs new file mode 100644 index 0000000..660cc35 --- /dev/null +++ b/src/shell/clock.rs @@ -0,0 +1,200 @@ +//! Часы в системном трее. +//! +//! Время берётся из libc напрямую: подключать chrono ради двух строк +//! `localtime_r` смысла нет, а лишние зависичности расходятся по версиям. +//! +//! Строки кэшируются и пересчитываются не чаще раза в секунду: иначе +//! панель растеризовывала бы текст 60 раз в секунду ради неизменной надписи. + +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Разобранное локальное время. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DateTime { + /// Час, 0..24. + pub hour: u32, + /// Минута, 0..60. + pub minute: u32, + /// День месяца, 1..32. + pub day: u32, + /// Месяц, 1..12. + pub month: u32, + /// Год. + pub year: i32, +} + +/// Собранные подписи часов. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClockText { + /// Время в формате `ЧЧ:ММ`. + pub time: String, + /// Дата в формате `ДД.ММ.ГГГГ`. + pub date: String, +} + +impl ClockText { + /// Формирует подписи из разобранного времени. + pub fn from_datetime(value: DateTime) -> Self { + Self { + time: format!("{:02}:{:02}", value.hour, value.minute), + date: format!( + "{:02}.{:02}.{:04}", + value.day, value.month, value.year + ), + } + } +} + +/// Кэш подписей часов. +#[derive(Debug, Default)] +pub struct Clock { + /// Последние собранные подписи. + text: Option, + /// Момент, когда подписи были собраны, в секундах с эпохи. + stamp: u64, +} + +impl Clock { + /// Создаёт часы без подписей: они соберутся при первом обращении. + pub fn new() -> Self { + Self::default() + } + + /// Возвращает актуальные подписи, обновляя их не чаще раза в секунду. + pub fn text(&mut self) -> ClockText { + let now = unix_seconds(); + + match &self.text { + Some(text) if now == self.stamp => text.clone(), + _ => { + let text = ClockText::from_datetime(local_datetime(now)); + self.stamp = now; + self.text = Some(text.clone()); + text + } + } + } + + /// `true`, если подписи собраны и ещё не устарели. + pub fn text_is_cached(&self) -> bool { + self.text.is_some() && self.stamp == unix_seconds() + } + + /// Принудительно состаривает кэш: нужно только в тестах. + fn force_stamp(&mut self, stamp: u64) { + self.stamp = stamp; + } +} + +/// Разбирает момент времени в локальную дату и время. +pub fn local_datetime(unix: u64) -> DateTime { + let seconds = unix as libc::time_t; + + // `localtime_r` пишет в переданный буфер и возвращает указатель на него + // либо `None`, если время не представимо (не бывает для u64 в разумных + // пределах, но проверка всё равно дешевле паники). + let mut parts: libc::tm = unsafe { std::mem::zeroed() }; + let ok = !unsafe { libc::localtime_r(&seconds, &mut parts) }.is_null(); + + if ok { + DateTime { + // Поля `tm` — знаковые целые, приведение безопасно: libc + // гарантирует диапазон для успешного разбора. + hour: parts.tm_hour as u32, + minute: parts.tm_min as u32, + day: parts.tm_mday as u32, + month: (parts.tm_mon + 1) as u32, + year: parts.tm_year + 1900, + } + } else { + // Фолбэк в UTC: лучше чуть неверная дата, чем пустая панель. + DateTime { + hour: ((unix / 3600) % 24) as u32, + minute: ((unix / 60) % 60) as u32, + day: 1, + month: 1, + year: 1970, + } + } +} + +/// Текущее время в секундах с эпохи Unix. +fn unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|delta| delta.as_secs()) + .unwrap_or(0) +} + +/// Тесты часов. +#[cfg(test)] +mod tests { + use super::*; + + /// Время и дата форматируются с ведущими нулями. + #[test] + fn text_has_leading_zeros() { + let text = ClockText::from_datetime(DateTime { + hour: 9, + minute: 5, + day: 3, + month: 1, + year: 2026, + }); + + assert_eq!(text.time, "09:05"); + assert_eq!(text.date, "03.01.2026"); + } + + /// Полночь не превращается в 24 часа. + #[test] + fn midnight_is_rendered_as_zero() { + let text = ClockText::from_datetime(DateTime { + hour: 0, + minute: 0, + day: 1, + month: 1, + year: 2026, + }); + + assert_eq!(text.time, "00:00"); + } + + /// Разбор реального момента времени не падает и даёт разумную дату. + #[test] + fn known_timestamp_is_parsed() { + // 2026-10-01T12:30:00Z + let value = local_datetime(1_788_265_800); + + assert!( + (1970..=2100).contains(&value.year), + "год вне диапазона: {}", + value.year + ); + assert!((1..=12).contains(&value.month), "месяц: {}", value.month); + assert!((1..=31).contains(&value.day), "день: {}", value.day); + assert!(value.hour <= 23, "час: {}", value.hour); + assert!(value.minute <= 59, "минута: {}", value.minute); + } + + /// Подписи переиспользуются, пока секунда не сменилась. + #[test] + fn text_is_cached_within_a_second() { + let mut clock = Clock::new(); + let first = clock.text(); + + assert_eq!(clock.text(), first, "подписи должны совпадать"); + assert!(clock.text_is_cached(), "кеш должен быть заполнен"); + } + + /// Ключ кэша меняется при смене секунды. + #[test] + fn cache_key_follows_time() { + let mut clock = Clock::new(); + clock.text(); + + assert!(clock.text_is_cached()); + clock.force_stamp(clock.stamp - 1); + assert!(!clock.text_is_cached(), "устаревший кеш должен сбрасываться"); + } +} diff --git a/src/shell/desktop.rs b/src/shell/desktop.rs new file mode 100644 index 0000000..0fcc853 --- /dev/null +++ b/src/shell/desktop.rs @@ -0,0 +1,47 @@ +//! Рабочий стол: иконки, обои, контекстное меню. + +/// Иконка на рабочем столе. +#[derive(Debug, Clone)] +pub struct DesktopIcon { + pub name: String, + pub icon: String, + pub position: (i32, i32), +} + +/// Рабочий стол. +pub struct Desktop { + icons: Vec, + wallpaper: Option, +} + +impl Desktop { + /// Создаёт новый рабочий стол. + pub fn new() -> Self { + Self { + icons: Vec::new(), + wallpaper: None, + } + } + + /// Добавляет иконку на рабочий стол. + pub fn add_icon(&mut self, name: impl Into, icon: impl Into, position: (i32, i32)) { + self.icons.push(DesktopIcon { + name: name.into(), + icon: icon.into(), + position, + }); + } + + /// Устанавливает обои. + pub fn set_wallpaper(&mut self, path: impl Into) { + self.wallpaper = Some(path.into()); + } + + /// Рендерит рабочий стол. + pub fn render(&self) { + // TODO: Рендеринг рабочего стола + // - Обои + // - Иконки + // - Контекстное меню + } +} diff --git a/src/shell/mod.rs b/src/shell/mod.rs new file mode 100644 index 0000000..385010c --- /dev/null +++ b/src/shell/mod.rs @@ -0,0 +1,760 @@ +//! Lark Shell — пользовательский интерфейс в стиле Windows 11. + +mod clock; +mod desktop; +mod notifications; +mod panel; +pub mod start_menu; +pub mod switcher; +mod tray; + +use crate::compositor::LarkCompositor; +use crate::config::Config; +use crate::text::{FontRenderer, TextColor, TextElement}; +use anyhow::Result; +use smithay::backend::allocator::Fourcc; +use smithay::backend::renderer::element::Kind; +use smithay::backend::renderer::element::memory::{ + MemoryBuffer, MemoryRenderBuffer, MemoryRenderBufferRenderElement, +}; +use smithay::backend::renderer::gles::GlesRenderer; +use smithay::backend::renderer::Color32F; +use smithay::utils::{Logical, Physical, Point, Size, Transform}; +use std::sync::Arc; + +/// Кегль подписей интерфейса. +const LABEL_SIZE: f32 = 13.0; + +/// Главный shell Lark DE. +pub struct LarkShell { + config: Arc, + panel: panel::Panel, + start_menu: start_menu::StartMenu, + tray: tray::SystemTray, + notifications: notifications::NotificationCenter, + desktop: desktop::Desktop, + /// Растровщик подписей: общий для панели, меню и заголовков. + pub font: FontRenderer, + /// Часы в трее. + clock: clock::Clock, + /// Состояние переключателя рабочих областей. + workspaces: Option, + /// Переключатель окон по `Alt+Tab`. + switcher: switcher::WindowSwitcher, + /// Раскладка панели последнего кадра. + /// + /// Хранится, чтобы попадание указателем считалось по тем же + /// прямоугольникам, что и нарисованы: иначе кнопка «попадает» мимо. + panel_layout: panel::PanelLayout, +} + +impl LarkShell { + /// Создаёт и инициализирует shell. + pub fn new(config: &Config, _compositor: &LarkCompositor) -> Result { + tracing::info!("Инициализация shell..."); + + Ok(Self { + config: Arc::new(config.clone()), + panel: panel::Panel::new(config), + start_menu: start_menu::StartMenu::new(), + tray: tray::SystemTray::new(), + notifications: notifications::NotificationCenter::new(), + desktop: desktop::Desktop::new(), + font: FontRenderer::new(), + clock: clock::Clock::new(), + workspaces: None, + switcher: switcher::WindowSwitcher::new(), + panel_layout: panel::PanelLayout::default(), + }) + } + + /// Обновляет shell (вызывается каждый кадр). + pub fn update(&mut self) { + // Состояние меню меняется только по действиям пользователя, поэтому + // здесь обновляется лишь время в трее. + let _ = self.clock.text(); + } + + /// Рендерит shell. + pub fn render(&mut self) { + // TODO: Рендеринг панели, меню, трея, уведомлений + } + + /// Цвет фона рабочего стола, выведенный из настроек темы. + pub fn background_color(&self) -> Color32F { + // Для тёмной темы используем почти чёрный фон, иначе — светло-серый. + if self.config.theme.name.eq_ignore_ascii_case("light") { + Color32F::new(0.85, 0.85, 0.87, 1.0) + } else { + Color32F::new(0.06, 0.06, 0.09, 1.0) + } + } + + /// Акцентный цвет темы в нормализованном виде. + pub fn accent_color(&self) -> Color32F { + match parse_hex_color(&self.config.theme.accent) { + Some((r, g, b)) => Color32F::new(r, g, b, 1.0), + None => Color32F::new(0.0, 0.47, 0.83, 1.0), + } + } + + /// Список открытых окон для панели задач, в порядке z-порядка. + pub fn set_windows(&mut self, windows: Vec) { + self.panel.set_windows(windows); + } + + /// Задаёт состояние переключателя рабочих областей. + pub fn set_workspaces(&mut self, current: usize, count: usize, window_counts: Vec) { + self.workspaces = Some(panel::WorkspaceBar { + current, + count, + // Счётчики дополняются нулями: панель обращается к ним по + // номеру области, а список мог оказаться короче. + window_counts: (0..count) + .map(|index| window_counts.get(index).copied().unwrap_or(0)) + .collect(), + }); + } + + /// Формирует список элементов рендеринга поверх окон. + /// + /// Сейчас рисуется панель задач с кнопкой «Пуск», кнопками окон, + /// подписями и часами; меню Пуск, трей и уведомления будут добавлены + /// по мере реализации соответствующих модулей. + pub fn render_elements( + &mut self, + renderer: &mut GlesRenderer, + output_size: Size, + ) -> Vec> { + let origin = panel::output_origin(output_size); + let clock = self.clock.text(); + + // Раскладка сохраняется: по ней же считается попадание указателем. + self.panel_layout = self.panel.layout_full( + origin, + self.accent_color(), + &mut self.font, + Some((&clock.time, &clock.date)), + self.workspaces.as_ref(), + ); + + let layout = std::mem::take(&mut self.panel_layout); + let mut elements = Vec::with_capacity(layout.solids.len() + layout.labels.len()); + + // Порядок критичен: элементы сцены отрисовываются **в обратном + // порядке списка**. Подписи идут первыми, чтобы оказаться сверху + // заливок, а фон панели последним, чтобы лечь вниз. + for label in &layout.labels { + if let Some(element) = self.label_element(renderer, label) { + elements.push(element); + } + } + + for solid in &layout.solids { + let (position, width, height) = solid.rect; + if width <= 0 || height <= 0 { + continue; + } + + let buffer = match solid_color_buffer(width, height, solid.color) { + Ok(buffer) => buffer, + Err(err) => { + tracing::warn!("не удалось построить буфер панели: {err}"); + continue; + } + }; + + match MemoryRenderBufferRenderElement::from_buffer( + renderer, + Point::new(position.x, position.y).to_f64(), + &buffer, + None, + None, + None, + Kind::Unspecified, + ) { + Ok(element) => elements.push(element), + Err(err) => tracing::warn!("не удалось создать элемент панели: {err}"), + } + } + + // Переключатель окон рисуется поверх всего, поэтому его элементы + // идут в самое начало списка: отрисовка идёт в обратном порядке. + if self.switcher.is_open() { + let switcher_layout = self.switcher_layout(output_size); + for element in self.switcher_elements(renderer, &switcher_layout) { + elements.push(element); + } + } + + // Меню «Пуск» рисуется поверх панели, поэтому его элементы идут в + // начало списка: при обратном порядке отрисовки панель закрыла бы + // меню целиком. + if self.start_menu.is_open() { + let menu = self.start_menu_layout(output_size); + let apps: Vec = self + .start_menu + .matches() + .iter() + .map(|app| app.name.to_string()) + .collect(); + + for element in self.start_menu_elements(renderer, &menu, &apps) { + elements.push(element); + } + } + + self.panel_layout = layout; + elements + } + + /// Открывает переключатель окон. + /// + /// Список пересобирается только при открытии: пока `Alt` удерживается, + /// порядок окон не должен меняться под выделением. + pub fn open_switcher(&mut self, entries: Vec) { + self.switcher.open_with(entries); + } + + /// Открыт ли переключатель окон. + pub fn switcher_open(&self) -> bool { + self.switcher.is_open() + } + + /// Переходит к следующему окну в переключателе. + pub fn switcher_next(&mut self) { + self.switcher.select_next(); + } + + /// Переходит к предыдущему окну в переключателе. + pub fn switcher_previous(&mut self) { + self.switcher.select_previous(); + } + + /// Закрывает переключатель окон. + /// + /// Возвращает идентификатор выбранного окна: `None`, если выбор отменён + /// или список пуст. + pub fn close_switcher(&mut self) -> Option { + let selected = if self.switcher.is_open() { + self.switcher.selected_id() + } else { + None + }; + + self.switcher.close(); + selected + } + + /// Переключает на окно под точкой, если переключатель открыт. + pub fn switcher_click(&mut self, point: Point, output_size: Size) -> bool { + if !self.switcher.is_open() { + return false; + } + + let layout = self.switcher_layout(output_size); + if let Some(index) = layout.item_at(point) { + // Выбор доводится до нужного пункта: подтверждение выполняется + // тем же путём, что и по отпусканию `Alt`. + while self.switcher.selected() != index && self.switcher.selected() < self.switcher.entries().len() { + if self.switcher.selected() < index { + self.switcher.select_next(); + } else { + self.switcher.select_previous(); + } + } + } + + true + } + + /// Считает раскладку переключателя окон. + fn switcher_layout(&mut self, output_size: Size) -> switcher::SwitcherLayout { + switcher::layout( + Point::new(output_size.w, output_size.h), + self.switcher.entries(), + &mut self.font, + ) + } + + /// Формирует элементы переключателя окон. + fn switcher_elements( + &mut self, + renderer: &mut GlesRenderer, + layout: &switcher::SwitcherLayout, + ) -> Vec> { + let entries: Vec = self.switcher.entries().to_vec(); + let selected = self.switcher.selected(); + + let mut elements = Vec::with_capacity(layout.items.len() * 3 + 1); + + // Порядок заливок обратен порядку отрисовки: значок идёт перед + // своей подложкой (иначе он окажется под ней), а фон панели — + // последним, чтобы лечь под всеми пунктами. + let accent = self.accent_color(); + let mut solids = Vec::with_capacity(layout.items.len() * 2 + 1); + + for (index, item) in layout.items.iter().enumerate() { + solids.push(( + switcher::icon_rect(*item), + Color32F::new(0.45, 0.55, 0.70, 1.0), + )); + + if index == selected { + solids.push((*item, accent)); + } + } + + solids.push((layout.background, Color32F::new(0.10, 0.10, 0.14, 0.96))); + + for (rect, color) in &solids { + let (origin, width, height) = *rect; + if width <= 0 || height <= 0 { + continue; + } + + let Ok(buffer) = solid_color_buffer(width, height, *color) else { + continue; + }; + + match MemoryRenderBufferRenderElement::from_buffer( + renderer, + Point::new(origin.x, origin.y).to_f64(), + &buffer, + None, + None, + None, + Kind::Unspecified, + ) { + Ok(element) => elements.push(element), + Err(err) => tracing::warn!("не удалось создать элемент переключателя: {err}"), + } + } + + // Подписи идут первыми: отрисовка идёт в обратном порядке, поэтому + // иначе заливки закрыли бы текст. + let mut texts = Vec::with_capacity(layout.items.len()); + for (index, entry) in entries.iter().enumerate() { + let Some(item) = layout.items.get(index) else { + break; + }; + + // Длинное название обрезается по ширине пункта. + let available = item.1 - (switcher::label_origin(*item).x - item.0.x) - 8; + let raw = self.font.render(&entry.title, switcher::title_size()); + let text = raw.truncated(&mut self.font, switcher::title_size(), available as f32); + if text.is_empty() { + continue; + } + + texts.push(panel::Label { + text: text.text, + bold: index == selected, + origin: switcher::label_origin(*item), + color: Color32F::new(1.0, 1.0, 1.0, 1.0), + }); + } + + let mut built: Vec<_> = texts + .iter() + .filter_map(|label| self.label_element(renderer, label)) + .collect(); + built.append(&mut elements); + built + } + + /// Формирует элементы меню «Пуск». + fn start_menu_elements( + &mut self, + renderer: &mut GlesRenderer, + menu: &start_menu::StartMenuLayout, + apps: &[String], + ) -> Vec> { + let mut elements = Vec::with_capacity(menu.items.len() + 4); + let query = self.start_menu.query().to_string(); + let selected = self.start_menu.selected(); + + // Фон меню и рамка поиска. + for (rect, color) in [ + (menu.background, Color32F::new(0.11, 0.11, 0.15, 0.97)), + (menu.search, Color32F::new(0.18, 0.18, 0.23, 1.0)), + ] { + let (origin, width, height) = rect; + if width <= 0 || height <= 0 { + continue; + } + + let Ok(buffer) = solid_color_buffer(width, height, color) else { + continue; + }; + + match MemoryRenderBufferRenderElement::from_buffer( + renderer, + Point::new(origin.x, origin.y).to_f64(), + &buffer, + None, + None, + None, + Kind::Unspecified, + ) { + Ok(element) => elements.push(element), + Err(err) => tracing::warn!("не удалось создать элемент меню: {err}"), + } + } + + // Подписи: сначала поиск, потом названия приложений. Их элементы + // должны оказаться поверх заливок, поэтому список пополняется ими + // первыми — отрисовка идёт в обратном порядке. + let mut texts: Vec = Vec::with_capacity(apps.len() + 1); + + let placeholder = if query.is_empty() { "Поиск приложений" } else { "" }; + if !placeholder.is_empty() { + texts.push(panel::Label { + text: placeholder.to_string(), + bold: false, + origin: Point::new(menu.search.0.x + 12, menu.search.0.y + 11), + color: Color32F::new(0.6, 0.6, 0.66, 1.0), + }); + } else { + texts.push(panel::Label { + text: query.clone(), + bold: false, + origin: Point::new(menu.search.0.x + 12, menu.search.0.y + 11), + color: Color32F::new(1.0, 1.0, 1.0, 1.0), + }); + } + + for (index, name) in apps.iter().enumerate() { + let Some(rect) = menu.items.get(index) else { + break; + }; + + texts.push(panel::Label { + text: name.clone(), + bold: false, + origin: Point::new(rect.0.x + 48, rect.0.y + 12), + color: if index == selected { + Color32F::new(1.0, 1.0, 1.0, 1.0) + } else { + Color32F::new(0.88, 0.88, 0.92, 1.0) + }, + }); + } + + let mut built: Vec<_> = texts + .iter() + .filter_map(|label| self.label_element(renderer, label)) + .collect(); + built.append(&mut elements); + built + } + + /// Растеризует подпись в элемент сцены. + fn label_element( + &mut self, + renderer: &mut GlesRenderer, + label: &panel::Label, + ) -> Option> { + // Панель задаёт цвет заливками `Color32F`, а текст растеризуется в + // `TextColor`: пересчёт делается здесь, один раз на подпись. + let color = TextColor { + r: label.color.r(), + g: label.color.g(), + b: label.color.b(), + a: label.color.a(), + }; + + let bitmap = if label.bold { + self.font.render_bold(&label.text, LABEL_SIZE) + } else { + self.font.render(&label.text, LABEL_SIZE) + }; + let buffer = TextElement { bitmap, color }.into_buffer()?; + + MemoryRenderBufferRenderElement::from_buffer( + renderer, + Point::new(label.origin.x, label.origin.y).to_f64(), + &buffer, + None, + None, + None, + Kind::Unspecified, + ) + .ok() + } + + /// Идентификатор окна под точкой панели задач. + /// + /// `None` означает, что клик попал вне панели, на фон или на кнопку + /// «Пуск», то есть переводить фокус не на что. + pub fn window_at(&self, point: Point) -> Option { + self.panel.window_at(&self.panel_layout, point) + } + + /// Проверяет, находится ли точка внутри панели задач. + pub fn on_panel(&self, point: Point, output_size: Size) -> bool { + self.panel + .contains_point(point, panel::output_origin(output_size)) + } + + /// Попадает ли точка в кнопку «Пуск». + /// + /// Кнопка не входит в список окон, поэтому по нему не найти — он + /// отсчитывается прямоугольником из раскладки. + pub fn start_button_hit(&self, point: Point) -> bool { + let (origin, width, height) = self.panel_layout.start_button; + let p = point.to_i32_round::(); + + p.x >= origin.x + && p.x < origin.x + width + && p.y >= origin.y + && p.y < origin.y + height + } + + /// Попадает ли точка в меню «Пуск». + /// + /// Раскладка считается заново: она зависит от числа совпавших с поиском + /// приложений, а не от того, что было нарисовано в прошлом кадре. + pub fn start_menu_contains( + &mut self, + point: Point, + output_size: Size, + ) -> bool { + if !self.start_menu.is_open() { + return false; + } + + self.start_menu_layout(output_size).contains(point) + } + + /// Перемещает выделение в меню «Пуск» вниз. + pub fn start_menu_select_next(&mut self) { + self.start_menu.select_next(); + } + + /// Перемещает выделение в меню «Пуск» вверх. + pub fn start_menu_select_previous(&mut self) { + self.start_menu.select_previous(); + } + + /// Открыто ли меню «Пуск». + pub fn start_menu_open(&self) -> bool { + self.start_menu.is_open() + } + + /// Переключает меню «Пуск». + pub fn toggle_start_menu(&mut self) { + self.start_menu.toggle(); + } + + /// Закрывает меню «Пуск». + pub fn close_start_menu(&mut self) { + self.start_menu.close(); + } + + /// Запускает выбранное в меню «Пуск» приложение. + /// + /// Меню закрывается при любом исходе: оставить его открытым после + /// нажатия на пункт нельзя — пользователь уже сделал выбор. + pub fn launch_selected_app(&mut self) -> start_menu::LaunchOutcome { + let outcome = self.start_menu.launch_selected(); + self.start_menu.close(); + outcome + } + + /// Обрабатывает символ, набранный при открытом меню «Пуск». + /// + /// Возвращает `false`, если символ не должен попадать в меню: строка + /// поиска пуста и Backspace ушёл на удаление символа из окна. + pub fn start_menu_key(&mut self, text: &str) -> bool { + if !self.start_menu.is_open() { + return false; + } + + for ch in text.chars() { + match ch { + // Пробел идёт в поиск, иначе строку нельзя было бы набрать. + '\u{8}' => { + if !self.start_menu.backspace() { + return false; + } + } + '\r' | '\n' => { + self.launch_selected_app(); + } + _ if ch.is_control() => {} + _ => self.start_menu.type_char(ch), + } + } + + true + } + + /// Удаляет символ из строки поиска меню «Пуск». + /// + /// Возвращает `false`, если строка уже пуста: тогда Backspace должен + /// уйти в окно, а не быть проглочен. + pub fn start_menu_backspace(&mut self) -> bool { + self.start_menu.backspace() + } + + /// Действие по клику в меню «Пуск». + /// + /// `None` означает, что клик не относится к меню. + pub fn start_menu_click( + &mut self, + point: Point, + output_size: Size, + ) -> Option { + if !self.start_menu.is_open() { + return None; + } + + let layout = start_menu::layout( + Point::new(output_size.w, output_size.h), + self.start_menu.matches().len(), + self.start_menu.selected(), + &mut self.font, + ); + + if !layout.contains(point) { + // Клик мимо меню закрывает его — как в Windows. + self.start_menu.close(); + return Some(start_menu::LaunchOutcome::Nothing); + } + + let outcome = match layout.app_at(point) { + Some(index) => { + self.start_menu.select(index); + self.launch_selected_app() + } + // Клик по пустому месту меню: выбор не изменился. + None => start_menu::LaunchOutcome::Nothing, + }; + + Some(outcome) + } + + /// Переключатель рабочих областей: размеры меню «Пуск» и панели. + fn start_menu_layout( + &mut self, + output_size: Size, + ) -> start_menu::StartMenuLayout { + start_menu::layout( + Point::new(output_size.w, output_size.h), + self.start_menu.matches().len(), + self.start_menu.selected(), + &mut self.font, + ) + } + + /// Рабочая область под точкой панели. + pub fn workspace_at(&self, point: Point) -> Option { + self.workspaces + .as_ref() + .and_then(|bar| bar.workspace_at(point, &self.panel_layout)) + } +} + +/// Сведения об открытом окне для панели задач. +#[derive(Debug, Clone)] +pub struct TaskbarEntry { + /// Идентификатор окна, по которому клик переводит фокус на него. + pub id: u64, + /// Заголовок окна, либо имя приложения по умолчанию. + pub title: String, + /// Активно ли окно (в него уходит клавиатурный ввод). + pub active: bool, + /// Минимизировано ли окно. + pub minimized: bool, +} + +/// Создаёт буфер, залитый сплошным цветом. +pub fn solid_color_buffer( + width: i32, + height: i32, + color: Color32F, +) -> anyhow::Result { + let width = width.max(1) as usize; + let height = height.max(1) as usize; + + let b = (color.b() * 255.0).clamp(0.0, 255.0) as u8; + let g = (color.g() * 255.0).clamp(0.0, 255.0) as u8; + let r = (color.r() * 255.0).clamp(0.0, 255.0) as u8; + let a = (color.a() * 255.0).clamp(0.0, 255.0) as u8; + + // Fourcc::Abgr8888 — это 0xAABBGGRR, то есть в памяти байты лежат + // в порядке R, G, B, A. + let mut data = Vec::with_capacity(width * height * 4); + for _ in 0..width * height { + data.extend_from_slice(&[r, g, b, a]); + } + + let mem = MemoryBuffer::from_slice( + &data, + Fourcc::Abgr8888, + (width as i32, height as i32), + ); + + Ok(MemoryRenderBuffer::from_memory(mem, 1, Transform::Normal, None)) +} + +/// Разбирает цвет вида `#RRGGBB` в нормализованные компоненты. +fn parse_hex_color(value: &str) -> Option<(f32, f32, f32)> { + let hex = value.trim().trim_start_matches('#'); + if hex.len() != 6 { + return None; + } + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some((r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0)) +} + +/// Тесты оформления панели задач. +#[cfg(test)] +mod tests { + use super::*; + + /// Проверяет разбор цвета из настроек темы. + #[test] + fn accent_color_is_parsed() { + let (r, g, b) = parse_hex_color("#0078D4").expect("цвет должен разбираться"); + assert!(r.abs() < 0.01, "красный канал: {r}"); + assert!((g - 0.47).abs() < 0.01, "зелёный канал: {g}"); + assert!((b - 0.83).abs() < 0.01, "синий канал: {b}"); + } + + /// Проверяет разбор граничных значений цвета. + #[test] + fn accent_color_handles_extremes() { + assert_eq!(parse_hex_color("#000000"), Some((0.0, 0.0, 0.0))); + assert_eq!(parse_hex_color("#FFFFFF"), Some((1.0, 1.0, 1.0))); + } + + /// Проверяет отбрасывание некорректного цвета: без него композитор + /// использовал бы неверный цвет панели. + #[test] + fn invalid_accent_color_is_rejected() { + assert_eq!(parse_hex_color(""), None); + assert_eq!(parse_hex_color("#FFF"), None); + assert_eq!(parse_hex_color("ZZZZZZ"), None); + } + + /// Проверяет, что буфер панели создаётся для корректных размеров. + #[test] + fn solid_color_buffer_is_created() { + solid_color_buffer(8, 4, Color32F::new(1.0, 0.0, 0.0, 1.0)) + .expect("буфер должен создаваться"); + } + + /// Проверяет, что нулевые и отрицательные размеры не приводят к панике. + /// + /// Размер панели берётся из конфигурации, поэтому нельзя исключить + /// значение `0`. + #[test] + fn solid_color_buffer_survives_invalid_size() { + solid_color_buffer(0, -5, Color32F::new(0.0, 1.0, 0.0, 1.0)) + .expect("буфер должен создаваться даже при некорректном размере"); + } +} diff --git a/src/shell/notifications.rs b/src/shell/notifications.rs new file mode 100644 index 0000000..fcf7b0c --- /dev/null +++ b/src/shell/notifications.rs @@ -0,0 +1,59 @@ +//! Центр уведомлений. + +/// Уведомление. +#[derive(Debug, Clone)] +pub struct Notification { + pub id: u64, + pub title: String, + pub body: String, + pub app_name: String, +} + +/// Центр уведомлений. +pub struct NotificationCenter { + notifications: Vec, + next_id: u64, +} + +impl NotificationCenter { + /// Создаёт новый центр уведомлений. + pub fn new() -> Self { + Self { + notifications: Vec::new(), + next_id: 1, + } + } + + /// Добавляет уведомление. + pub fn add(&mut self, title: impl Into, body: impl Into, app_name: impl Into) -> u64 { + let id = self.next_id; + self.next_id += 1; + self.notifications.push(Notification { + id, + title: title.into(), + body: body.into(), + app_name: app_name.into(), + }); + id + } + + /// Удаляет уведомление по ID. + pub fn remove(&mut self, id: u64) { + self.notifications.retain(|n| n.id != id); + } + + /// Очищает все уведомления. + pub fn clear_all(&mut self) { + self.notifications.clear(); + } + + /// Возвращает список уведомлений. + pub fn notifications(&self) -> &[Notification] { + &self.notifications + } + + /// Рендерит центр уведомлений. + pub fn render(&self) { + // TODO: Рендеринг уведомлений + } +} diff --git a/src/shell/panel.rs b/src/shell/panel.rs new file mode 100644 index 0000000..a3e04f3 --- /dev/null +++ b/src/shell/panel.rs @@ -0,0 +1,1148 @@ +//! Панель задач в стиле Windows 11. +//! +//! Раскладка повторяет Windows 11: по центру кнопка «Пуск», рядом кнопки +//! открытых окон с подписями, справа — системный трей с часами. +//! +//! Панель описывается как список прямоугольников с заливкой и список +//! подписей. Заливки и текст рисуются разными элементами сцены, поэтому +//! панель возвращает их раздельно, а не одной кучей. + +use crate::config::Config; +use crate::shell::TaskbarEntry; +use crate::text::FontRenderer; +use smithay::backend::renderer::Color32F; +use smithay::utils::{Logical, Physical, Point, Size}; + +/// Точка в логических координатах окна. +type Pos = Point; +/// Размер выхода в физических пикселях. +type OutputSize = Size; + +/// Отступ кнопок от края панели при выравнивании по левому краю. +const PADDING: i32 = 8; + +/// Кегль подписей окон. +const LABEL_SIZE: f32 = 13.0; + +/// Кегль времени в часах. +const CLOCK_SIZE: f32 = 13.0; + +/// Кегль даты в часах. +/// +/// Мельче 13 пикселей растеризатор без хинтинга даёт рваные штрихи, а +/// цифры разъезжаются сильнее всего. +const CLOCK_DATE_SIZE: f32 = 13.0; + +/// Отступ подписи окна от значка. +const LABEL_GAP: i32 = 8; + +/// Отступ подписи от края кнопки. +const LABEL_MARGIN: i32 = 8; + +/// Прямоугольник в логических координатах. +pub type Rect = (Pos, i32, i32); + +/// Одноцветная заливка, подготовленная для отрисовки. +#[derive(Debug, Clone, Copy)] +pub struct Solid { + /// Цвет заливки. + pub color: Color32F, + /// Прямоугольник заливки: начало в логических координатах, затем + /// ширина и высота. + pub rect: Rect, +} + +/// Подпись, подготовленная для растеризации. +#[derive(Debug, Clone)] +pub struct Label { + /// Текст подписи. + pub text: String, + /// Рисовать полужирным: для мелких подписей на цветной заливке + /// обычное начертание выходит слишком бледным. + pub bold: bool, + /// Левый верхний угол растра в логических координатах. + pub origin: Pos, + /// Цвет текста. + pub color: Color32F, +} + +/// Кнопка открытого окна на панели. +#[derive(Debug, Clone)] +pub struct TaskbarButton { + /// Прямоугольник кнопки. + pub rect: Rect, + /// Цвет фона кнопки. + pub color: Color32F, + /// Прямоугольник значка приложения внутри кнопки. + pub icon: Rect, + /// Подпись окна: пустая, если места не хватило. + pub label: Option