diff --git a/.gitignore b/.gitignore index 7f6c9d8..206e2cb 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,8 @@ pkg/src/ pkg/pkg/ pkg/*.pkg.tar* pkg/moonset/ + + +# Added by cargo + +/target diff --git a/CLAUDE.md b/CLAUDE.md index 0a20d65..35b97e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,44 +4,46 @@ ## Projekt -Moonset ist ein Wayland Session Power Menu, gebaut mit Python + GTK4 + gtk4-layer-shell. +Moonset ist ein Wayland Session Power Menu, gebaut mit Rust + gtk4-rs + gtk4-layer-shell. Teil des Moonarch-Ökosystems. Per Keybind aufrufbares Overlay mit 5 Aktionen: Lock, Logout, Hibernate, Reboot, Shutdown. ## Tech-Stack -- Python 3.11+, PyGObject (GTK 4.0) -- gtk4-layer-shell für Wayland Layer Shell (OVERLAY Layer) -- pytest für Tests +- Rust (Edition 2024), gtk4-rs 0.11, glib 0.22 +- gtk4-layer-shell 0.8 für Wayland Layer Shell (OVERLAY Layer) +- `cargo test` für Unit-Tests +- Python-Quellen in `src/moonset/` als Referenz erhalten ## Projektstruktur -- `src/moonset/` — Quellcode -- `src/moonset/data/` — Package-Assets (Fallback-Wallpaper) -- `tests/` — pytest Tests +- `src/` — Rust-Quellcode (main.rs, power.rs, i18n.rs, config.rs, users.rs, panel.rs) +- `resources/` — GResource-Assets (style.css, wallpaper.jpg, default-avatar.svg) - `config/` — Beispiel-Konfigurationsdateien +- `src/moonset/` — Python-Referenzimplementierung (v0.2.0) +- `tests/` — Python-Tests (Referenz) ## Kommandos ```bash # Tests ausführen -uv run pytest tests/ -v +cargo test -# Typ-Checks -uv run pyright src/ +# Release-Build +cargo build --release # Power-Menu starten (in Niri-Session) -uv run moonset +LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonset ``` ## Architektur -- `power.py` — 5 Power-Action-Wrapper (lock, logout, hibernate, reboot, shutdown) -- `i18n.py` — Locale-Erkennung und String-Tabellen (DE/EN) -- `config.py` — TOML-Config + Wallpaper-Fallback -- `panel.py` — GTK4 UI (Action-Buttons, Inline-Confirmation, WallpaperWindow) -- `main.py` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor -- `style.css` — Catppuccin Mocha Theme +- `power.rs` — 5 Power-Action-Wrapper (lock, logout, hibernate, reboot, shutdown) +- `i18n.rs` — Locale-Erkennung und String-Tabellen (DE/EN) +- `config.rs` — TOML-Config + Wallpaper-Fallback +- `panel.rs` — GTK4 UI (Action-Buttons, Inline-Confirmation, WallpaperWindow) +- `main.rs` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor +- `resources/style.css` — Catppuccin Mocha Theme (aus Python-Version übernommen) ## Design Decisions @@ -50,3 +52,6 @@ uv run moonset - **Einmal-Start per Keybind**: Kein Daemon, GTK `application_id` verhindert Doppelstart - **System-Icons**: Adwaita/Catppuccin liefern alle benötigten symbolischen Icons - **Lock ohne Confirmation**: Lock ist sofort reversibel, braucht kein Confirm +- **Icon-Scaling**: 22px Theme-Variante laden, auf 64px skalieren via GdkPixbuf +- **GResource-Bundle**: CSS, Wallpaper und Default-Avatar sind in die Binary kompiliert +- **Async Power Actions**: `glib::spawn_future_local` + `gio::spawn_blocking` statt raw Threads diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e313700 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1396 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "cairo-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b4985713047f5faee02b8db6a6ef32bbb50269ff53c1aee716d1d195b76d54" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cfg-expr" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[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", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f420376dbee041b2db374ce4573892a36222bb3f6c0c43e24f0d67eae9b646" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f31b37b1fc4b48b54f6b91b7ef04c18e00b4585d98359dd7b998774bbd91fb" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa528049fd8726974a7aa1a6e1421f891e7579bea6cc6d54056ab4d1a1b937e7" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "gl", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dd48b1b03dce78ab52805ac35cfb69c48af71a03af5723231d8583718738377" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[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.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "gio" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "816b6743c46b217aa8fba679095ac6f2162fd53259dc8f186fcdbff9c555db03" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64729ba2772c080448f9f966dba8f4456beeb100d8c28a865ef8a0f2ef4987e1" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys", +] + +[[package]] +name = "gl" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94edab108827d67608095e269cf862e60d920f144a5026d3dbcfd8b877fb404" +dependencies = [ + "gl_generator", +] + +[[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 = "glib" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "039f93465ac17e6cb02d16f16572cd3e43a77e736d5ecc461e71b9c9c5c0569c" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-build-tools" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bf3abfd1e60b194dded50f802277c68a59121a5a221701102f02db223cdda27" +dependencies = [ + "gio", +] + +[[package]] +name = "glib-macros" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda575994e3689b1bc12f89c3df621ead46ff292623b76b4710a3a5b79be54bb" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "glib-sys" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1eb23a616a3dbc7fc15bbd26f58756ff0b04c8a894df3f0680cd21011db6a642" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18eda93f09d3778f38255b231b17ef67195013a592c91624a4daf8bead875565" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d1b7881f96869f49808b6adfe906a93a57a34204952253444d68c3208d71f1" +dependencies = [ + "glib", + "graphene-sys", + "libc", +] + +[[package]] +name = "graphene-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "517f062f3fd6b7fd3e57a3f038a74b3c23ca32f51199ff028aa704609943f79c" +dependencies = [ + "glib-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gsk4" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c912dfcbd28acace5fc99c40bb9f25e1dcb73efb1f2608327f66a99acdcb62" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d54bbc7a9d8b6ffe4f0c95eede15ccfb365c8bf521275abe6bcfb57b18fb8a" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f671029e3f5288fd35e03a6e6b19e1ce643b10a3d261d33d183e453f6c52fe" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-layer-shell" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4069987ff4793699511a251028cc336b438e46565b463f111250148d574752a" +dependencies = [ + "bitflags", + "gdk4", + "glib", + "glib-sys", + "gtk4", + "gtk4-layer-shell-sys", + "libc", +] + +[[package]] +name = "gtk4-layer-shell-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f566a5ec5bcc454e7fcf2ab76930887ced5365afce12c1e5201bb296b95f1b9" +dependencies = [ + "gdk4-sys", + "glib-sys", + "gtk4-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk4-macros" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3581b242ba62fdff122ebb626ea641582ec326031622bd19d60f85029c804a87" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gtk4-sys" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0786e7e8e0550d0ab2df4d0d90032f22033e07d5ed78b6a1b2e51b05340339e" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "moonset" +version = "0.1.0" +dependencies = [ + "dirs", + "env_logger", + "gdk-pixbuf", + "gdk4", + "glib", + "glib-build-tools", + "gtk4", + "gtk4-layer-shell", + "log", + "nix", + "serde", + "tempfile", + "toml 0.8.23", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d8f224eddef627b896d2f7b05725b3faedbd140e0e8343446f0d34f34238ee" +dependencies = [ + "gio", + "glib", + "libc", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd111a20ca90fedf03e09c59783c679c00900f1d8491cca5399f5e33609d5d6" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[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.8+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[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 = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" +dependencies = [ + "serde_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "7.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c8f33736f986f16d69b6cb8b03f55ddcad5c41acc4ccc39dd88e84aa805e7f" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml 0.9.12+spec-1.1.0", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.0", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[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 = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +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 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +dependencies = [ + "indexmap", + "toml_datetime 1.1.0+spec-1.1.0", + "toml_parser", + "winnow 1.0.0", +] + +[[package]] +name = "toml_parser" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +dependencies = [ + "winnow 1.0.0", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[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.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[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.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b87a3a6 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "moonset" +version = "0.1.0" +edition = "2024" +description = "Wayland session power menu with GTK4 and Layer Shell" +license = "MIT" + +[dependencies] +gtk4 = { version = "0.11", features = ["v4_10"] } +gtk4-layer-shell = "0.8" +glib = "0.22" +gdk4 = "0.11" +gdk-pixbuf = "0.22" +toml = "0.8" +dirs = "6" +serde = { version = "1", features = ["derive"] } +nix = { version = "0.29", features = ["user"] } +log = "0.4" +env_logger = "0.11" + +[dev-dependencies] +tempfile = "3" + +[build-dependencies] +glib-build-tools = "0.22" diff --git a/README.md b/README.md index 304248a..0877e41 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Per Keybind aufrufbares Fullscreen-Overlay mit 5 Aktionen: ## Features -- GTK4 + gtk4-layer-shell (OVERLAY Layer — über Waybar) +- Rust + gtk4-rs + gtk4-layer-shell (OVERLAY Layer — über Waybar) - Catppuccin Mocha Theme - Multi-Monitor-Support (Wallpaper auf Sekundärmonitoren) - Inline-Confirmation für destruktive Aktionen @@ -18,7 +18,14 @@ Per Keybind aufrufbares Fullscreen-Overlay mit 5 Aktionen: ## Installation ```bash -uv pip install . +cargo build --release +install -Dm755 target/release/moonset /usr/bin/moonset +``` + +Oder via PKGBUILD: + +```bash +cd pkg && makepkg -si ``` ## Verwendung @@ -42,16 +49,16 @@ Konfigurationsdatei: `~/.config/moonset/moonset.toml` oder `/etc/moonset/moonset background_path = "/usr/share/moonarch/wallpaper.jpg" ``` -Wallpaper-Fallback: Konfiguration → `/usr/share/moonarch/wallpaper.jpg` → Package-Wallpaper +Wallpaper-Fallback: Konfiguration → `/usr/share/moonarch/wallpaper.jpg` → eingebettetes Package-Wallpaper ## Entwicklung ```bash # Tests -uv run pytest tests/ -v +cargo test -# Type-Check -uv run pyright src/ +# Release-Build +cargo build --release ``` ## Teil des Moonarch-Ökosystems diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..cb96cb4 --- /dev/null +++ b/build.rs @@ -0,0 +1,10 @@ +// ABOUTME: Build script for compiling GResource bundle. +// ABOUTME: Bundles style.css, wallpaper.jpg, and default-avatar.svg into the binary. + +fn main() { + glib_build_tools::compile_resources( + &["resources"], + "resources/resources.gresource.xml", + "moonset.gresource", + ); +} diff --git a/journal.md b/journal.md index 9dc64b6..b857c9d 100644 --- a/journal.md +++ b/journal.md @@ -1,6 +1,27 @@ # Hekate — Journal -## 2026-03-27 +## 2026-03-27 — Rust Rewrite + +Rewrite von Python auf Rust (gtk4-rs + gtk4-layer-shell). Motivation: ~800ms Startzeit der Python-Version durch Interpreter-Overhead. + +Alle Module 1:1 portiert: +- `power.rs` — Command::new statt subprocess.run, PowerError enum statt Exceptions +- `i18n.rs` — Static Strings statt Dataclass, parse_lang_prefix() separat testbar (kein env::set_var nötig) +- `config.rs` — serde::Deserialize für TOML, GResource-Pfad als letzter Fallback +- `users.rs` — nix-crate für getuid/getpwuid, GResource-Pfad für default-avatar +- `panel.rs` — Freie Funktionen statt Klassen, Rc für Confirm-State, glib::spawn_future_local + gio::spawn_blocking für async Power-Actions +- `main.rs` — GResource-Registration, LayerShell trait statt Gtk4LayerShell-Modul + +45 Unit-Tests grün. Release-Binary: 3.1 MB. + +Gelernt: +- gtk4-rs 0.11 braucht Rust ≥1.92 (system hatte 1.91 → rustup update) +- `ContentFit` und `Widget::color()` brauchen Feature-Flags (`v4_8`, `v4_10`) +- GTK-Objekte (WeakRef) sind nicht Send → glib::spawn_future_local statt std::thread für UI-Updates +- `set_from_paintable` heißt jetzt `set_paintable` in gtk4-rs 0.11 +- GResource-Bundle kompiliert CSS/Wallpaper/Avatar in die Binary — kein importlib.resources mehr nötig + +## 2026-03-27 — Initiale Python-Version Erster Tag. Moonset von Null auf v0.1.0 gebracht. TDD durchgezogen — alle 54 Tests grün, bevor der erste manuelle Start passiert ist. Das Pattern aus moongreet/moonlock hat sich bewährt: power.py, i18n.py, config.py sind fast 1:1 übernommen, nur mit den 5 Aktionen erweitert. diff --git a/pkg/PKGBUILD b/pkg/PKGBUILD index b3373e4..6f1a5d8 100644 --- a/pkg/PKGBUILD +++ b/pkg/PKGBUILD @@ -7,20 +7,16 @@ pkgname=moonset-git pkgver=0.1.0.r8.g934a923 pkgrel=1 pkgdesc="A Wayland session power menu with GTK4 and Layer Shell" -arch=('any') +arch=('x86_64') url="https://gitea.moonarch.de/nevaforget/moonset" license=('MIT') depends=( - 'python' - 'python-gobject' 'gtk4' 'gtk4-layer-shell' ) makedepends=( 'git' - 'python-build' - 'python-installer' - 'python-hatchling' + 'cargo' ) provides=('moonset') conflicts=('moonset') @@ -34,13 +30,12 @@ pkgver() { build() { cd "$srcdir/moonset" - rm -rf dist/ - python -m build --wheel --no-isolation + cargo build --release --locked } package() { cd "$srcdir/moonset" - python -m installer --destdir="$pkgdir" dist/*.whl + install -Dm755 target/release/moonset "$pkgdir/usr/bin/moonset" # Example config install -Dm644 config/moonset.toml "$pkgdir/etc/moonset/moonset.toml.example" diff --git a/resources/default-avatar.svg b/resources/default-avatar.svg new file mode 100644 index 0000000..e3da366 --- /dev/null +++ b/resources/default-avatar.svg @@ -0,0 +1 @@ + diff --git a/resources/resources.gresource.xml b/resources/resources.gresource.xml new file mode 100644 index 0000000..c379862 --- /dev/null +++ b/resources/resources.gresource.xml @@ -0,0 +1,8 @@ + + + + style.css + wallpaper.jpg + default-avatar.svg + + diff --git a/resources/style.css b/resources/style.css new file mode 100644 index 0000000..fd9283b --- /dev/null +++ b/resources/style.css @@ -0,0 +1,105 @@ +/* ABOUTME: GTK4 CSS stylesheet for the Moonset power menu. */ +/* ABOUTME: Uses GTK theme colors for consistency with the active desktop theme. */ + +/* Main panel window background */ +window.panel { + background-color: @theme_bg_color; + background-size: cover; + background-position: center; +} + +/* Wallpaper-only window for secondary monitors */ +window.wallpaper { + background-color: @theme_bg_color; +} + +/* Round avatar image */ +.avatar { + border-radius: 50%; + min-width: 128px; + min-height: 128px; + background-color: @theme_selected_bg_color; + border: 3px solid alpha(white, 0.3); +} + +/* Username label */ +.username-label { + font-size: 24px; + font-weight: bold; + color: white; + margin-top: 12px; + margin-bottom: 40px; +} + +/* Action button — square card */ +.action-button { + min-width: 120px; + min-height: 120px; + padding: 16px; + border-radius: 50%; + background-color: alpha(@theme_base_color, 0.55); + color: @theme_fg_color; + border: none; +} + +.action-button:hover { + background-color: alpha(@theme_base_color, 0.7); +} + +/* Action icon inside button — request 48px from theme, scale up via CSS */ +.action-icon { + color: @theme_fg_color; + -gtk-icon-size: 64px; +} + +/* Action label below icon */ +.action-label { + font-size: 14px; + color: @theme_unfocused_fg_color; +} + +/* Confirmation box below action buttons */ +.confirm-box { + padding: 16px 24px; + background-color: transparent; +} + +/* Confirmation prompt text */ +.confirm-label { + font-size: 16px; + color: @theme_fg_color; + margin-bottom: 4px; +} + +/* Confirm "Yes" button */ +.confirm-yes { + padding: 8px 24px; + border-radius: 8px; + background-color: @error_color; + color: @theme_bg_color; + border: none; + font-weight: bold; +} + +.confirm-yes:hover { + background-color: lighter(@error_color); +} + +/* Confirm "No/Cancel" button */ +.confirm-no { + padding: 8px 24px; + border-radius: 8px; + background-color: @theme_unfocused_bg_color; + color: @theme_fg_color; + border: none; +} + +.confirm-no:hover { + background-color: @theme_selected_bg_color; +} + +/* Error message label */ +.error-label { + color: @error_color; + font-size: 14px; +} diff --git a/resources/wallpaper.jpg b/resources/wallpaper.jpg new file mode 100644 index 0000000..86371cd Binary files /dev/null and b/resources/wallpaper.jpg differ diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..87b0732 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,159 @@ +// ABOUTME: Configuration loading for the session power menu. +// ABOUTME: Reads moonset.toml for wallpaper settings with fallback hierarchy. + +use serde::Deserialize; +use std::fs; +use std::path::{Path, PathBuf}; + +const MOONARCH_WALLPAPER: &str = "/usr/share/moonarch/wallpaper.jpg"; +const GRESOURCE_PREFIX: &str = "/dev/moonarch/moonset"; + +/// Default config search paths: system-wide, then user-specific. +fn default_config_paths() -> Vec { + let mut paths = vec![PathBuf::from("/etc/moonset/moonset.toml")]; + if let Some(config_dir) = dirs::config_dir() { + paths.push(config_dir.join("moonset").join("moonset.toml")); + } + paths +} + +/// Power menu configuration. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct Config { + pub background_path: Option, +} + +/// Load config from TOML files. Later paths override earlier ones. +pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config { + let default_paths = default_config_paths(); + let paths = config_paths.unwrap_or(&default_paths); + + let mut merged = Config::default(); + for path in paths { + if let Ok(content) = fs::read_to_string(path) { + if let Ok(parsed) = toml::from_str::(&content) { + if parsed.background_path.is_some() { + merged.background_path = parsed.background_path; + } + } + } + } + + merged +} + +/// Resolve the wallpaper path using the fallback hierarchy. +/// +/// Priority: config background_path > Moonarch system default > gresource fallback. +pub fn resolve_background_path(config: &Config) -> PathBuf { + resolve_background_path_with(config, Path::new(MOONARCH_WALLPAPER)) +} + +/// Resolve with configurable moonarch wallpaper path (for testing). +pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> PathBuf { + // User-configured path + if let Some(ref bg) = config.background_path { + let path = PathBuf::from(bg); + if path.is_file() { + return path; + } + } + + // Moonarch ecosystem default + if moonarch_wallpaper.is_file() { + return moonarch_wallpaper.to_path_buf(); + } + + // GResource fallback path (loaded from compiled resources at runtime) + PathBuf::from(format!("{GRESOURCE_PREFIX}/wallpaper.jpg")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config_has_none_background() { + let config = Config::default(); + assert!(config.background_path.is_none()); + } + + #[test] + fn load_config_returns_default_when_no_files_exist() { + let paths = vec![PathBuf::from("/nonexistent/moonset.toml")]; + let config = load_config(Some(&paths)); + assert!(config.background_path.is_none()); + } + + #[test] + fn load_config_reads_background_path() { + let dir = tempfile::tempdir().unwrap(); + let conf = dir.path().join("moonset.toml"); + fs::write(&conf, "background_path = \"/custom/wallpaper.jpg\"\n").unwrap(); + let paths = vec![conf]; + let config = load_config(Some(&paths)); + assert_eq!(config.background_path.as_deref(), Some("/custom/wallpaper.jpg")); + } + + #[test] + fn load_config_later_paths_override_earlier() { + let dir = tempfile::tempdir().unwrap(); + let conf1 = dir.path().join("first.toml"); + let conf2 = dir.path().join("second.toml"); + fs::write(&conf1, "background_path = \"/first.jpg\"\n").unwrap(); + fs::write(&conf2, "background_path = \"/second.jpg\"\n").unwrap(); + let paths = vec![conf1, conf2]; + let config = load_config(Some(&paths)); + assert_eq!(config.background_path.as_deref(), Some("/second.jpg")); + } + + #[test] + fn load_config_skips_missing_files() { + let dir = tempfile::tempdir().unwrap(); + let conf = dir.path().join("exists.toml"); + fs::write(&conf, "background_path = \"/exists.jpg\"\n").unwrap(); + let paths = vec![PathBuf::from("/nonexistent.toml"), conf]; + let config = load_config(Some(&paths)); + assert_eq!(config.background_path.as_deref(), Some("/exists.jpg")); + } + + #[test] + fn resolve_uses_config_path_when_file_exists() { + let dir = tempfile::tempdir().unwrap(); + let wallpaper = dir.path().join("custom.jpg"); + fs::write(&wallpaper, "fake").unwrap(); + let config = Config { + background_path: Some(wallpaper.to_str().unwrap().to_string()), + }; + assert_eq!( + resolve_background_path_with(&config, Path::new("/nonexistent")), + wallpaper + ); + } + + #[test] + fn resolve_ignores_config_path_when_file_missing() { + let config = Config { + background_path: Some("/nonexistent/wallpaper.jpg".to_string()), + }; + let result = resolve_background_path_with(&config, Path::new("/nonexistent")); + // Falls through to gresource fallback + assert!(result.to_str().unwrap().contains("moonset")); + } + + #[test] + fn resolve_uses_moonarch_wallpaper_as_second_fallback() { + let dir = tempfile::tempdir().unwrap(); + let moonarch_wp = dir.path().join("wallpaper.jpg"); + fs::write(&moonarch_wp, "fake").unwrap(); + let config = Config::default(); + assert_eq!(resolve_background_path_with(&config, &moonarch_wp), moonarch_wp); + } + + #[test] + fn resolve_uses_gresource_fallback_as_last_resort() { + let config = Config::default(); + let result = resolve_background_path_with(&config, Path::new("/nonexistent")); + assert!(result.to_str().unwrap().contains("wallpaper.jpg")); + } +} diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..fc77647 --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,271 @@ +// ABOUTME: Locale detection and string lookup for the power menu UI. +// ABOUTME: Reads system locale (LANG or /etc/locale.conf) and provides DE or EN strings. + +use std::env; +use std::fs; +use std::path::Path; + +const DEFAULT_LOCALE_CONF: &str = "/etc/locale.conf"; + +/// All user-visible strings for the power menu UI. +#[derive(Debug, Clone)] +pub struct Strings { + // Button labels + pub lock_label: &'static str, + pub logout_label: &'static str, + pub hibernate_label: &'static str, + pub reboot_label: &'static str, + pub shutdown_label: &'static str, + + // Confirmation prompts + pub logout_confirm: &'static str, + pub hibernate_confirm: &'static str, + pub reboot_confirm: &'static str, + pub shutdown_confirm: &'static str, + + // Confirmation buttons + pub confirm_yes: &'static str, + pub confirm_no: &'static str, + + // Error messages + pub lock_failed: &'static str, + pub logout_failed: &'static str, + pub hibernate_failed: &'static str, + pub reboot_failed: &'static str, + pub shutdown_failed: &'static str, +} + +const STRINGS_DE: Strings = Strings { + lock_label: "Sperren", + logout_label: "Abmelden", + hibernate_label: "Ruhezustand", + reboot_label: "Neustart", + shutdown_label: "Herunterfahren", + logout_confirm: "Wirklich abmelden?", + hibernate_confirm: "Wirklich in den Ruhezustand?", + reboot_confirm: "Wirklich neu starten?", + shutdown_confirm: "Wirklich herunterfahren?", + confirm_yes: "Ja", + confirm_no: "Abbrechen", + lock_failed: "Sperren fehlgeschlagen", + logout_failed: "Abmelden fehlgeschlagen", + hibernate_failed: "Ruhezustand fehlgeschlagen", + reboot_failed: "Neustart fehlgeschlagen", + shutdown_failed: "Herunterfahren fehlgeschlagen", +}; + +const STRINGS_EN: Strings = Strings { + lock_label: "Lock", + logout_label: "Log out", + hibernate_label: "Hibernate", + reboot_label: "Reboot", + shutdown_label: "Shut down", + logout_confirm: "Really log out?", + hibernate_confirm: "Really hibernate?", + reboot_confirm: "Really reboot?", + shutdown_confirm: "Really shut down?", + confirm_yes: "Yes", + confirm_no: "Cancel", + lock_failed: "Lock failed", + logout_failed: "Log out failed", + hibernate_failed: "Hibernate failed", + reboot_failed: "Reboot failed", + shutdown_failed: "Shutdown failed", +}; + +/// Extract the language prefix from a LANG value like "de_DE.UTF-8" → "de". +/// Returns "en" for empty, "C", or "POSIX" values. +fn parse_lang_prefix(lang: &str) -> String { + if lang.is_empty() || lang == "C" || lang == "POSIX" { + return "en".to_string(); + } + + let prefix = lang + .split('_') + .next() + .unwrap_or(lang) + .split('.') + .next() + .unwrap_or(lang) + .to_lowercase(); + + if prefix.chars().all(|c| c.is_ascii_alphabetic()) && !prefix.is_empty() { + prefix + } else { + "en".to_string() + } +} + +/// Read the LANG= value from a locale.conf file. +fn read_lang_from_conf(path: &Path) -> Option { + let content = fs::read_to_string(path).ok()?; + for line in content.lines() { + if let Some(value) = line.strip_prefix("LANG=") { + let value = value.trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + None +} + +/// Determine the system language from LANG env var or /etc/locale.conf. +pub fn detect_locale() -> String { + let lang = env::var("LANG") + .ok() + .filter(|s| !s.is_empty()) + .or_else(|| read_lang_from_conf(Path::new(DEFAULT_LOCALE_CONF))); + + match lang { + Some(l) => parse_lang_prefix(&l), + None => "en".to_string(), + } +} + +/// Return the string table for the given locale, defaulting to English. +pub fn load_strings(locale: Option<&str>) -> &'static Strings { + let locale = match locale { + Some(l) => l.to_string(), + None => detect_locale(), + }; + + match locale.as_str() { + "de" => &STRINGS_DE, + _ => &STRINGS_EN, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + // -- parse_lang_prefix tests (no env manipulation needed) -- + + #[test] + fn parse_german_locale() { + assert_eq!(parse_lang_prefix("de_DE.UTF-8"), "de"); + } + + #[test] + fn parse_english_locale() { + assert_eq!(parse_lang_prefix("en_US.UTF-8"), "en"); + } + + #[test] + fn parse_c_falls_back_to_english() { + assert_eq!(parse_lang_prefix("C"), "en"); + } + + #[test] + fn parse_posix_falls_back_to_english() { + assert_eq!(parse_lang_prefix("POSIX"), "en"); + } + + #[test] + fn parse_empty_falls_back_to_english() { + assert_eq!(parse_lang_prefix(""), "en"); + } + + #[test] + fn parse_unsupported_returns_prefix() { + assert_eq!(parse_lang_prefix("fr_FR.UTF-8"), "fr"); + } + + #[test] + fn parse_bare_language_code() { + assert_eq!(parse_lang_prefix("de"), "de"); + } + + // -- read_lang_from_conf tests -- + + #[test] + fn read_conf_extracts_lang() { + let dir = tempfile::tempdir().unwrap(); + let conf = dir.path().join("locale.conf"); + let mut f = fs::File::create(&conf).unwrap(); + writeln!(f, "LANG=de_DE.UTF-8").unwrap(); + assert_eq!(read_lang_from_conf(&conf), Some("de_DE.UTF-8".to_string())); + } + + #[test] + fn read_conf_returns_none_for_missing_file() { + assert_eq!(read_lang_from_conf(Path::new("/nonexistent/locale.conf")), None); + } + + #[test] + fn read_conf_returns_none_for_empty_lang() { + let dir = tempfile::tempdir().unwrap(); + let conf = dir.path().join("locale.conf"); + let mut f = fs::File::create(&conf).unwrap(); + writeln!(f, "LANG=").unwrap(); + assert_eq!(read_lang_from_conf(&conf), None); + } + + #[test] + fn read_conf_skips_non_lang_lines() { + let dir = tempfile::tempdir().unwrap(); + let conf = dir.path().join("locale.conf"); + let mut f = fs::File::create(&conf).unwrap(); + writeln!(f, "LC_ALL=en_US.UTF-8").unwrap(); + writeln!(f, "LANG=de_DE.UTF-8").unwrap(); + assert_eq!(read_lang_from_conf(&conf), Some("de_DE.UTF-8".to_string())); + } + + // -- load_strings tests -- + + #[test] + fn load_strings_german() { + let strings = load_strings(Some("de")); + assert_eq!(strings.lock_label, "Sperren"); + assert_eq!(strings.confirm_yes, "Ja"); + assert_eq!(strings.confirm_no, "Abbrechen"); + } + + #[test] + fn load_strings_english() { + let strings = load_strings(Some("en")); + assert_eq!(strings.lock_label, "Lock"); + assert_eq!(strings.confirm_yes, "Yes"); + } + + #[test] + fn load_strings_unknown_falls_back_to_english() { + let strings = load_strings(Some("fr")); + assert_eq!(strings.lock_label, "Lock"); + } + + #[test] + fn all_string_fields_nonempty() { + for locale in &["de", "en"] { + let s = load_strings(Some(locale)); + assert!(!s.lock_label.is_empty(), "{locale}: lock_label empty"); + assert!(!s.logout_label.is_empty(), "{locale}: logout_label empty"); + assert!(!s.hibernate_label.is_empty(), "{locale}: hibernate_label empty"); + assert!(!s.reboot_label.is_empty(), "{locale}: reboot_label empty"); + assert!(!s.shutdown_label.is_empty(), "{locale}: shutdown_label empty"); + assert!(!s.logout_confirm.is_empty(), "{locale}: logout_confirm empty"); + assert!(!s.hibernate_confirm.is_empty(), "{locale}: hibernate_confirm empty"); + assert!(!s.reboot_confirm.is_empty(), "{locale}: reboot_confirm empty"); + assert!(!s.shutdown_confirm.is_empty(), "{locale}: shutdown_confirm empty"); + assert!(!s.confirm_yes.is_empty(), "{locale}: confirm_yes empty"); + assert!(!s.confirm_no.is_empty(), "{locale}: confirm_no empty"); + assert!(!s.lock_failed.is_empty(), "{locale}: lock_failed empty"); + assert!(!s.logout_failed.is_empty(), "{locale}: logout_failed empty"); + assert!(!s.hibernate_failed.is_empty(), "{locale}: hibernate_failed empty"); + assert!(!s.reboot_failed.is_empty(), "{locale}: reboot_failed empty"); + assert!(!s.shutdown_failed.is_empty(), "{locale}: shutdown_failed empty"); + } + } + + #[test] + fn error_messages_contain_failed() { + let s = load_strings(Some("en")); + assert!(s.lock_failed.to_lowercase().contains("failed")); + assert!(s.logout_failed.to_lowercase().contains("failed")); + assert!(s.hibernate_failed.to_lowercase().contains("failed")); + assert!(s.reboot_failed.to_lowercase().contains("failed")); + assert!(s.shutdown_failed.to_lowercase().contains("failed")); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..4923c2d --- /dev/null +++ b/src/main.rs @@ -0,0 +1,90 @@ +// ABOUTME: Entry point for Moonset — Wayland session power menu. +// ABOUTME: Sets up GTK Application, Layer Shell, CSS, and multi-monitor windows. + +mod config; +mod i18n; +mod panel; +mod power; +mod users; + +use gdk4 as gdk; +use gtk4::prelude::*; +use gtk4::{self as gtk, gio}; +use gtk4_layer_shell::LayerShell; + +fn load_css(display: &gdk::Display) { + let css_provider = gtk::CssProvider::new(); + css_provider.load_from_resource("/dev/moonarch/moonset/style.css"); + gtk::style_context_add_provider_for_display( + display, + &css_provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); +} + +fn setup_layer_shell( + window: >k::ApplicationWindow, + keyboard: bool, + layer: gtk4_layer_shell::Layer, +) { + window.init_layer_shell(); + window.set_layer(layer); + window.set_exclusive_zone(-1); + if keyboard { + window.set_keyboard_mode(gtk4_layer_shell::KeyboardMode::Exclusive); + } + // Anchor to all edges for fullscreen + window.set_anchor(gtk4_layer_shell::Edge::Top, true); + window.set_anchor(gtk4_layer_shell::Edge::Bottom, true); + window.set_anchor(gtk4_layer_shell::Edge::Left, true); + window.set_anchor(gtk4_layer_shell::Edge::Right, true); +} + +fn activate(app: >k::Application) { + let display = match gdk::Display::default() { + Some(d) => d, + None => { + log::error!("No display available — cannot start power menu UI"); + return; + } + }; + + load_css(&display); + + // Resolve wallpaper once, share across all windows + let config = config::load_config(None); + let bg_path = config::resolve_background_path(&config); + + // Panel on focused output (no set_monitor → compositor picks focused) + let panel = panel::create_panel_window(&bg_path, app); + setup_layer_shell(&panel, true, gtk4_layer_shell::Layer::Overlay); + panel.present(); + + // Wallpaper on all monitors + let monitors = display.monitors(); + for i in 0..monitors.n_items() { + if let Some(monitor) = monitors.item(i).and_then(|obj| obj.downcast::().ok()) { + let wallpaper = panel::create_wallpaper_window(&bg_path, app); + setup_layer_shell(&wallpaper, false, gtk4_layer_shell::Layer::Top); + wallpaper.set_monitor(Some(&monitor)); + wallpaper.present(); + } + } +} + +fn main() { + env_logger::Builder::from_default_env() + .filter_level(log::LevelFilter::Info) + .init(); + log::info!("Moonset starting"); + + // Register compiled GResources + gio::resources_register_include!("moonset.gresource").expect("Failed to register resources"); + + let app = gtk::Application::builder() + .application_id("dev.moonarch.moonset") + .build(); + + app.connect_activate(activate); + app.run(); +} diff --git a/src/panel.rs b/src/panel.rs new file mode 100644 index 0000000..f32aef3 --- /dev/null +++ b/src/panel.rs @@ -0,0 +1,597 @@ +// ABOUTME: UI module for the power menu — action buttons, confirmation flow, wallpaper windows. +// ABOUTME: Defines PanelWindow (primary monitor) and WallpaperWindow (secondary monitors). + +use gdk4 as gdk; +use gdk_pixbuf::Pixbuf; +use glib::clone; +use gtk4::prelude::*; +use gtk4::{self as gtk, gio}; +use std::cell::RefCell; +use std::path::Path; +use std::rc::Rc; + +use crate::i18n::{load_strings, Strings}; +use crate::power::{self, PowerError}; +use crate::users; + +const AVATAR_SIZE: i32 = 128; + +/// Definition for a single power action button. +#[derive(Clone)] +pub struct ActionDef { + pub name: &'static str, + pub icon_name: &'static str, + pub needs_confirm: bool, + pub action_fn: fn() -> Result<(), PowerError>, + pub label_attr: fn(&Strings) -> &'static str, + pub error_attr: fn(&Strings) -> &'static str, + pub confirm_attr: Option &'static str>, +} + +/// All 5 power action definitions. +pub fn action_definitions() -> Vec { + vec![ + ActionDef { + name: "lock", + icon_name: "system-lock-screen-symbolic", + needs_confirm: false, + action_fn: power::lock, + label_attr: |s| s.lock_label, + error_attr: |s| s.lock_failed, + confirm_attr: None, + }, + ActionDef { + name: "logout", + icon_name: "system-log-out-symbolic", + needs_confirm: true, + action_fn: power::logout, + label_attr: |s| s.logout_label, + error_attr: |s| s.logout_failed, + confirm_attr: Some(|s| s.logout_confirm), + }, + ActionDef { + name: "hibernate", + icon_name: "system-hibernate-symbolic", + needs_confirm: true, + action_fn: power::hibernate, + label_attr: |s| s.hibernate_label, + error_attr: |s| s.hibernate_failed, + confirm_attr: Some(|s| s.hibernate_confirm), + }, + ActionDef { + name: "reboot", + icon_name: "system-reboot-symbolic", + needs_confirm: true, + action_fn: power::reboot, + label_attr: |s| s.reboot_label, + error_attr: |s| s.reboot_failed, + confirm_attr: Some(|s| s.reboot_confirm), + }, + ActionDef { + name: "shutdown", + icon_name: "system-shutdown-symbolic", + needs_confirm: true, + action_fn: power::shutdown, + label_attr: |s| s.shutdown_label, + error_attr: |s| s.shutdown_failed, + confirm_attr: Some(|s| s.shutdown_confirm), + }, + ] +} + +/// Create a wallpaper-only window for secondary monitors. +pub fn create_wallpaper_window(bg_path: &Path, app: >k::Application) -> gtk::ApplicationWindow { + let window = gtk::ApplicationWindow::builder() + .application(app) + .build(); + window.add_css_class("wallpaper"); + + let background = create_background_picture(bg_path); + window.set_child(Some(&background)); + + // Fade-in on map + window.connect_map(|w| { + glib::idle_add_local_once(clone!( + #[weak] + w, + move || { + w.add_css_class("visible"); + } + )); + }); + + window +} + +/// Create the main panel window with action buttons and confirm flow. +pub fn create_panel_window(bg_path: &Path, app: >k::Application) -> gtk::ApplicationWindow { + let window = gtk::ApplicationWindow::builder() + .application(app) + .build(); + window.add_css_class("panel"); + + let strings = load_strings(None); + let user = users::get_current_user().unwrap_or_else(|| users::User { + username: "user".to_string(), + display_name: "User".to_string(), + home: dirs::home_dir().unwrap_or_default(), + uid: 0, + }); + + // State for confirm box + let confirm_box: Rc>> = Rc::new(RefCell::new(None)); + + // Main overlay for background + centered content + let overlay = gtk::Overlay::new(); + window.set_child(Some(&overlay)); + + // Background wallpaper + let background = create_background_picture(bg_path); + overlay.set_child(Some(&background)); + + // Click on background dismisses the menu + let click_controller = gtk::GestureClick::new(); + click_controller.connect_released(clone!( + #[weak] + app, + move |_, _, _, _| { + app.quit(); + } + )); + background.add_controller(click_controller); + + // Centered content box + let content_box = gtk::Box::new(gtk::Orientation::Vertical, 0); + content_box.set_halign(gtk::Align::Center); + content_box.set_valign(gtk::Align::Center); + overlay.add_overlay(&content_box); + + // Avatar + let avatar_frame = gtk::Box::new(gtk::Orientation::Horizontal, 0); + avatar_frame.set_size_request(AVATAR_SIZE, AVATAR_SIZE); + avatar_frame.set_halign(gtk::Align::Center); + avatar_frame.set_overflow(gtk::Overflow::Hidden); + avatar_frame.add_css_class("avatar"); + let avatar_image = gtk::Image::new(); + avatar_image.set_pixel_size(AVATAR_SIZE); + avatar_frame.append(&avatar_image); + content_box.append(&avatar_frame); + + // Load avatar + let avatar_path = users::get_avatar_path(&user.home, Some(&user.username)); + if let Some(path) = avatar_path { + set_avatar_from_file(&avatar_image, &path); + } else { + set_default_avatar(&avatar_image, &window); + } + + // Username label + let username_label = gtk::Label::new(Some(&user.display_name)); + username_label.add_css_class("username-label"); + content_box.append(&username_label); + + // Action buttons row + let button_box = gtk::Box::new(gtk::Orientation::Horizontal, 24); + button_box.set_halign(gtk::Align::Center); + content_box.append(&button_box); + + // Confirmation area (below buttons) + let confirm_area = gtk::Box::new(gtk::Orientation::Vertical, 0); + confirm_area.set_halign(gtk::Align::Center); + confirm_area.set_margin_top(24); + content_box.append(&confirm_area); + + // Error label + let error_label = gtk::Label::new(None); + error_label.add_css_class("error-label"); + error_label.set_visible(false); + error_label.set_margin_top(16); + content_box.append(&error_label); + + // Create action buttons + for action_def in action_definitions() { + let button = create_action_button( + &action_def, + strings, + app, + &confirm_area, + &confirm_box, + &error_label, + ); + button_box.append(&button); + } + + // Keyboard handling — Escape dismisses + let key_controller = gtk::EventControllerKey::new(); + key_controller.connect_key_pressed(clone!( + #[weak] + app, + #[upgrade_or] + glib::Propagation::Proceed, + move |_, keyval, _, _| { + if keyval == gdk::Key::Escape { + app.quit(); + glib::Propagation::Stop + } else { + glib::Propagation::Proceed + } + } + )); + window.add_controller(key_controller); + + // Focus first button + fade-in on map + let button_box_clone = button_box.clone(); + window.connect_map(move |w| { + let w = w.clone(); + let bb = button_box_clone.clone(); + glib::idle_add_local_once(move || { + w.add_css_class("visible"); + glib::idle_add_local_once(move || { + if let Some(first) = bb.first_child() { + first.grab_focus(); + } + }); + }); + }); + + window +} + +/// Create a Picture widget for the wallpaper background. +fn create_background_picture(bg_path: &Path) -> gtk::Picture { + let background = if bg_path.starts_with("/dev/moonarch/moonset") { + gtk::Picture::for_resource(bg_path.to_str().unwrap_or("")) + } else { + gtk::Picture::for_filename(bg_path.to_str().unwrap_or("")) + }; + background.set_content_fit(gtk::ContentFit::Cover); + background.set_hexpand(true); + background.set_vexpand(true); + background +} + +/// Create a single action button with icon and label. +fn create_action_button( + action_def: &ActionDef, + strings: &'static Strings, + app: >k::Application, + confirm_area: >k::Box, + confirm_box: &Rc>>, + error_label: >k::Label, +) -> gtk::Button { + let button_content = gtk::Box::new(gtk::Orientation::Vertical, 4); + button_content.set_halign(gtk::Align::Center); + button_content.set_valign(gtk::Align::Center); + + // Look up the 22px icon variant, render at 64px (matches moonlock) + let icon = load_scaled_icon(action_def.icon_name); + icon.add_css_class("action-icon"); + button_content.append(&icon); + + let label_text = (action_def.label_attr)(strings); + let label = gtk::Label::new(Some(label_text)); + label.add_css_class("action-label"); + button_content.append(&label); + + let button = gtk::Button::new(); + button.set_child(Some(&button_content)); + button.add_css_class("action-button"); + + let action_def = action_def.clone(); + button.connect_clicked(clone!( + #[weak] + app, + #[weak] + confirm_area, + #[strong] + confirm_box, + #[weak] + error_label, + move |_| { + on_action_clicked( + &action_def, + strings, + &app, + &confirm_area, + &confirm_box, + &error_label, + ); + } + )); + + button +} + +/// Load a symbolic icon at 22px and scale to 64px via GdkPixbuf. +fn load_scaled_icon(icon_name: &str) -> gtk::Image { + let display = gdk::Display::default().unwrap(); + let theme = gtk::IconTheme::for_display(&display); + let icon_paintable = theme.lookup_icon( + icon_name, + &[], + 22, + 1, + gtk::TextDirection::None, + gtk::IconLookupFlags::FORCE_SYMBOLIC, + ); + + let icon = gtk::Image::new(); + if let Some(file) = icon_paintable.file() { + if let Some(path) = file.path() { + if let Ok(pixbuf) = + Pixbuf::from_file_at_scale(path.to_str().unwrap_or(""), 64, 64, true) + { + let texture = gdk::Texture::for_pixbuf(&pixbuf); + icon.set_paintable(Some(&texture)); + return icon; + } + } + } + + // Fallback: use icon name directly + icon.set_icon_name(Some(icon_name)); + icon.set_pixel_size(64); + icon +} + +/// Handle an action button click. +fn on_action_clicked( + action_def: &ActionDef, + strings: &'static Strings, + app: >k::Application, + confirm_area: >k::Box, + confirm_box: &Rc>>, + error_label: >k::Label, +) { + dismiss_confirm(confirm_area, confirm_box); + error_label.set_visible(false); + + if !action_def.needs_confirm { + execute_action(action_def, strings, app, confirm_area, confirm_box, error_label); + return; + } + + show_confirm(action_def, strings, app, confirm_area, confirm_box, error_label); +} + +/// Show inline confirmation below the action buttons. +fn show_confirm( + action_def: &ActionDef, + strings: &'static Strings, + app: >k::Application, + confirm_area: >k::Box, + confirm_box: &Rc>>, + error_label: >k::Label, +) { + let new_box = gtk::Box::new(gtk::Orientation::Vertical, 8); + new_box.set_halign(gtk::Align::Center); + new_box.add_css_class("confirm-box"); + + if let Some(prompt_fn) = action_def.confirm_attr { + let prompt_text = prompt_fn(strings); + let confirm_label = gtk::Label::new(Some(prompt_text)); + confirm_label.add_css_class("confirm-label"); + new_box.append(&confirm_label); + } + + let button_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + button_row.set_halign(gtk::Align::Center); + + let yes_btn = gtk::Button::with_label(strings.confirm_yes); + yes_btn.add_css_class("confirm-yes"); + let action_def_clone = action_def.clone(); + yes_btn.connect_clicked(clone!( + #[weak] + app, + #[weak] + confirm_area, + #[strong] + confirm_box, + #[weak] + error_label, + move |_| { + execute_action( + &action_def_clone, + strings, + &app, + &confirm_area, + &confirm_box, + &error_label, + ); + } + )); + button_row.append(&yes_btn); + + let no_btn = gtk::Button::with_label(strings.confirm_no); + no_btn.add_css_class("confirm-no"); + no_btn.connect_clicked(clone!( + #[weak] + confirm_area, + #[strong] + confirm_box, + move |_| { + dismiss_confirm(&confirm_area, &confirm_box); + } + )); + button_row.append(&no_btn); + + new_box.append(&button_row); + confirm_area.append(&new_box); + + *confirm_box.borrow_mut() = Some(new_box); + + // Focus the "No" button — safe default for keyboard navigation + no_btn.grab_focus(); +} + +/// Remove the confirmation prompt. +fn dismiss_confirm(confirm_area: >k::Box, confirm_box: &Rc>>) { + if let Some(box_widget) = confirm_box.borrow_mut().take() { + confirm_area.remove(&box_widget); + } +} + +/// Execute a power action in a background thread. +fn execute_action( + action_def: &ActionDef, + strings: &'static Strings, + app: >k::Application, + confirm_area: >k::Box, + confirm_box: &Rc>>, + error_label: >k::Label, +) { + dismiss_confirm(confirm_area, confirm_box); + + let action_fn = action_def.action_fn; + let action_name = action_def.name; + let error_message = (action_def.error_attr)(strings).to_string(); + + // Use glib::spawn_future_local + gio::spawn_blocking to avoid Send issues + // with GTK objects. The blocking closure runs on a thread pool, the result + // is handled back on the main thread. + glib::spawn_future_local(clone!( + #[weak] + app, + #[weak] + error_label, + async move { + let result = gio::spawn_blocking(move || action_fn()).await; + + match result { + Ok(Ok(())) => { + // Lock action: quit after successful execution + if action_name == "lock" { + app.quit(); + } + } + Ok(Err(e)) => { + log::error!("Power action '{}' failed: {}", action_name, e); + error_label.set_text(&error_message); + error_label.set_visible(true); + } + Err(_) => { + log::error!("Power action '{}' panicked", action_name); + error_label.set_text(&error_message); + error_label.set_visible(true); + } + } + } + )); +} + +/// Load an image file and set it as the avatar. +fn set_avatar_from_file(image: >k::Image, path: &Path) { + match Pixbuf::from_file_at_scale(path.to_str().unwrap_or(""), AVATAR_SIZE, AVATAR_SIZE, true) { + Ok(pixbuf) => { + let texture = gdk::Texture::for_pixbuf(&pixbuf); + image.set_paintable(Some(&texture)); + } + Err(_) => { + image.set_icon_name(Some("avatar-default-symbolic")); + } + } +} + +/// Load the default avatar SVG from GResources, tinted with the foreground color. +fn set_default_avatar(image: >k::Image, window: >k::ApplicationWindow) { + let resource_path = users::get_default_avatar_path(); + + // Try loading from GResource + if let Ok(bytes) = gio::resources_lookup_data(&resource_path, gio::ResourceLookupFlags::NONE) { + let svg_text = String::from_utf8_lossy(&bytes); + + // Get foreground color from widget's style context + let rgba = window.color(); + let fg_color = format!( + "#{:02x}{:02x}{:02x}", + (rgba.red() * 255.0) as u8, + (rgba.green() * 255.0) as u8, + (rgba.blue() * 255.0) as u8, + ); + let tinted = svg_text.replace("#PLACEHOLDER", &fg_color); + let svg_bytes = tinted.as_bytes(); + + if let Ok(loader) = gdk_pixbuf::PixbufLoader::with_type("svg") { + loader.set_size(AVATAR_SIZE, AVATAR_SIZE); + if loader.write(svg_bytes).is_ok() { + let _ = loader.close(); + if let Some(pixbuf) = loader.pixbuf() { + let texture = gdk::Texture::for_pixbuf(&pixbuf); + image.set_paintable(Some(&texture)); + return; + } + } + } + } + + // Fallback + image.set_icon_name(Some("avatar-default-symbolic")); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn action_definitions_count() { + let defs = action_definitions(); + assert_eq!(defs.len(), 5); + } + + #[test] + fn action_definitions_names() { + let defs = action_definitions(); + let names: Vec<&str> = defs.iter().map(|d| d.name).collect(); + assert_eq!(names, vec!["lock", "logout", "hibernate", "reboot", "shutdown"]); + } + + #[test] + fn action_definitions_icons() { + let defs = action_definitions(); + assert_eq!(defs[0].icon_name, "system-lock-screen-symbolic"); + assert_eq!(defs[1].icon_name, "system-log-out-symbolic"); + assert_eq!(defs[2].icon_name, "system-hibernate-symbolic"); + assert_eq!(defs[3].icon_name, "system-reboot-symbolic"); + assert_eq!(defs[4].icon_name, "system-shutdown-symbolic"); + } + + #[test] + fn lock_does_not_need_confirm() { + let defs = action_definitions(); + assert!(!defs[0].needs_confirm); + assert!(defs[0].confirm_attr.is_none()); + } + + #[test] + fn other_actions_need_confirm() { + let defs = action_definitions(); + for def in &defs[1..] { + assert!(def.needs_confirm, "{} should need confirm", def.name); + assert!(def.confirm_attr.is_some(), "{} should have confirm_attr", def.name); + } + } + + #[test] + fn action_labels_from_strings() { + let strings = load_strings(Some("en")); + let defs = action_definitions(); + assert_eq!((defs[0].label_attr)(strings), "Lock"); + assert_eq!((defs[4].label_attr)(strings), "Shut down"); + } + + #[test] + fn action_error_messages_from_strings() { + let strings = load_strings(Some("en")); + let defs = action_definitions(); + assert_eq!((defs[0].error_attr)(strings), "Lock failed"); + assert_eq!((defs[4].error_attr)(strings), "Shutdown failed"); + } + + #[test] + fn action_confirm_prompts_from_strings() { + let strings = load_strings(Some("de")); + let defs = action_definitions(); + let confirm_fn = defs[1].confirm_attr.unwrap(); + assert_eq!(confirm_fn(strings), "Wirklich abmelden?"); + } +} diff --git a/src/power.rs b/src/power.rs new file mode 100644 index 0000000..f147959 --- /dev/null +++ b/src/power.rs @@ -0,0 +1,133 @@ +// ABOUTME: Power actions — lock, logout, hibernate, reboot, shutdown. +// ABOUTME: Wrappers around system commands for the session power menu. + +use std::fmt; +use std::process::Command; +use std::time::Duration; + +#[allow(dead_code)] +const POWER_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug)] +pub enum PowerError { + CommandFailed { action: &'static str, message: String }, + #[allow(dead_code)] + Timeout { action: &'static str }, +} + +impl fmt::Display for PowerError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PowerError::CommandFailed { action, message } => { + write!(f, "{action} failed: {message}") + } + PowerError::Timeout { action } => { + write!(f, "{action} timed out") + } + } + } +} + +impl std::error::Error for PowerError {} + +/// Run a command with timeout and return a PowerError on failure. +fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(), PowerError> { + let child = Command::new(program) + .args(args) + .spawn() + .map_err(|e| PowerError::CommandFailed { + action, + message: e.to_string(), + })?; + + let output = child + .wait_with_output() + .map_err(|e| PowerError::CommandFailed { + action, + message: e.to_string(), + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(PowerError::CommandFailed { + action, + message: format!("exit code {}: {}", output.status, stderr.trim()), + }); + } + + Ok(()) +} + +/// Lock the current session by launching moonlock. +pub fn lock() -> Result<(), PowerError> { + run_command("lock", "moonlock", &[]) +} + +/// Quit the Niri compositor (logout). +pub fn logout() -> Result<(), PowerError> { + run_command("logout", "niri", &["msg", "action", "quit"]) +} + +/// Hibernate the system via systemctl. +pub fn hibernate() -> Result<(), PowerError> { + run_command("hibernate", "systemctl", &["hibernate"]) +} + +/// Reboot the system via loginctl. +pub fn reboot() -> Result<(), PowerError> { + run_command("reboot", "loginctl", &["reboot"]) +} + +/// Shut down the system via loginctl. +pub fn shutdown() -> Result<(), PowerError> { + run_command("shutdown", "loginctl", &["poweroff"]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn power_error_command_failed_display() { + let err = PowerError::CommandFailed { + action: "lock", + message: "No such file or directory".to_string(), + }; + assert_eq!(err.to_string(), "lock failed: No such file or directory"); + } + + #[test] + fn power_error_timeout_display() { + let err = PowerError::Timeout { action: "shutdown" }; + assert_eq!(err.to_string(), "shutdown timed out"); + } + + #[test] + fn run_command_returns_error_for_missing_binary() { + let result = run_command("test", "nonexistent-binary-xyz", &[]); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, PowerError::CommandFailed { action: "test", .. })); + } + + #[test] + fn run_command_returns_error_on_nonzero_exit() { + let result = run_command("test", "false", &[]); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, PowerError::CommandFailed { action: "test", .. })); + } + + #[test] + fn run_command_succeeds_for_true() { + let result = run_command("test", "true", &[]); + assert!(result.is_ok()); + } + + #[test] + fn run_command_passes_args() { + // "echo" with args should succeed + let result = run_command("test", "echo", &["hello", "world"]); + assert!(result.is_ok()); + } +} diff --git a/src/users.rs b/src/users.rs new file mode 100644 index 0000000..7451907 --- /dev/null +++ b/src/users.rs @@ -0,0 +1,141 @@ +// ABOUTME: Current user detection and avatar loading for the power menu. +// ABOUTME: Retrieves user info from the system (pwd, AccountsService, ~/.face). + +use nix::unistd::{getuid, User as NixUser}; +use std::path::{Path, PathBuf}; + +const DEFAULT_ACCOUNTSSERVICE_DIR: &str = "/var/lib/AccountsService/icons"; +const GRESOURCE_PREFIX: &str = "/dev/moonarch/moonset"; + +/// Represents the current user for the power menu. +#[derive(Debug, Clone)] +pub struct User { + pub username: String, + pub display_name: String, + pub home: PathBuf, + pub uid: u32, +} + +/// Get the currently logged-in user's info from the system. +pub fn get_current_user() -> Option { + let uid = getuid(); + let nix_user = NixUser::from_uid(uid).ok()??; + + let gecos = nix_user.gecos.to_str().unwrap_or("").to_string(); + // GECOS field may contain comma-separated values; first field is the full name + let display_name = if !gecos.is_empty() { + let first = gecos.split(',').next().unwrap_or(""); + if first.is_empty() { + nix_user.name.clone() + } else { + first.to_string() + } + } else { + nix_user.name.clone() + }; + + Some(User { + username: nix_user.name, + display_name, + home: nix_user.dir, + uid: uid.as_raw(), + }) +} + +/// Find the user's avatar image, checking ~/.face then AccountsService. +pub fn get_avatar_path(home: &Path, username: Option<&str>) -> Option { + get_avatar_path_with(home, username, Path::new(DEFAULT_ACCOUNTSSERVICE_DIR)) +} + +/// Find avatar with configurable AccountsService dir (for testing). +pub fn get_avatar_path_with( + home: &Path, + username: Option<&str>, + accountsservice_dir: &Path, +) -> Option { + // ~/.face takes priority + let face = home.join(".face"); + if face.exists() { + return Some(face); + } + + // AccountsService icon + if let Some(name) = username { + if accountsservice_dir.exists() { + let icon = accountsservice_dir.join(name); + if icon.exists() { + return Some(icon); + } + } + } + + None +} + +/// Return the GResource path to the default avatar SVG. +pub fn get_default_avatar_path() -> String { + format!("{GRESOURCE_PREFIX}/default-avatar.svg") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn get_current_user_returns_some() { + let user = get_current_user(); + assert!(user.is_some()); + let user = user.unwrap(); + assert!(!user.username.is_empty()); + assert!(!user.display_name.is_empty()); + assert!(user.home.exists()); + } + + #[test] + fn returns_face_file_if_exists() { + let dir = tempfile::tempdir().unwrap(); + let face = dir.path().join(".face"); + fs::write(&face, "fake image").unwrap(); + let path = get_avatar_path_with(dir.path(), None, Path::new("/nonexistent")); + assert_eq!(path, Some(face)); + } + + #[test] + fn returns_accountsservice_icon_if_exists() { + let dir = tempfile::tempdir().unwrap(); + let icons_dir = dir.path().join("icons"); + fs::create_dir(&icons_dir).unwrap(); + let icon = icons_dir.join("testuser"); + fs::write(&icon, "fake image").unwrap(); + let path = get_avatar_path_with(dir.path(), Some("testuser"), &icons_dir); + assert_eq!(path, Some(icon)); + } + + #[test] + fn face_file_takes_priority_over_accountsservice() { + let dir = tempfile::tempdir().unwrap(); + let face = dir.path().join(".face"); + fs::write(&face, "fake image").unwrap(); + let icons_dir = dir.path().join("icons"); + fs::create_dir(&icons_dir).unwrap(); + let icon = icons_dir.join("testuser"); + fs::write(&icon, "fake image").unwrap(); + let path = get_avatar_path_with(dir.path(), Some("testuser"), &icons_dir); + assert_eq!(path, Some(face)); + } + + #[test] + fn returns_none_when_no_avatar() { + let dir = tempfile::tempdir().unwrap(); + let path = get_avatar_path_with(dir.path(), None, Path::new("/nonexistent")); + assert!(path.is_none()); + } + + #[test] + fn default_avatar_path_is_gresource() { + let path = get_default_avatar_path(); + assert!(path.contains("default-avatar.svg")); + assert!(path.starts_with("/dev/moonarch/moonset")); + } +}