commit bc2fa655586da6f1005602204be5b22e7a4b565c Author: claude Date: Sat Aug 29 23:24:29 2026 +0200 waydock v0.1: panel, pins, fönstergrupper, menyer, settings-GUI, live-config M0–M5 ur doc/plan.md: layer-shell-dock per skärm med transparent #222222-ö, pins + körande appar (gruppering, badges, indikatorer), klick/skroll/mittklick, konfigurerbar högerklicksmeny, tomyta-meny, settings-GUI som redigerar JSON-configen live, filewatcher, Wayfire-IPC-stubbar (scale, flytta-till-skärm), CI + release-flöde. Co-Authored-By: Claude Fable 5 diff --git a/.gitea/workflows/check.yaml b/.gitea/workflows/check.yaml new file mode 100644 index 0000000..6b6f0aa --- /dev/null +++ b/.gitea/workflows/check.yaml @@ -0,0 +1,38 @@ +name: check + +# Kompilerings- och lintvakt på Pi5-runnern (arm64). Bygger inga +# artefakter — släpp byggs lokalt på brasse-linux01 via ./release.sh. +on: + push: + branches: [master] + paths: + - "src/**" + - "Cargo.toml" + - "Cargo.lock" + - ".gitea/workflows/check.yaml" + workflow_dispatch: {} + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install toolchain + GTK4 deps + run: | + apt-get update -qq + apt-get install -y -qq curl build-essential pkg-config \ + libgtk-4-dev libgtk4-layer-shell-dev + curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal \ + --component clippy + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + - name: cargo check + run: cargo check --locked + + - name: cargo clippy + run: cargo clippy --locked -- -D warnings + + - name: cargo test + run: cargo test --locked diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9a8a966 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +build/ +target/ +*.log +.env diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..aa463a3 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug waydock", + "type": "lldb", + "request": "launch", + "program": "${workspaceFolder}/target/debug/waydock", + "args": ["--log", "debug"], + "cwd": "${workspaceFolder}", + "preLaunchTask": "build-debug" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..7f790d9 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,37 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "type": "shell", + "command": "cargo build --release && mkdir -p build && cp target/release/waydock build/waydock", + "group": { "kind": "build", "isDefault": true }, + "problemMatcher": ["$rustc"] + }, + { + "label": "build-debug", + "type": "shell", + "command": "cargo build", + "problemMatcher": ["$rustc"] + }, + { + "label": "run (debug-logg)", + "type": "shell", + "command": "WAYDOCK_LOG=debug cargo run", + "problemMatcher": ["$rustc"] + }, + { + "label": "test", + "type": "shell", + "command": "cargo test", + "group": { "kind": "test", "isDefault": true }, + "problemMatcher": ["$rustc"] + }, + { + "label": "lint (clippy)", + "type": "shell", + "command": "cargo clippy -- -D warnings", + "problemMatcher": ["$rustc"] + } + ] +} diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..1f48ff5 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1319 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "cairo-rs" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e3bd0f4e25afa9cabc157908d14eeef9067d6448c49414d17b3fb55f0eadd0" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "059cc746549898cbfd9a47754288e5a958756650ef4652bbb6c5f71a6bda4f8b" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-expr" +version = "0.20.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4ece8474b5f766c63426647e7b4b316b67431ade1036a8313cee24a03ae917" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[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 = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[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-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd242894c084f4beed508a56952750bce3e96e85eb68fdc153637daa163e10c" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b34f3b580c988bd217e9543a2de59823fafae369d1a055555e5f95a8b130b96" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850c9d9c1aecd1a3eb14fadc1cdb0ac0a2298037e116264c7473e1740a32d60" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "gl", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f6eb95798e2b46f279cf59005daf297d5b69555428f185650d71974a910473a" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gio" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e27e276e7b6b8d50f6376ee7769a71133e80d093bdc363bd0af71664228b831" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e93a7e56fc89e84aea9a52cfc9436816a4b363b030260b699950ff1336c83" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys 0.59.0", +] + +[[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.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683" +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-macros" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8084af62f09475a3f529b1629c10c429d7600ee1398ae12dd3bf175d74e7145" +dependencies = [ + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ab79e1ed126803a8fb827e3de0e2ff95191912b8db65cee467edb56fc4cc215" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9aca94bb73989e3cfdbf8f2e0f1f6da04db4d291c431f444838925c4c63eda" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "graphene-rs" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b86dfad7d14251c9acaf1de63bc8754b7e3b4e5b16777b6f5a748208fe9519b" +dependencies = [ + "glib", + "graphene-sys", + "libc", +] + +[[package]] +name = "graphene-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df583a85ba2d5e15e1797e40d666057b28bc2f60a67c9c24145e6db2cc3861ea" +dependencies = [ + "glib-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gsk4" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f5e72f931c8c9f65fbfc89fe0ddc7746f147f822f127a53a9854666ac1f855" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "755059de55fa6f85a46bde8caf03e2184c96bfda1f6206163c72fb0ea12436dc" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk4" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f274dd0102c21c47bbfa8ebcb92d0464fab794a22fad6c3f3d5f165139a326d6" +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.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aec4fd3226bb6aa8dda5370142e14a4d15f00bba99bfb355b6ef7bb49d100758" +dependencies = [ + "bitflags", + "gdk4", + "glib", + "glib-sys", + "gtk4", + "gtk4-layer-shell-sys", + "libc", +] + +[[package]] +name = "gtk4-layer-shell-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3057dc117db2d664a9b45f1956568701914e80cf9f2c8cef0a755af4c1c8105" +dependencies = [ + "gdk4-sys", + "glib-sys", + "gtk4-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk4-macros" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ed1786c4703dd196baf7e103525ce0cf579b3a63a0570fe653b7ee6bac33999" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gtk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41e03b01e54d77c310e1d98647d73f996d04b2f29b9121fe493ea525a7ec03d6" +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.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "pango" +version = "0.20.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6576b311f6df659397043a5fa8a021da8f72e34af180b44f7d57348de691ab5c" +dependencies = [ + "gio", + "glib", + "libc", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186909673fc09be354555c302c0b3dcf753cd9fa08dcb8077fa663c80fb243fa" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "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 = "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 0.61.2", +] + +[[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 = "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.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simplelog" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16257adbfaef1ee58b1363bdc0664c9b8e1e30aed86049635fb5f147d065a9c0" +dependencies = [ + "log", + "termcolor", + "time", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[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 = "waydock" +version = "0.1.0" +dependencies = [ + "async-channel", + "gtk4", + "gtk4-layer-shell", + "log", + "notify", + "serde", + "serde_json", + "simplelog", + "wayland-client", + "wayland-protocols-wlr", +] + +[[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", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + +[[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.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..37802cd --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "waydock" +version = "0.1.0" +edition = "2021" +description = "Modern, resurssnål dock för Wayfire — transparent, JSON-konfigurerad, per-skärm" +license = "MIT" + +[dependencies] +gtk4 = { version = "0.9", features = ["v4_12"] } +gtk4-layer-shell = "0.5" +wayland-client = "0.31" +wayland-protocols-wlr = { version = "0.3", features = ["client"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +notify = "8" +async-channel = "2" +log = "0.4" +simplelog = "0.12" + +[profile.release] +lto = "thin" +opt-level = 3 +strip = true +codegen-units = 1 +panic = "abort" diff --git a/README.md b/README.md new file mode 100644 index 0000000..7dcad78 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# waydock + +Modern, resurssnål och genomkonfigurerbar dock för +[Wayfire](https://wayfire.org/) — transparent Plasma 6-känsla, byggd i +Rust/GTK4 som **en enda binär**. Pinnade appar, körande fönster med +gruppering, per-skärm-kontext, konfigurerbara menyer, settings-GUI och +live-omladdning av JSON-configen. + +- Config: `~/.config/waydock/config.json` (skapas automatiskt första starten) +- Djupare docs: [`doc/plan.md`](doc/plan.md), + [`doc/architecture.md`](doc/architecture.md), + [`doc/usage.md`](doc/usage.md) — samt [wikin](https://gitea.brasse-pc.eu/brasse/waydock/wiki) + +## Beroenden (Arch/Garuda) + +```bash +sudo pacman -S --needed gtk4 gtk4-layer-shell +rustup default stable # Rust ≥ 1.85 +``` + +Kör kräver en wlroots-compositor med `zwlr_foreign_toplevel_manager_v1` +(Wayfire, Sway, …). Wayfire IPC-funktionerna (översikt, dodge) kräver +`ipc`-pluginen i wayfire.ini. + +## Bygga & köra + +```bash +git clone https://gitea.brasse-pc.eu/brasse/waydock.git +cd waydock +cargo build --release # binär: target/release/waydock +./target/release/waydock # eller: cargo run +``` + +I VS Code: **Ctrl+Shift+B** kör build-tasken (lägger binären i `build/`). + +Loggning: `waydock --log debug` eller env `WAYDOCK_LOG=debug`, +`--log-file ~/.cache/waydock.log` för fil. + +## Installera (release-binär) + +```bash +curl -fsSL https://gitea.brasse-pc.eu/brasse/waydock/raw/branch/master/install.sh | sh +``` + +## Hur det funkar (kort) + +En GTK4-process ritar ett layer-shell-fönster per skärm. En egen +Wayland-anslutning lyssnar på `zwlr_foreign_toplevel_manager_v1` för +fönsterlistan (aktivera/minimera/stäng går samma väg). `.desktop`-filer +och ikoner slås upp via gio/ikontemat. All styling genereras som CSS +från JSON-configen; en filewatcher gör att ändringar (för hand eller via +settings-GUI:t) slår igenom direkt. Wayfire IPC används för +scale/expo-toggle och fönstergeometri till intelligent autohide. + +## Släpp + +```bash +./release.sh v0.1.0 # taggar, bygger x64, skapar Gitea-release + latest +``` + +CI på Gitea (Pi5) kör `cargo check`/`clippy`/`test` vid varje push. diff --git a/doc/architecture.md b/doc/architecture.md new file mode 100644 index 0000000..77cde76 --- /dev/null +++ b/doc/architecture.md @@ -0,0 +1,67 @@ +# waydock — arkitektur (hur appen är tänkt att byggas) + +## Översikt + +``` + ┌────────────────────────────────────────┐ + │ waydock (1 process) │ + wayfire ── wl ────►│ toplevel.rs zwlr_foreign_toplevel │ + (egen anslutning) │ │ (fönster, states, output)│ + │ ▼ │ + wayfire ── ipc ───►│ wayfire_ipc.rs scale/expo, geometri │ + (unix-socket) │ │ │ + │ ▼ │ + │ state ──► dock.rs ── GTK4-fönster/skärm│ + │ ▲ (layer-shell, CSS) │ + │ config.rs ───┘ │ + │ ▲ ▲ │ + │ │ └── notify-filewatcher (live) │ + │ └──────── settings.rs (GUI ↔ JSON) │ + └────────────────────────────────────────┘ +``` + +## Moduler + +| Modul | Ansvar | +|---|---| +| `main.rs` | CLI-flaggor, loggning, GTK-init, kopplar ihop allt | +| `config.rs` | Serde-schema för JSON-configen, defaults, load/save, filewatcher med debounce | +| `style.rs` | Config → CSS-sträng (`CssProvider`), färger/opacity/radier/skuggor/accent | +| `toplevel.rs` | Egen `wayland-client`-anslutning; foreign-toplevel-events → app-state; requests (activate/minimize/maximize/close/fullscreen) | +| `icons.rs` | app_id → `.desktop` (gio `DesktopAppInfo`) → namn, ikon, launch-kommando; cache | +| `dock.rs` | Ett `Gtk.ApplicationWindow` per monitor via gtk4-layer-shell; bygger ikonrad av pins + körande grupper; indikatorer, tooltips, klick/skroll | +| `menu.rs` | Konfigurerbar högerklicksmeny per app + tomyta-menyn (Popover/GMenu) | +| `settings.rs` | Settings-fönster; widgets bundna mot config-structen; skriver JSON (debounced) | +| `hotspots.rs` | Osynliga 1 px-layer-ytor för hot corners/edges + autohide-sensor; dwell-timer; action = exec eller IPC | +| `wayfire_ipc.rs` | Minimal klient mot `$WAYFIRE_SOCKET` (längd-prefixad JSON); `scale/toggle`, `expo/toggle`, view-geometri för dodge | + +## Nyckelbeslut + +- **En process, ingen polling.** Wayland-fd:n och IPC-socketen hängs in i + GLib-mainloopen (`unix_fd_add_local`). Allt är händelsestyrt → + 0 % CPU i vila. +- **Egen Wayland-anslutning** för foreign-toplevel i stället för att + gräva i GTK:s — enklare, stabilare, samma mönster som waybar/nwg-dock. +- **Output-matchning:** GTK:s `Monitor::connector()` ↔ `wl_output.name` + (v4) från egna anslutningen ↔ Wayfire IPC:ns output-namn. Samma nyckel + ("DP-1" …) används i configens `outputs{}`. +- **Gruppering** sker på app_id. Grupp-popup (Popover) listar fönstren + med titel. Äkta miniatyrer är omöjliga tills Wayfire exponerar + `ext_foreign_toplevel_image_capture_source_v1`. +- **Theming = genererad CSS.** Configvärden interpoleras i en + CSS-template; om filen ändras laddas providern om. GTK:s egna + animationer/transitions används där det går (billigt, GPU). +- **Autohide/dodge:** utan fönstergeometri i wlr-protokollet används + Wayfire IPC för view-rektanglar; saknas IPC faller den tillbaka till + "alltid dölj + hot edge väcker". +- **Fel-tålighet:** saknad ikon → generisk; saknad .desktop → app_id som + namn, ingen launch; IPC nere → funktioner göms; trasig config → + logga + kör defaults (skriver aldrig över en trasig fil). + +## Resursbudget + +- Release-profil: `lto = "thin"`, `opt-level = 3`, `strip = true`, + `panic = "abort"`, `codegen-units = 1` +- Mål: binär < 10 MB, RSS < 70 MB (3 skärmar), 0 % CPU i vila, + inga per-frame-omritningar utanför hover/animationer +- Ikon-pixbufar cachas per (namn, storlek); .desktop-uppslag cachas diff --git a/doc/plan.md b/doc/plan.md new file mode 100644 index 0000000..06054e0 --- /dev/null +++ b/doc/plan.md @@ -0,0 +1,82 @@ +# waydock — plan + +## Goal + +En modern, resurssnål och genomkonfigurerbar dock för Wayfire på +brasse-linux01 — transparent med hint av `#222222`, i stil med KDE +Plasma 6/Ubuntu-dockan. Ersätter på sikt sfwbar-dockan i Wayfire-DE. +Allt konfigureras via en JSON-fil som kan redigeras för hand eller via +inbyggt settings-GUI, med live-uppdatering åt båda hållen. + +## Stack + +- **Rust** (stabil, rustup) — en singel-binär, LTO-optimerad release +- **GTK4 + gtk4-layer-shell** — rendering, CSS-theming, popovers/menyer +- **wayland-client + wayland-protocols-wlr** — egen Wayland-anslutning + mot `zwlr_foreign_toplevel_manager_v1` (fönsterlista, aktivera, + minimera, maximera, stäng, flytta-mellan-outputs-kontext) +- **gio** (ingår i GTK-stacken) — `.desktop`-uppslag, ikoner, applansering +- Övriga crates: `serde`/`serde_json` (config), `notify` (filewatcher), + `log` + `simplelog` (loggning) +- **Wayfire IPC** (egen liten klient över unix-socket) — scale/expo-toggle + och fönstergeometri för intelligent dodge +- Mål-OS/arch: **linux-x64** (denna dator). Ingen Pi5-deploy. + +## Deploy target + +Pattern A (binär), med lokalt släppbygge: + +- **CI (Gitea Actions, Pi5/arm64):** `cargo check` + `clippy` + `test` + vid push till master — kompilerings-/lintvakt, inga artefakter. +- **Släpp:** `./release.sh vX.Y.Z` på brasse-linux01 — taggar, bygger + x64-release, skapar Gitea-release med binären + uppdaterar rullande + `latest`. `install.sh` ger en-rads-install via curl. + +## Config + +`~/.config/waydock/config.json` (XDG). Genereras med defaults om den +saknas. Filewatcher → alla ändringar appliceras live. Settings-GUI:t +skriver samma fil. Schema dokumenteras i `doc/usage.md`. + +## Milestones + +1. **M0 Scaffold** — repo, docs, CI, VS Code-tasks, release-flöde *(klar)* +2. **M1 Panelskelett** — layer-shell-fönster per skärm; position + (top/bottom/left/right), centrering, marginal, layer, exclusive zone; + CSS-pipeline från config (bakgrund/opacity/radie/skugga/kant); + default-config-generering; loggning +3. **M2 Fönsterspårning** — foreign-toplevel-klient; app_id → ikon/namn; + körande appar med gruppering, indikatorprickar, urgency-puls, + tooltips; klick = fokus/minimera, skroll = växla fönster, + mittklick konfigurerbart +4. **M3 Pins & per skärm** — pinnade appar (start via gio); unika pins + per output; `only_this_output`-filter; visa skrivbord-knapp; + översikt-knapp (scale) +5. **M4 Menyer** — konfigurerbar högerklicksmeny per app (minimera, + maximera, flytta till skärm-submeny, pin/unpin, stäng); + tomyta-meny (inställningar, ladda om, avsluta) +6. **M5 Settings-GUI** — GTK4-fönster som redigerar hela JSON-configen + live (färger, transparens, skuggor, accent, storlek, beteende, pins, + menyval, hotspots) +7. **M6 Autohide & hotspots** — autohide med animation + hot edge-väckning; + intelligent dodge via Wayfire IPC-geometri (fallback: alltid-dölj); + hot corners/edges med exec- eller IPC-actions +8. **M7 Polish & släpp** — profilering (mål: 0 % CPU i vila, < 70 MB RAM), + release v0.1.0, wiki-docs +9. **M8 Wayfire-DE-integration** — ersätt `autostart/11-dock.sh` + (sfwbar) med waydock, pensionera sfwbar-configen + +## Roadmap / expansions (överenskomna) + +- Autohide + dodge-animering (M6) +- Fönsterindikatorer + urgency-puls (M2) +- Scroll- och mittklicksåtgärder (M2) +- Wayfire IPC-integration för hotspots/översikt/dodge (M3/M6) + +## Open questions + +- Äkta fönsterminiatyrer i grupp-popup är **inte möjliga** — Wayfire + exponerar inte `ext_foreign_toplevel_image_capture_source_v1`. + Om protokollet dyker upp i senare Wayfire kan miniatyrer läggas till. +- Dodge-precision beror på Wayfire IPC:ns view-geometri i 0.11-git — + verifieras i M6. diff --git a/doc/usage.md b/doc/usage.md new file mode 100644 index 0000000..26d88ae --- /dev/null +++ b/doc/usage.md @@ -0,0 +1,109 @@ +# waydock — användning + +## Starta + +```bash +waydock # normal start +waydock --log debug # pratigare logg till stderr +waydock --log-file ~/.cache/waydock.log +waydock --config /annan/väg.json +``` + +Vid första start skapas `~/.config/waydock/config.json` med defaults. +Alla ändringar i filen slår igenom **live** — ingen omstart behövs. + +## Dagligt bruk + +- **Vänsterklick** på ikon: fokusera appen; klick igen minimerar + (Plasma-beteende, konfigurerbart) +- **Skroll** på ikon: växla mellan appens fönster +- **Mittklick**: starta ny instans (konfigurerbart) +- **Högerklick** på ikon: kontextmeny (innehållet väljs i configen) — + minimera, maximera, flytta till skärm ▸, pinna/släpp, stäng +- **Högerklick på tom yta**: Inställningar…, Ladda om config, Avsluta +- **Visa skrivbord-knappen** minimerar allt; klick igen återställer +- **Översikt-knappen** togglar Wayfires scale-vy (kräver ipc-pluginen) +- Grupp-ikoner får en siffra och accentprickar; hovra för fönsterlistan + +## Settings-GUI + +Högerklick på tom yta → **Inställningar…** Ändringar skrivs direkt till +JSON-filen och appliceras live. GUI:t och handredigering kan blandas fritt. + +## Configreferens (`config.json`) + +```jsonc +{ + "position": "bottom", // bottom | top | left | right + "centered": true, // ö i mitten i stället för hela kanten + "margin": 8, // px från skärmkant (0 = kant i kant) + "layer": "top", // top | overlay | bottom (always-on-top = overlay) + "avoid_windows": false, // true = reservera plats (exclusive zone) + + "appearance": { + "background": "#222222", // panelfärg + "opacity": 0.72, // paneltransparens 0–1 + "corner_radius": 14, + "border": { "color": "#ffffff", "opacity": 0.08, "width": 1 }, + "shadow": { "enabled": true, "blur": 18, "opacity": 0.45 }, + "accent": "#c9545d", // indikatorer, hover, urgency + "icon_size": 36, + "icon_spacing": 6, // px mellan ikoner + "padding": 6, // px innanför panelkanten + "hover_zoom": true, // liten zoom vid hover + "animations": true, // false stänger alla animationer + "indicator": { "style": "dot", "max": 3 } // dot | line | none + }, + + "behavior": { + "click": "focus_or_minimize", // focus_or_minimize | focus | cycle + "middle_click": "launch_new", // launch_new | close | none + "scroll": "cycle_windows", // cycle_windows | none + "group_apps": true, + "tooltips": true, + "urgent_pulse": true + }, + + "context_menu": ["minimize", "maximize", "move_to_output", + "pin", "close"], // ordning & urval styr menyn + + "pins": ["firefox", "org.kde.konsole", "org.kde.dolphin"], + "special_buttons": { "show_desktop": true, "overview": true }, + + "outputs": { // per-skärm-överstyrning (nyckel = connector) + "DP-1": { + "enabled": true, + "only_this_output": true, // visa bara fönster på den här skärmen + "pins": ["steam", "discord"] // null/utelämnad = ärver globala pins + } + }, + + "autohide": { + "enabled": false, + "mode": "dodge", // dodge (göm vid överlapp, kräver wayfire-ipc) + // | always (göm alltid, hot edge väcker) + "hide_delay_ms": 500, + "reveal_px": 2, // sensorbredd vid skärmkanten + "animation_ms": 200 + }, + + "hotspots": { // action: {"exec": "kommando"} eller {"ipc": "scale/toggle"} + "dwell_ms": 300, + "corners": { "top_left": null, "top_right": null, + "bottom_left": null, "bottom_right": null }, + "edges": { "top": null, "bottom": null, "left": null, "right": null } + }, + + "log": { "level": "info", "file": null } +} +``` + +## Tips + +- Wayfires **blur-plugin** kan ge frostat glas bakom panelen — + transparensen i `appearance.opacity` räcker för blur-effekten. +- Trasig JSON? waydock loggar felet och kör vidare på defaults utan att + röra din fil — rätta filen så laddas den om automatiskt. +- `pins` använder `.desktop`-id (filnamnet utan `.desktop`, + t.ex. `org.kde.konsole`). Hitta rätt id: + `ls /usr/share/applications | grep -i `. diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..0056674 --- /dev/null +++ b/install.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# En-rads-install av waydock (linux-x64): +# curl -fsSL https://gitea.brasse-pc.eu/brasse/waydock/raw/branch/master/install.sh | sh +set -eu + +BASE=https://gitea.brasse-pc.eu/brasse/waydock +DEST="${HOME}/.local/bin" +ARCH=$(uname -m) + +[ "$ARCH" = "x86_64" ] || { echo "waydock byggs bara för linux-x64 (du har $ARCH)"; exit 1; } +command -v gtk4-launch >/dev/null 2>&1 || \ + echo "OBS: gtk4 verkar saknas — installera: sudo pacman -S gtk4 gtk4-layer-shell" + +mkdir -p "$DEST" +echo "Hämtar waydock (latest)…" +curl -fSL -o "$DEST/waydock" \ + "$BASE/releases/download/latest/waydock-latest-linux-x64" +chmod +x "$DEST/waydock" + +echo "Installerad: $DEST/waydock" +case ":$PATH:" in + *":$DEST:"*) ;; + *) echo "OBS: $DEST ligger inte i PATH" ;; +esac +echo "Starta med: waydock (config skapas i ~/.config/waydock/config.json)" diff --git a/release.sh b/release.sh new file mode 100644 index 0000000..6277b4c --- /dev/null +++ b/release.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Lokalt släppbygge för waydock (körs på brasse-linux01, x64). +# ./release.sh v0.2.0 +# Taggar, bygger release-binären, skapar Gitea-release med binären som +# asset och uppdaterar den rullande "latest"-releasen. +set -euo pipefail + +TAG="${1:?användning: ./release.sh vX.Y.Z}" +[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "taggen ska vara vX.Y.Z"; exit 1; } + +OWNER=brasse REPO=waydock +API=https://gitea.brasse-pc.eu/api/v1 +TOKEN="${GITEA_TOKEN:-$(jq -r .token ~/.config/giteactl/config.json)}" +BIN=target/release/waydock +ASSET="waydock-${TAG}-linux-x64" + +# versionen i Cargo.toml ska matcha taggen +CARGO_VER=$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2) +[[ "v$CARGO_VER" == "$TAG" ]] || { echo "Cargo.toml har $CARGO_VER, taggen är $TAG — uppdatera först"; exit 1; } + +echo "==> bygger $TAG" +cargo build --release --locked +cargo test --locked + +echo "==> taggar + pushar" +git tag -a "$TAG" -m "waydock $TAG" +git push origin master "$TAG" + +api() { # method path [json|@fil] + local m=$1 p=$2; shift 2 + curl -fsS -X "$m" -H "Authorization: token $TOKEN" "$@" "$API$p" +} + +make_release() { # tag namn prerelease + api POST "/repos/$OWNER/$REPO/releases" \ + -H 'Content-Type: application/json' \ + -d "{\"tag_name\":\"$1\",\"name\":\"$2\",\"body\":\"waydock $TAG — linux-x64.\\n\\nInstallera:\\n\`\`\`\\ncurl -fsSL https://gitea.brasse-pc.eu/brasse/waydock/raw/branch/master/install.sh | sh\\n\`\`\`\"}" +} + +upload() { # release-id filnamn + api POST "/repos/$OWNER/$REPO/releases/$1/assets?name=$2" \ + -F "attachment=@$BIN;filename=$2" > /dev/null +} + +echo "==> skapar release $TAG" +REL_ID=$(make_release "$TAG" "waydock $TAG" | jq .id) +upload "$REL_ID" "$ASSET" + +echo "==> uppdaterar rullande latest" +OLD=$(api GET "/repos/$OWNER/$REPO/releases/tags/latest" 2>/dev/null | jq -r .id) || OLD="" +[[ -n "$OLD" && "$OLD" != null ]] && api DELETE "/repos/$OWNER/$REPO/releases/$OLD" || true +git tag -f latest && git push -f origin latest +LATEST_ID=$(make_release "latest" "waydock latest ($TAG)" | jq .id) +upload "$LATEST_ID" "waydock-latest-linux-x64" + +echo "==> klart: https://gitea.brasse-pc.eu/$OWNER/$REPO/releases" diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..ad516a0 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,434 @@ +// JSON-configen: schema, defaults, load/save och live-omladdning. +// Trasig config skrivs aldrig över — vi loggar och kör på defaults. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::time::Duration; + +use gtk4::glib; +use log::{error, info, warn}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum Position { + Top, + Bottom, + Left, + Right, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum Layer { + Top, + Overlay, + Bottom, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ClickAction { + FocusOrMinimize, + Focus, + Cycle, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum MiddleClick { + LaunchNew, + Close, + None, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ScrollAction { + CycleWindows, + None, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum IndicatorStyle { + Dot, + Line, + None, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum MenuItem { + Minimize, + Maximize, + MoveToOutput, + Pin, + Close, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct Border { + pub color: String, + pub opacity: f64, + pub width: u32, +} +impl Default for Border { + fn default() -> Self { + Self { color: "#ffffff".into(), opacity: 0.08, width: 1 } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct Shadow { + pub enabled: bool, + pub blur: u32, + pub opacity: f64, +} +impl Default for Shadow { + fn default() -> Self { + Self { enabled: true, blur: 18, opacity: 0.45 } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct Indicator { + pub style: IndicatorStyle, + pub max: u32, +} +impl Default for Indicator { + fn default() -> Self { + Self { style: IndicatorStyle::Dot, max: 3 } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct Appearance { + pub background: String, + pub opacity: f64, + pub corner_radius: u32, + pub border: Border, + pub shadow: Shadow, + pub accent: String, + pub icon_size: i32, + pub icon_spacing: i32, + pub padding: i32, + pub hover_zoom: bool, + pub animations: bool, + pub indicator: Indicator, +} +impl Default for Appearance { + fn default() -> Self { + Self { + background: "#222222".into(), + opacity: 0.72, + corner_radius: 14, + border: Border::default(), + shadow: Shadow::default(), + accent: "#c9545d".into(), + icon_size: 36, + icon_spacing: 6, + padding: 6, + hover_zoom: true, + animations: true, + indicator: Indicator::default(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct Behavior { + pub click: ClickAction, + pub middle_click: MiddleClick, + pub scroll: ScrollAction, + pub group_apps: bool, + pub tooltips: bool, + pub urgent_pulse: bool, +} +impl Default for Behavior { + fn default() -> Self { + Self { + click: ClickAction::FocusOrMinimize, + middle_click: MiddleClick::LaunchNew, + scroll: ScrollAction::CycleWindows, + group_apps: true, + tooltips: true, + urgent_pulse: true, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct OutputConfig { + pub enabled: Option, + pub only_this_output: Option, + pub pins: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct SpecialButtons { + pub show_desktop: bool, + pub overview: bool, +} +impl Default for SpecialButtons { + fn default() -> Self { + Self { show_desktop: true, overview: true } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum AutohideMode { + Dodge, + Always, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct Autohide { + pub enabled: bool, + pub mode: AutohideMode, + pub hide_delay_ms: u32, + pub reveal_px: i32, + pub animation_ms: u32, +} +impl Default for Autohide { + fn default() -> Self { + Self { + enabled: false, + mode: AutohideMode::Dodge, + hide_delay_ms: 500, + reveal_px: 2, + animation_ms: 200, + } + } +} + +/// En hotspot-action: kör ett kommando eller anropa Wayfire IPC. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HotspotAction { + Exec(String), + Ipc(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct Corners { + pub top_left: Option, + pub top_right: Option, + pub bottom_left: Option, + pub bottom_right: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct Edges { + pub top: Option, + pub bottom: Option, + pub left: Option, + pub right: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct Hotspots { + pub dwell_ms: u32, + pub corners: Corners, + pub edges: Edges, +} +impl Default for Hotspots { + fn default() -> Self { + Self { dwell_ms: 300, corners: Corners::default(), edges: Edges::default() } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct LogConfig { + pub level: String, + pub file: Option, +} +impl Default for LogConfig { + fn default() -> Self { + Self { level: "info".into(), file: None } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct Config { + pub position: Position, + pub centered: bool, + pub margin: i32, + pub layer: Layer, + pub avoid_windows: bool, + pub appearance: Appearance, + pub behavior: Behavior, + pub context_menu: Vec, + pub pins: Vec, + pub special_buttons: SpecialButtons, + pub outputs: HashMap, + pub autohide: Autohide, + pub hotspots: Hotspots, + pub log: LogConfig, +} + +impl Default for Config { + fn default() -> Self { + Self { + position: Position::Bottom, + centered: true, + margin: 8, + layer: Layer::Top, + avoid_windows: false, + appearance: Appearance::default(), + behavior: Behavior::default(), + context_menu: vec![ + MenuItem::Minimize, + MenuItem::Maximize, + MenuItem::MoveToOutput, + MenuItem::Pin, + MenuItem::Close, + ], + pins: vec![ + "firefox".into(), + "org.kde.konsole".into(), + "org.kde.dolphin".into(), + ], + special_buttons: SpecialButtons::default(), + outputs: HashMap::new(), + autohide: Autohide::default(), + hotspots: Hotspots::default(), + log: LogConfig::default(), + } + } +} + +impl Config { + /// Effektiva pins för en viss skärm (output-överstyrning eller globala). + pub fn pins_for(&self, output: &str) -> &[String] { + self.outputs + .get(output) + .and_then(|o| o.pins.as_deref()) + .unwrap_or(&self.pins) + } + + pub fn output_enabled(&self, output: &str) -> bool { + self.outputs + .get(output) + .and_then(|o| o.enabled) + .unwrap_or(true) + } + + pub fn only_this_output(&self, output: &str) -> bool { + self.outputs + .get(output) + .and_then(|o| o.only_this_output) + .unwrap_or(false) + } +} + +pub fn default_path() -> PathBuf { + glib::user_config_dir().join("waydock").join("config.json") +} + +/// Läs configen; skapa default-fil om den saknas. Trasig fil → defaults. +pub fn load_or_create(path: &Path) -> Config { + match std::fs::read_to_string(path) { + Ok(text) => match serde_json::from_str::(&text) { + Ok(cfg) => { + info!("läste config från {}", path.display()); + cfg + } + Err(e) => { + error!("trasig config ({e}) — kör med defaults, filen lämnas orörd"); + Config::default() + } + }, + Err(_) => { + let cfg = Config::default(); + if let Err(e) = save(path, &cfg) { + warn!("kunde inte skriva default-config: {e}"); + } else { + info!("skapade default-config i {}", path.display()); + } + cfg + } + } +} + +pub fn save(path: &Path, cfg: &Config) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let json = serde_json::to_string_pretty(cfg).expect("config är alltid serialiserbar"); + // atomiskt: skriv temp + rename, så filewatchers aldrig ser en halv fil + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, json)?; + std::fs::rename(&tmp, path) +} + +/// Filewatcher med debounce: anropar `on_change` på GTK-tråden när +/// configfilen ändrats (oavsett om det var settings-GUI:t eller en editor). +pub fn watch(path: &Path, on_change: Rc) { + use notify::{RecursiveMode, Watcher}; + use std::cell::Cell; + + let (tx, rx) = async_channel::unbounded::<()>(); + let file_name = path.file_name().map(|s| s.to_os_string()); + let dir = path.parent().unwrap_or(Path::new(".")).to_path_buf(); + + let mut watcher = match notify::recommended_watcher(move |res: notify::Result| { + if let Ok(ev) = res { + let hit = ev + .paths + .iter() + .any(|p| p.file_name().map(|s| s.to_os_string()) == file_name); + if hit { + let _ = tx.send_blocking(()); + } + } + }) { + Ok(w) => w, + Err(e) => { + warn!("ingen filewatcher ({e}) — live-omladdning avstängd"); + return; + } + }; + // bevaka katalogen (editorer gör ofta rename-danser runt filen) + if let Err(e) = watcher.watch(&dir, RecursiveMode::NonRecursive) { + warn!("kunde inte bevaka {}: {e}", dir.display()); + return; + } + // watcher måste leva så länge processen — läck den medvetet + std::mem::forget(watcher); + + // debounce på GTK-tråden: kör on_change först när det varit tyst 150 ms + let generation = Rc::new(Cell::new(0u64)); + glib::MainContext::default().spawn_local(async move { + while rx.recv().await.is_ok() { + let this_gen = generation.get() + 1; + generation.set(this_gen); + let generation = generation.clone(); + let on_change = on_change.clone(); + glib::timeout_add_local_once(Duration::from_millis(150), move || { + if generation.get() == this_gen { + on_change(); + } + }); + } + }); +} + +/// Delad, muterbar config + sökväg — det alla moduler håller i. +pub type SharedConfig = Rc>; diff --git a/src/dock.rs b/src/dock.rs new file mode 100644 index 0000000..044ef17 --- /dev/null +++ b/src/dock.rs @@ -0,0 +1,402 @@ +// Dockfönstren: ett layer-shell-fönster per skärm. Bygger ikonraden av +// pins + körande fönstergrupper och kopplar klick/skroll/menyer. + +use std::cell::RefCell; +use std::path::PathBuf; +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::gdk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; +use log::{debug, info}; + +use crate::config::{ + ClickAction, Layer as CfgLayer, MiddleClick, Position, ScrollAction, SharedConfig, +}; +use crate::icons::{self, AppMeta}; +use crate::menu; +use crate::toplevel::{ToplevelInfo, ToplevelService}; +use crate::wayfire_ipc; + +/// Allt delat som dockarna behöver nå. +pub struct Ctx { + pub cfg: SharedConfig, + pub cfg_path: PathBuf, + pub service: RefCell>>, + pub docks: RefCell>, + /// målet för den öppna kontextmenyn (menu.rs läser) + pub menu_target: RefCell>, + pub app: gtk::Application, + /// körs när configen ändrats (sätts av main; applicerar CSS/rebuild) + pub on_config_changed: RefCell>>, + /// generationsräknare för settings-GUI:ts debouncade sparningar + pub save_generation: std::cell::Cell, +} + +pub struct Dock { + pub window: gtk::ApplicationWindow, + pub panel: gtk::Box, + pub connector: String, +} + +/// En ikon i docken: en pin och/eller en grupp körande fönster. +pub struct Item { + pub meta: AppMeta, + pub windows: Vec, + pub pinned: bool, +} + +fn orientation(pos: &Position) -> gtk::Orientation { + match pos { + Position::Left | Position::Right => gtk::Orientation::Vertical, + _ => gtk::Orientation::Horizontal, + } +} + +fn configure_layer(win: >k::ApplicationWindow, ctx: &Ctx, monitor: &gdk::Monitor) { + let cfg = ctx.cfg.borrow(); + win.init_layer_shell(); + win.set_namespace(Some("waydock")); + win.set_monitor(Some(monitor)); + win.set_layer(match cfg.layer { + CfgLayer::Top => Layer::Top, + CfgLayer::Overlay => Layer::Overlay, + CfgLayer::Bottom => Layer::Bottom, + }); + win.set_keyboard_mode(KeyboardMode::None); + + let (main_edge, side_a, side_b) = match cfg.position { + Position::Bottom => (Edge::Bottom, Edge::Left, Edge::Right), + Position::Top => (Edge::Top, Edge::Left, Edge::Right), + Position::Left => (Edge::Left, Edge::Top, Edge::Bottom), + Position::Right => (Edge::Right, Edge::Top, Edge::Bottom), + }; + win.set_anchor(main_edge, true); + win.set_margin(main_edge, cfg.margin); + if !cfg.centered { + // full bredd/höjd längs kanten + win.set_anchor(side_a, true); + win.set_anchor(side_b, true); + } + if cfg.avoid_windows { + win.auto_exclusive_zone_enable(); + } else { + win.set_exclusive_zone(0); + } +} + +/// Bygg om alla dockfönster från grunden (vid start, configbyte, skärmbyte). +pub fn rebuild_all(ctx: &Rc) { + for dock in ctx.docks.borrow_mut().drain(..) { + dock.window.destroy(); + } + + let display = gdk::Display::default().expect("ingen display"); + let monitors = display.monitors(); + let mut docks = Vec::new(); + for i in 0..monitors.n_items() { + let Some(monitor) = monitors.item(i).and_then(|o| o.downcast::().ok()) + else { + continue; + }; + let connector = monitor + .connector() + .map(|c| c.to_string()) + .unwrap_or_else(|| format!("monitor-{i}")); + if !ctx.cfg.borrow().output_enabled(&connector) { + debug!("skärm {connector} avstängd i configen"); + continue; + } + docks.push(build_dock(ctx, &monitor, connector)); + } + *ctx.docks.borrow_mut() = docks; + refresh_items(ctx); +} + +fn build_dock(ctx: &Rc, monitor: &gdk::Monitor, connector: String) -> Dock { + let cfg = ctx.cfg.borrow(); + let win = gtk::ApplicationWindow::new(&ctx.app); + win.add_css_class("waydock"); + + let orient = orientation(&cfg.position); + let panel = gtk::Box::new(orient, 0); + panel.add_css_class("dock-panel"); + panel.set_halign(gtk::Align::Center); + panel.set_valign(gtk::Align::Center); + + // yttre marginal = plats för skuggan att rita + let outer = gtk::Box::new(orient, 0); + let pad = if cfg.appearance.shadow.enabled { + cfg.appearance.shadow.blur as i32 + } else { + 0 + }; + outer.set_margin_top(pad); + outer.set_margin_bottom(pad); + outer.set_margin_start(pad); + outer.set_margin_end(pad); + outer.append(&panel); + win.set_child(Some(&outer)); + drop(cfg); + + configure_layer(&win, ctx, monitor); + menu::setup_dock_actions(ctx, &win); + + // högerklick på tom panelyta → panelmenyn + let empty_click = gtk::GestureClick::new(); + empty_click.set_button(3); + { + let ctx = ctx.clone(); + let panel2 = panel.clone(); + empty_click.connect_pressed(move |gesture, _, x, y| { + gesture.set_state(gtk::EventSequenceState::Claimed); + menu::popup_panel_menu(&ctx, &panel2, x, y); + }); + } + panel.add_controller(empty_click); + + win.present(); + info!("dock uppe på {connector}"); + Dock { window: win, panel, connector } +} + +/// Gruppera snapshot → ikonrad för en viss skärm. +fn build_items(ctx: &Ctx, connector: &str, snapshot: &[ToplevelInfo]) -> Vec { + let cfg = ctx.cfg.borrow(); + let only_here = cfg.only_this_output(connector); + let group = cfg.behavior.group_apps; + + let mut items: Vec = cfg + .pins_for(connector) + .iter() + .filter_map(|pin| icons::lookup_pin(pin)) + .map(|meta| Item { meta, windows: Vec::new(), pinned: true }) + .collect(); + + for t in snapshot { + if only_here && !t.outputs.iter().any(|o| o == connector) { + continue; + } + let meta = icons::lookup(&t.app_id); + let existing = items + .iter_mut() + .find(|i| i.meta.key == meta.key && (group || i.windows.is_empty())); + match existing { + Some(item) => item.windows.push(t.clone()), + None => items.push(Item { meta, windows: vec![t.clone()], pinned: false }), + } + } + items +} + +/// Uppdatera ikonraderna i alla dockor (körs vid varje fönsterhändelse). +pub fn refresh_items(ctx: &Rc) { + let snapshot = ctx + .service + .borrow() + .as_ref() + .map(|s| s.snapshot()) + .unwrap_or_default(); + + for dock in ctx.docks.borrow().iter() { + while let Some(child) = dock.panel.first_child() { + dock.panel.remove(&child); + } + add_special_buttons(ctx, dock); + for item in build_items(ctx, &dock.connector, &snapshot) { + let widget = build_item_widget(ctx, Rc::new(item)); + dock.panel.append(&widget); + } + } +} + +fn special_button(icon_name: &str, tooltip: &str) -> gtk::Button { + let btn = gtk::Button::new(); + btn.add_css_class("dock-item"); + btn.set_has_frame(false); + let img = gtk::Image::from_icon_name(icon_name); + img.set_pixel_size(24); + btn.set_child(Some(&img)); + btn.set_tooltip_text(Some(tooltip)); + btn +} + +fn add_special_buttons(ctx: &Rc, dock: &Dock) { + let cfg = ctx.cfg.borrow(); + let specials = cfg.special_buttons.clone(); + let icon_size = cfg.appearance.icon_size; + drop(cfg); + let _ = icon_size; + + if specials.overview && wayfire_ipc::available() { + let btn = special_button("view-grid-symbolic", "Översikt (scale)"); + btn.connect_clicked(|_| wayfire_ipc::toggle_scale()); + dock.panel.append(&btn); + } + if specials.show_desktop { + let btn = special_button("user-desktop-symbolic", "Visa skrivbordet"); + let ctx = ctx.clone(); + btn.connect_clicked(move |_| { + if let Some(service) = ctx.service.borrow().as_ref() { + service.toggle_show_desktop(); + } + }); + dock.panel.append(&btn); + } +} + +fn build_item_widget(ctx: &Rc, item: Rc) -> gtk::Button { + let cfg = ctx.cfg.borrow(); + let icon_size = cfg.appearance.icon_size; + let max_dots = cfg.appearance.indicator.max as usize; + let tooltips = cfg.behavior.tooltips; + drop(cfg); + + let btn = gtk::Button::new(); + btn.add_css_class("dock-item"); + btn.set_has_frame(false); + + let vbox = gtk::Box::new(gtk::Orientation::Vertical, 2); + let overlay = gtk::Overlay::new(); + overlay.set_child(Some(&icons::image_for(&item.meta, icon_size))); + + // siffer-badge för grupper med flera fönster + if item.windows.len() > 1 { + let badge = gtk::Label::new(Some(&item.windows.len().to_string())); + badge.add_css_class("group-badge"); + badge.set_halign(gtk::Align::End); + badge.set_valign(gtk::Align::Start); + overlay.add_overlay(&badge); + } + vbox.append(&overlay); + + // indikatorprickar + let ind_box = gtk::Box::new(gtk::Orientation::Horizontal, 2); + ind_box.add_css_class("indicator-box"); + ind_box.set_halign(gtk::Align::Center); + let any_active = item.windows.iter().any(|w| w.activated); + for _ in 0..item.windows.len().min(max_dots) { + let dot = gtk::Box::new(gtk::Orientation::Horizontal, 0); + dot.add_css_class("indicator"); + if !any_active { + dot.add_css_class("inactive"); + } + ind_box.append(&dot); + } + vbox.append(&ind_box); + btn.set_child(Some(&vbox)); + + if tooltips { + let tip = match item.windows.len() { + 0 => item.meta.display_name.clone(), + 1 => { + let t = &item.windows[0].title; + if t.is_empty() { item.meta.display_name.clone() } else { t.clone() } + } + n => format!("{} — {n} fönster", item.meta.display_name), + }; + btn.set_tooltip_text(Some(&tip)); + } + + // klick: vänster/mitt/höger + let click = gtk::GestureClick::new(); + click.set_button(0); + { + let ctx = ctx.clone(); + let item = item.clone(); + let btn2 = btn.clone(); + click.connect_released(move |gesture, _, _, _| { + gesture.set_state(gtk::EventSequenceState::Claimed); + match gesture.current_button() { + 1 => primary_action(&ctx, &item, &btn2), + 2 => middle_action(&ctx, &item), + 3 => menu::popup_item_menu(&ctx, &item, &btn2), + _ => {} + } + }); + } + btn.add_controller(click); + + // skroll: växla mellan gruppens fönster + let scroll = gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::VERTICAL); + { + let ctx = ctx.clone(); + let item = item.clone(); + scroll.connect_scroll(move |_, _, dy| { + if ctx.cfg.borrow().behavior.scroll == ScrollAction::CycleWindows { + cycle_windows(&ctx, &item, dy > 0.0); + glib::Propagation::Stop + } else { + glib::Propagation::Proceed + } + }); + } + btn.add_controller(scroll); + + btn +} + +fn primary_action(ctx: &Rc, item: &Rc, anchor: >k::Button) { + let service = ctx.service.borrow(); + let Some(service) = service.as_ref() else { + icons::launch(&item.meta); + return; + }; + match item.windows.len() { + 0 => icons::launch(&item.meta), + 1 => { + let w = &item.windows[0]; + let click = ctx.cfg.borrow().behavior.click.clone(); + if w.activated && !w.minimized && click == ClickAction::FocusOrMinimize { + service.set_minimized(w, true); + } else { + service.activate(w); + } + } + _ => { + let click = ctx.cfg.borrow().behavior.click.clone(); + if click == ClickAction::Cycle { + cycle_windows(ctx, item, true); + } else { + menu::popup_group_list(ctx, item, anchor); + } + } + } +} + +fn middle_action(ctx: &Rc, item: &Rc) { + let action = ctx.cfg.borrow().behavior.middle_click.clone(); + match action { + MiddleClick::LaunchNew => icons::launch(&item.meta), + MiddleClick::Close => { + if let Some(service) = ctx.service.borrow().as_ref() { + for w in &item.windows { + service.close(w); + } + } + } + MiddleClick::None => {} + } +} + +pub fn cycle_windows(ctx: &Rc, item: &Item, forward: bool) { + if item.windows.is_empty() { + return; + } + let service = ctx.service.borrow(); + let Some(service) = service.as_ref() else { return }; + let idx = item.windows.iter().position(|w| w.activated); + let next = match idx { + Some(i) => { + if forward { + (i + 1) % item.windows.len() + } else { + (i + item.windows.len() - 1) % item.windows.len() + } + } + None => 0, + }; + service.activate(&item.windows[next]); +} diff --git a/src/icons.rs b/src/icons.rs new file mode 100644 index 0000000..22b5971 --- /dev/null +++ b/src/icons.rs @@ -0,0 +1,107 @@ +// app_id → .desktop-metadata (namn, ikon, launch). Allt cachas — +// uppslag sker bara första gången ett nytt app_id dyker upp. + +use std::cell::RefCell; +use std::collections::HashMap; + +use gtk4::{gdk, gio, prelude::*}; + +#[derive(Clone)] +pub struct AppMeta { + /// Nyckel som grupperar fönster och matchar pins: desktop-id utan + /// ".desktop" om det finns, annars app_id gemener. + pub key: String, + pub display_name: String, + pub icon: Option, + pub app_info: Option, +} + +thread_local! { + static CACHE: RefCell> = RefCell::new(HashMap::new()); +} + +fn find_desktop(app_id: &str) -> Option { + let candidates = [ + format!("{app_id}.desktop"), + format!("{}.desktop", app_id.to_lowercase()), + format!("{}.desktop", app_id.replace(' ', "-").to_lowercase()), + ]; + for c in &candidates { + if let Some(info) = gio::DesktopAppInfo::new(c) { + return Some(info); + } + } + // sista utväg: gios sökindex (matchar StartupWMClass, namn m.m.) + let hits = gio::DesktopAppInfo::search(app_id); + hits.first() + .and_then(|group| group.first()) + .and_then(|id| gio::DesktopAppInfo::new(id.as_str())) +} + +fn meta_from_info(info: gio::DesktopAppInfo, fallback_name: &str) -> AppMeta { + let key = info + .id() + .map(|id| id.trim_end_matches(".desktop").to_string()) + .unwrap_or_else(|| fallback_name.to_lowercase()); + AppMeta { + key, + display_name: info.display_name().to_string(), + icon: info.icon(), + app_info: Some(info), + } +} + +/// Slå upp metadata för ett körande fönsters app_id. +pub fn lookup(app_id: &str) -> AppMeta { + CACHE.with(|c| { + if let Some(meta) = c.borrow().get(app_id) { + return meta.clone(); + } + let meta = match find_desktop(app_id) { + Some(info) => meta_from_info(info, app_id), + None => { + // ingen .desktop — prova app_id som temaikon rakt av + let icon: Option = gdk::Display::default() + .map(|d| gtk4::IconTheme::for_display(&d)) + .filter(|theme| theme.has_icon(app_id)) + .map(|_| gio::ThemedIcon::new(app_id).upcast()); + AppMeta { + key: app_id.to_lowercase(), + display_name: app_id.to_string(), + icon, + app_info: None, + } + } + }; + c.borrow_mut().insert(app_id.to_string(), meta.clone()); + meta + }) +} + +/// Slå upp en pin (desktop-id från configen). +pub fn lookup_pin(desktop_id: &str) -> Option { + gio::DesktopAppInfo::new(&format!("{desktop_id}.desktop")) + .or_else(|| gio::DesktopAppInfo::new(desktop_id)) + .map(|info| meta_from_info(info, desktop_id)) +} + +/// Starta en app (ny instans). +pub fn launch(meta: &AppMeta) { + if let Some(info) = &meta.app_info { + if let Err(e) = info.launch(&[], None::<&gio::AppLaunchContext>) { + log::warn!("kunde inte starta {}: {e}", meta.display_name); + } + } else { + log::warn!("ingen .desktop för {} — kan inte starta", meta.display_name); + } +} + +/// Ikon-widget för en app i given storlek. +pub fn image_for(meta: &AppMeta, size: i32) -> gtk4::Image { + let img = match &meta.icon { + Some(icon) => gtk4::Image::from_gicon(icon), + None => gtk4::Image::from_icon_name("application-x-executable"), + }; + img.set_pixel_size(size); + img +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..896a5d9 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,232 @@ +// waydock — modern, resurssnål dock för Wayfire. +// Se doc/architecture.md för helheten. + +mod config; +mod dock; +mod icons; +mod menu; +mod settings; +mod style; +mod toplevel; +mod wayfire_ipc; + +use std::cell::{Cell, RefCell}; +use std::path::PathBuf; +use std::rc::Rc; +use std::time::Duration; + +use gtk4 as gtk; +use gtk4::gdk; +use gtk4::glib; +use gtk4::prelude::*; +use log::{error, info, warn, LevelFilter}; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +struct Cli { + config: Option, + log: Option, + log_file: Option, +} + +fn parse_cli() -> Cli { + let mut cli = Cli { config: None, log: None, log_file: None }; + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--config" => cli.config = args.next().map(PathBuf::from), + "--log" => cli.log = args.next(), + "--log-file" => cli.log_file = args.next(), + "--version" | "-V" => { + println!("waydock {VERSION}"); + std::process::exit(0); + } + "--help" | "-h" => { + println!( + "waydock {VERSION} — dock för Wayfire\n\n\ + FLAGGOR:\n \ + --config annan configfil (default ~/.config/waydock/config.json)\n \ + --log error|warn|info|debug|trace (även env WAYDOCK_LOG)\n \ + --log-file skriv logg till fil\n \ + --version visa version" + ); + std::process::exit(0); + } + other => { + eprintln!("okänd flagga: {other} (se --help)"); + std::process::exit(2); + } + } + } + cli +} + +fn init_logging(cli: &Cli, cfg: &config::Config) { + let level_str = cli + .log + .clone() + .or_else(|| std::env::var("WAYDOCK_LOG").ok()) + .unwrap_or_else(|| cfg.log.level.clone()); + let level = match level_str.to_lowercase().as_str() { + "error" => LevelFilter::Error, + "warn" => LevelFilter::Warn, + "debug" => LevelFilter::Debug, + "trace" => LevelFilter::Trace, + _ => LevelFilter::Info, + }; + let log_cfg = simplelog::ConfigBuilder::new() + .set_time_format_rfc3339() + .build(); + let mut loggers: Vec> = vec![simplelog::TermLogger::new( + level, + log_cfg.clone(), + simplelog::TerminalMode::Stderr, + simplelog::ColorChoice::Auto, + )]; + let file_path = cli.log_file.clone().or_else(|| cfg.log.file.clone()); + if let Some(path) = file_path { + let expanded = if let Some(rest) = path.strip_prefix("~/") { + glib::home_dir().join(rest) + } else { + PathBuf::from(&path) + }; + match std::fs::File::create(&expanded) { + Ok(f) => loggers.push(simplelog::WriteLogger::new(level, log_cfg, f)), + Err(e) => eprintln!("kunde inte öppna loggfil {path}: {e}"), + } + } + let _ = simplelog::CombinedLogger::init(loggers); +} + +/// Delar av configen som kräver att fönstren byggs om (inte bara CSS). +fn layout_signature(cfg: &config::Config) -> String { + format!( + "{:?}|{}|{}|{:?}|{}|{}|{}|{:?}", + cfg.position, + cfg.centered, + cfg.margin, + cfg.layer, + cfg.avoid_windows, + cfg.appearance.shadow.enabled, + cfg.appearance.shadow.blur, + cfg.outputs.keys().collect::>(), + ) +} + +fn main() -> glib::ExitCode { + let cli = parse_cli(); + let cfg_path = cli.config.clone().unwrap_or_else(config::default_path); + let initial_cfg = config::load_or_create(&cfg_path); + init_logging(&cli, &initial_cfg); + info!("waydock {VERSION} — config: {}", cfg_path.display()); + + let app = gtk::Application::new(Some("eu.brassepc.waydock"), Default::default()); + let started = Rc::new(Cell::new(false)); + + let cfg_path2 = cfg_path.clone(); + app.connect_activate(move |app| { + if started.get() { + info!("waydock kör redan — ignorerar andra instansen"); + return; + } + started.set(true); + activate(app, cfg_path2.clone()); + }); + + // ge inte GTK våra egna flaggor + app.run_with_args::<&str>(&[]) +} + +fn activate(app: >k::Application, cfg_path: PathBuf) { + let cfg: config::SharedConfig = Rc::new(RefCell::new(config::load_or_create(&cfg_path))); + + let ctx = Rc::new(dock::Ctx { + cfg: cfg.clone(), + cfg_path: cfg_path.clone(), + service: RefCell::new(None), + docks: RefCell::new(Vec::new()), + menu_target: RefCell::new(None), + app: app.clone(), + on_config_changed: RefCell::new(None), + save_generation: Cell::new(0), + }); + + // CSS + rebuild/refresh-logik + let css_provider: Rc>> = Rc::new(RefCell::new(None)); + let last_signature = Rc::new(RefCell::new(String::new())); + { + let ctx2 = ctx.clone(); + let css_provider = css_provider.clone(); + let last_signature = last_signature.clone(); + let apply: Rc = Rc::new(move || { + let cfg = ctx2.cfg.borrow(); + let provider = style::apply(&cfg, css_provider.borrow().as_ref()); + *css_provider.borrow_mut() = Some(provider); + let sig = layout_signature(&cfg); + drop(cfg); + if *last_signature.borrow() != sig { + *last_signature.borrow_mut() = sig; + dock::rebuild_all(&ctx2); + } else { + dock::refresh_items(&ctx2); + } + }); + *ctx.on_config_changed.borrow_mut() = Some(apply); + } + + // fönsterspårningen + { + let ctx2 = ctx.clone(); + let on_update: Rc = Rc::new(move || dock::refresh_items(&ctx2)); + match toplevel::start(on_update) { + Ok(service) => *ctx.service.borrow_mut() = Some(service), + Err(e) => { + error!("{e} — docken visar bara pins"); + } + } + } + + if !wayfire_ipc::available() { + warn!("WAYFIRE_SOCKET saknas — översikt/flytta-till-skärm/dodge avstängda (aktivera ipc-pluginen i wayfire.ini)"); + } + + // första uppritningen + if let Some(cb) = ctx.on_config_changed.borrow().as_ref() { + cb(); + } + + // live-omladdning när configfilen ändras + { + let ctx2 = ctx.clone(); + config::watch( + &cfg_path, + Rc::new(move || { + info!("configfilen ändrad — laddar om"); + let fresh = config::load_or_create(&ctx2.cfg_path); + *ctx2.cfg.borrow_mut() = fresh; + if let Some(cb) = ctx2.on_config_changed.borrow().as_ref() { + cb(); + } + }), + ); + } + + // skärmar in/ut → bygg om (debouncat, skärmar kommer i klungor) + { + let ctx2 = ctx.clone(); + let pending = Rc::new(Cell::new(0u64)); + let display = gdk::Display::default().expect("ingen display"); + display.monitors().connect_items_changed(move |_, _, _, _| { + let generation = pending.get() + 1; + pending.set(generation); + let ctx3 = ctx2.clone(); + let pending2 = pending.clone(); + glib::timeout_add_local_once(Duration::from_millis(500), move || { + if pending2.get() == generation { + info!("skärmuppsättningen ändrad — bygger om dockorna"); + dock::rebuild_all(&ctx3); + } + }); + }); + } +} diff --git a/src/menu.rs b/src/menu.rs new file mode 100644 index 0000000..3ff8a93 --- /dev/null +++ b/src/menu.rs @@ -0,0 +1,316 @@ +// Kontextmenyer: höger­klick på app-ikon (konfigurerbar via +// config.context_menu), grupp-popup med fönsterlistan, samt panelmenyn +// på tom yta (Inställningar…, Ladda om, Avsluta). + +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::gdk; +use gtk4::gio; +use gtk4::glib; +use gtk4::prelude::*; +use log::warn; + +use crate::config::{self, MenuItem}; +use crate::dock::{Ctx, Item}; +use crate::icons; +use crate::settings; +use crate::toplevel::ToplevelInfo; +use crate::wayfire_ipc; + +/// Målet för den senast öppnade ikon-menyn (actions läser härifrån). +pub struct MenuTarget { + pub key: String, + pub windows: Vec, + pub pinned: bool, + pub connector: String, +} + +fn with_service(ctx: &Ctx, f: impl Fn(&crate::toplevel::ToplevelService, &ToplevelInfo)) { + let target = ctx.menu_target.borrow(); + let service = ctx.service.borrow(); + if let (Some(t), Some(s)) = (target.as_ref(), service.as_ref()) { + for w in &t.windows { + f(s, w); + } + } +} + +/// Registrera action-gruppen "dock" på ett dockfönster. +pub fn setup_dock_actions(ctx: &Rc, win: >k::ApplicationWindow) { + let group = gio::SimpleActionGroup::new(); + + let minimize = gio::SimpleAction::new("minimize", None); + { + let ctx = ctx.clone(); + minimize.connect_activate(move |_, _| { + let all_min = ctx + .menu_target + .borrow() + .as_ref() + .map(|t| t.windows.iter().all(|w| w.minimized)) + .unwrap_or(false); + with_service(&ctx, |s, w| s.set_minimized(w, !all_min)); + }); + } + group.add_action(&minimize); + + let maximize = gio::SimpleAction::new("maximize", None); + { + let ctx = ctx.clone(); + maximize.connect_activate(move |_, _| { + let all_max = ctx + .menu_target + .borrow() + .as_ref() + .map(|t| t.windows.iter().all(|w| w.maximized)) + .unwrap_or(false); + with_service(&ctx, |s, w| s.set_maximized(w, !all_max)); + }); + } + group.add_action(&maximize); + + let close = gio::SimpleAction::new("close", None); + { + let ctx = ctx.clone(); + close.connect_activate(move |_, _| with_service(&ctx, |s, w| s.close(w))); + } + group.add_action(&close); + + let pin = gio::SimpleAction::new("pin", None); + { + let ctx = ctx.clone(); + pin.connect_activate(move |_, _| { + let connector = ctx + .menu_target + .borrow() + .as_ref() + .map(|t| t.connector.clone()) + .unwrap_or_default(); + toggle_pin(&ctx, &connector); + }); + } + group.add_action(&pin); + + let move_to = gio::SimpleAction::new("move-to-output", Some(glib::VariantTy::STRING)); + { + let ctx = ctx.clone(); + move_to.connect_activate(move |_, param| { + let Some(output) = param.and_then(|v| v.get::()) else { return }; + let target = ctx.menu_target.borrow(); + let Some(t) = target.as_ref() else { return }; + for w in &t.windows { + match wayfire_ipc::find_view_id(&w.app_id, &w.title) { + Some(id) => wayfire_ipc::move_view_to_output(id, &output), + None => warn!("hittade ingen wayfire-vy för {}", w.app_id), + } + } + }); + } + group.add_action(&move_to); + + win.insert_action_group("dock", Some(&group)); +} + +fn toggle_pin(ctx: &Rc, connector: &str) { + let Some((key, pinned)) = ctx + .menu_target + .borrow() + .as_ref() + .map(|t| (t.key.clone(), t.pinned)) + else { + return; + }; + { + let mut cfg = ctx.cfg.borrow_mut(); + // skriv i output-egna pins om skärmen har egna, annars i de globala + let has_own = cfg + .outputs + .get(connector) + .map(|o| o.pins.is_some()) + .unwrap_or(false); + let pins: &mut Vec = if has_own { + cfg.outputs + .get_mut(connector) + .and_then(|o| o.pins.as_mut()) + .expect("has_own kollades nyss") + } else { + &mut cfg.pins + }; + if pinned { + pins.retain(|p| p.to_lowercase() != key); + } else if !pins.iter().any(|p| p.to_lowercase() == key) { + pins.push(key.clone()); + } + if let Err(e) = config::save(&ctx.cfg_path, &cfg) { + warn!("kunde inte spara config: {e}"); + } + } + crate::dock::refresh_items(ctx); +} + +/// Popover som städar bort sig själv när den stängs. +fn autodispose(popover: &impl IsA) { + let p = popover.as_ref().clone(); + p.connect_closed(|p| { + let p = p.clone(); + glib::idle_add_local_once(move || p.unparent()); + }); +} + +/// Högerklicksmenyn på en app-ikon. +pub fn popup_item_menu(ctx: &Rc, item: &Rc, anchor: >k::Button) { + let cfg = ctx.cfg.borrow(); + let has_windows = !item.windows.is_empty(); + let all_min = has_windows && item.windows.iter().all(|w| w.minimized); + let all_max = has_windows && item.windows.iter().all(|w| w.maximized); + + let menu = gio::Menu::new(); + for entry in &cfg.context_menu { + match entry { + MenuItem::Minimize if has_windows => { + menu.append( + Some(if all_min { "Återställ" } else { "Minimera" }), + Some("dock.minimize"), + ); + } + MenuItem::Maximize if has_windows => { + menu.append( + Some(if all_max { "Avmaximera" } else { "Maximera" }), + Some("dock.maximize"), + ); + } + MenuItem::MoveToOutput if has_windows && wayfire_ipc::available() => { + let sub = gio::Menu::new(); + let docks = ctx.docks.borrow(); + for dock in docks.iter() { + let mi = gio::MenuItem::new(Some(&dock.connector), None); + mi.set_action_and_target_value( + Some("dock.move-to-output"), + Some(&dock.connector.to_variant()), + ); + sub.append_item(&mi); + } + menu.append_submenu(Some("Flytta till skärm"), &sub); + } + MenuItem::Pin => { + menu.append( + Some(if item.pinned { "Släpp pin" } else { "Pinna" }), + Some("dock.pin"), + ); + } + MenuItem::Close if has_windows => { + menu.append(Some("Stäng"), Some("dock.close")); + } + _ => {} + } + } + drop(cfg); + + // hitta skärmens connector via dockan som äger knappen + let connector = ctx + .docks + .borrow() + .iter() + .find(|d| anchor.ancestor(gtk::ApplicationWindow::static_type()) + .map(|w| w == *d.window.upcast_ref::()) + .unwrap_or(false)) + .map(|d| d.connector.clone()) + .unwrap_or_default(); + + *ctx.menu_target.borrow_mut() = Some(MenuTarget { + key: item.meta.key.clone(), + windows: item.windows.clone(), + pinned: item.pinned, + connector, + }); + + let popover = gtk::PopoverMenu::from_model(Some(&menu)); + popover.add_css_class("waydock-menu"); + popover.set_parent(anchor); + autodispose(&popover); + popover.popup(); +} + +/// Vänsterklick på grupp med flera fönster: lista fönstren. +pub fn popup_group_list(ctx: &Rc, item: &Rc, anchor: >k::Button) { + let popover = gtk::Popover::new(); + popover.add_css_class("waydock-menu"); + let list = gtk::Box::new(gtk::Orientation::Vertical, 2); + + for w in &item.windows { + let row = gtk::Button::new(); + row.add_css_class("flat"); + let title = if w.title.is_empty() { item.meta.display_name.clone() } else { w.title.clone() }; + let label = gtk::Label::new(Some(&title)); + label.set_ellipsize(gtk4::pango::EllipsizeMode::End); + label.set_max_width_chars(40); + label.set_halign(gtk::Align::Start); + row.set_child(Some(&label)); + let ctx2 = ctx.clone(); + let w2 = w.clone(); + let pop2 = popover.clone(); + row.connect_clicked(move |_| { + if let Some(s) = ctx2.service.borrow().as_ref() { + s.activate(&w2); + } + pop2.popdown(); + }); + list.append(&row); + } + popover.set_child(Some(&list)); + popover.set_parent(anchor); + autodispose(&popover); + popover.popup(); +} + +/// Högerklick på tom panelyta. +pub fn popup_panel_menu(ctx: &Rc, panel: >k::Box, x: f64, y: f64) { + let menu = gio::Menu::new(); + menu.append(Some("Inställningar…"), Some("panel.settings")); + menu.append(Some("Ladda om config"), Some("panel.reload")); + menu.append(Some("Avsluta waydock"), Some("panel.quit")); + + let group = gio::SimpleActionGroup::new(); + let open_settings = gio::SimpleAction::new("settings", None); + { + let ctx = ctx.clone(); + open_settings.connect_activate(move |_, _| settings::open(&ctx)); + } + group.add_action(&open_settings); + + let reload = gio::SimpleAction::new("reload", None); + { + let ctx = ctx.clone(); + reload.connect_activate(move |_, _| { + let fresh = config::load_or_create(&ctx.cfg_path); + *ctx.cfg.borrow_mut() = fresh; + if let Some(cb) = ctx.on_config_changed.borrow().as_ref() { + cb(); + } + }); + } + group.add_action(&reload); + + let quit = gio::SimpleAction::new("quit", None); + { + let ctx = ctx.clone(); + quit.connect_activate(move |_, _| ctx.app.quit()); + } + group.add_action(&quit); + + panel.insert_action_group("panel", Some(&group)); + + let popover = gtk::PopoverMenu::from_model(Some(&menu)); + popover.add_css_class("waydock-menu"); + popover.set_parent(panel); + popover.set_pointing_to(Some(&gdk::Rectangle::new(x as i32, y as i32, 1, 1))); + autodispose(&popover); + popover.popup(); +} + +/// Starta en app från menyn (används inte ännu — behålls för settings-GUI:t). +#[allow(dead_code)] +pub fn launch_item(item: &Item) { + icons::launch(&item.meta); +} diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..3bb7b2c --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,529 @@ +// Settings-GUI:t: ett GTK4-fönster som redigerar hela JSON-configen. +// Varje widgetändring uppdaterar delade configen, applicerar direkt +// (snabb feedback) och sparar filen debouncat — samma fil som kan +// handredigeras, filewatchern håller allt i synk. + +use std::cell::Cell; +use std::rc::Rc; +use std::time::Duration; + +use gtk4 as gtk; +use gtk4::gdk; +use gtk4::glib; +use gtk4::prelude::*; +use log::warn; + +use crate::config::{ + self, AutohideMode, ClickAction, HotspotAction, IndicatorStyle, Layer, MenuItem, + MiddleClick, Position, ScrollAction, +}; +use crate::dock::Ctx; + +thread_local! { + static OPEN_WINDOW: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; +} + +/// Uppdatera config + applicera + spara (debouncat 300 ms). +fn commit(ctx: &Rc, mutate: impl FnOnce(&mut config::Config)) { + mutate(&mut ctx.cfg.borrow_mut()); + if let Some(cb) = ctx.on_config_changed.borrow().as_ref() { + cb(); + } + let generation = ctx.save_generation.get() + 1; + ctx.save_generation.set(generation); + let ctx = ctx.clone(); + glib::timeout_add_local_once(Duration::from_millis(300), move || { + if ctx.save_generation.get() == generation { + if let Err(e) = config::save(&ctx.cfg_path, &ctx.cfg.borrow()) { + warn!("kunde inte spara config: {e}"); + } + } + }); +} + +fn hex_of(rgba: &gdk::RGBA) -> String { + format!( + "#{:02x}{:02x}{:02x}", + (rgba.red() * 255.0) as u8, + (rgba.green() * 255.0) as u8, + (rgba.blue() * 255.0) as u8 + ) +} + +// -- widgethjälpare ------------------------------------------------------ + +fn page() -> (gtk::ScrolledWindow, gtk::Box) { + let list = gtk::Box::new(gtk::Orientation::Vertical, 6); + list.set_margin_top(12); + list.set_margin_bottom(12); + list.set_margin_start(14); + list.set_margin_end(14); + let scroll = gtk::ScrolledWindow::new(); + scroll.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic); + scroll.set_child(Some(&list)); + (scroll, list) +} + +fn row(list: >k::Box, label: &str, widget: &impl IsA) { + let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 10); + let lbl = gtk::Label::new(Some(label)); + lbl.set_halign(gtk::Align::Start); + lbl.set_hexpand(true); + hbox.append(&lbl); + hbox.append(widget); + list.append(&hbox); +} + +fn heading(list: >k::Box, text: &str) { + let lbl = gtk::Label::new(None); + lbl.set_markup(&format!("{text}")); + lbl.set_halign(gtk::Align::Start); + lbl.set_margin_top(10); + list.append(&lbl); +} + +fn switch(ctx: &Rc, active: bool, set: impl Fn(&mut config::Config, bool) + 'static) -> gtk::Switch { + let sw = gtk::Switch::new(); + sw.set_active(active); + sw.set_valign(gtk::Align::Center); + let ctx = ctx.clone(); + let set = Rc::new(set); + sw.connect_active_notify(move |sw| { + let v = sw.is_active(); + let set = set.clone(); + commit(&ctx, move |c| set(c, v)); + }); + sw +} + +fn spin( + ctx: &Rc, + value: f64, + range: (f64, f64), + step: f64, + set: impl Fn(&mut config::Config, f64) + 'static, +) -> gtk::SpinButton { + let sb = gtk::SpinButton::with_range(range.0, range.1, step); + sb.set_value(value); + let ctx = ctx.clone(); + let set = Rc::new(set); + sb.connect_value_changed(move |sb| { + let v = sb.value(); + let set = set.clone(); + commit(&ctx, move |c| set(c, v)); + }); + sb +} + +fn scale01(ctx: &Rc, value: f64, set: impl Fn(&mut config::Config, f64) + 'static) -> gtk::Scale { + let sc = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 0.01); + sc.set_value(value); + sc.set_size_request(160, -1); + sc.set_draw_value(false); + let ctx = ctx.clone(); + let set = Rc::new(set); + sc.connect_value_changed(move |sc| { + let v = sc.value(); + let set = set.clone(); + commit(&ctx, move |c| set(c, v)); + }); + sc +} + +fn color_button( + ctx: &Rc, + hex: &str, + set: impl Fn(&mut config::Config, String) + 'static, +) -> gtk::ColorDialogButton { + let btn = gtk::ColorDialogButton::new(Some(gtk::ColorDialog::new())); + if let Ok(rgba) = gdk::RGBA::parse(hex) { + btn.set_rgba(&rgba); + } + let ctx = ctx.clone(); + let set = Rc::new(set); + btn.connect_rgba_notify(move |btn| { + let hex = hex_of(&btn.rgba()); + let set = set.clone(); + commit(&ctx, move |c| set(c, hex)); + }); + btn +} + +fn dropdown( + ctx: &Rc, + options: &[&str], + selected: usize, + set: impl Fn(&mut config::Config, usize) + 'static, +) -> gtk::DropDown { + let dd = gtk::DropDown::from_strings(options); + dd.set_selected(selected as u32); + let ctx = ctx.clone(); + let set = Rc::new(set); + dd.connect_selected_notify(move |dd| { + let i = dd.selected() as usize; + let set = set.clone(); + commit(&ctx, move |c| set(c, i)); + }); + dd +} + +// -- hotspot-hjälpare ---------------------------------------------------- + +fn hotspot_to_text(a: &Option) -> String { + match a { + Some(HotspotAction::Exec(cmd)) => format!("exec:{cmd}"), + Some(HotspotAction::Ipc(m)) => format!("ipc:{m}"), + None => String::new(), + } +} + +fn text_to_hotspot(text: &str) -> Option { + let t = text.trim(); + if let Some(cmd) = t.strip_prefix("exec:") { + Some(HotspotAction::Exec(cmd.trim().to_string())) + } else if let Some(m) = t.strip_prefix("ipc:") { + Some(HotspotAction::Ipc(m.trim().to_string())) + } else if t.is_empty() { + None + } else { + Some(HotspotAction::Exec(t.to_string())) + } +} + +fn hotspot_entry( + ctx: &Rc, + current: &Option, + set: impl Fn(&mut config::Config, Option) + 'static, +) -> gtk::Entry { + let entry = gtk::Entry::new(); + entry.set_text(&hotspot_to_text(current)); + entry.set_placeholder_text(Some("exec:kommando eller ipc:scale/toggle")); + entry.set_width_chars(24); + let ctx = ctx.clone(); + let set = Rc::new(set); + entry.connect_changed(move |e| { + let action = text_to_hotspot(&e.text()); + let set = set.clone(); + commit(&ctx, move |c| set(c, action)); + }); + entry +} + +// -- sidorna ------------------------------------------------------------- + +fn build_appearance_page(ctx: &Rc) -> gtk::ScrolledWindow { + let (scroll, list) = page(); + let a = ctx.cfg.borrow().appearance.clone(); + + heading(&list, "Panel"); + row(&list, "Panelfärg", &color_button(ctx, &a.background, |c, v| c.appearance.background = v)); + row(&list, "Transparens", &scale01(ctx, a.opacity, |c, v| c.appearance.opacity = v)); + row(&list, "Hörnradie", &spin(ctx, a.corner_radius as f64, (0.0, 40.0), 1.0, |c, v| c.appearance.corner_radius = v as u32)); + row(&list, "Accentfärg", &color_button(ctx, &a.accent, |c, v| c.appearance.accent = v)); + + heading(&list, "Kant & skugga"); + row(&list, "Kantfärg", &color_button(ctx, &a.border.color, |c, v| c.appearance.border.color = v)); + row(&list, "Kantopacitet", &scale01(ctx, a.border.opacity, |c, v| c.appearance.border.opacity = v)); + row(&list, "Kantbredd", &spin(ctx, a.border.width as f64, (0.0, 8.0), 1.0, |c, v| c.appearance.border.width = v as u32)); + row(&list, "Skugga", &switch(ctx, a.shadow.enabled, |c, v| c.appearance.shadow.enabled = v)); + row(&list, "Skuggstorlek", &spin(ctx, a.shadow.blur as f64, (0.0, 60.0), 1.0, |c, v| c.appearance.shadow.blur = v as u32)); + row(&list, "Skuggopacitet", &scale01(ctx, a.shadow.opacity, |c, v| c.appearance.shadow.opacity = v)); + + heading(&list, "Ikoner"); + row(&list, "Ikonstorlek", &spin(ctx, a.icon_size as f64, (16.0, 96.0), 2.0, |c, v| c.appearance.icon_size = v as i32)); + row(&list, "Ikonavstånd", &spin(ctx, a.icon_spacing as f64, (0.0, 32.0), 1.0, |c, v| c.appearance.icon_spacing = v as i32)); + row(&list, "Panel-padding", &spin(ctx, a.padding as f64, (0.0, 32.0), 1.0, |c, v| c.appearance.padding = v as i32)); + row(&list, "Hover-zoom", &switch(ctx, a.hover_zoom, |c, v| c.appearance.hover_zoom = v)); + row(&list, "Animationer", &switch(ctx, a.animations, |c, v| c.appearance.animations = v)); + let ind_idx = match a.indicator.style { + IndicatorStyle::Dot => 0, + IndicatorStyle::Line => 1, + IndicatorStyle::None => 2, + }; + row(&list, "Indikatorstil", &dropdown(ctx, &["Prickar", "Linje", "Ingen"], ind_idx, |c, i| { + c.appearance.indicator.style = match i { + 1 => IndicatorStyle::Line, + 2 => IndicatorStyle::None, + _ => IndicatorStyle::Dot, + }; + })); + row(&list, "Max indikatorer", &spin(ctx, a.indicator.max as f64, (1.0, 5.0), 1.0, |c, v| c.appearance.indicator.max = v as u32)); + + scroll +} + +fn build_layout_page(ctx: &Rc) -> gtk::ScrolledWindow { + let (scroll, list) = page(); + let cfg = ctx.cfg.borrow().clone(); + + heading(&list, "Placering"); + let pos_idx = match cfg.position { + Position::Bottom => 0, + Position::Top => 1, + Position::Left => 2, + Position::Right => 3, + }; + row(&list, "Kant", &dropdown(ctx, &["Nere", "Uppe", "Vänster", "Höger"], pos_idx, |c, i| { + c.position = match i { + 1 => Position::Top, + 2 => Position::Left, + 3 => Position::Right, + _ => Position::Bottom, + }; + })); + row(&list, "Centrerad ö", &switch(ctx, cfg.centered, |c, v| c.centered = v)); + row(&list, "Marginal från kant", &spin(ctx, cfg.margin as f64, (0.0, 64.0), 1.0, |c, v| c.margin = v as i32)); + let layer_idx = match cfg.layer { + Layer::Top => 0, + Layer::Overlay => 1, + Layer::Bottom => 2, + }; + row(&list, "Lager", &dropdown(ctx, &["Ovanpå fönster", "Alltid överst (overlay)", "Under fönster"], layer_idx, |c, i| { + c.layer = match i { + 1 => Layer::Overlay, + 2 => Layer::Bottom, + _ => Layer::Top, + }; + })); + row(&list, "Reservera plats (undvik fönster)", &switch(ctx, cfg.avoid_windows, |c, v| c.avoid_windows = v)); + + heading(&list, "Autohide"); + row(&list, "Aktiverad", &switch(ctx, cfg.autohide.enabled, |c, v| c.autohide.enabled = v)); + let mode_idx = match cfg.autohide.mode { + AutohideMode::Dodge => 0, + AutohideMode::Always => 1, + }; + row(&list, "Läge", &dropdown(ctx, &["Dodge (göm vid överlapp)", "Göm alltid"], mode_idx, |c, i| { + c.autohide.mode = if i == 1 { AutohideMode::Always } else { AutohideMode::Dodge }; + })); + row(&list, "Fördröjning (ms)", &spin(ctx, cfg.autohide.hide_delay_ms as f64, (0.0, 3000.0), 50.0, |c, v| c.autohide.hide_delay_ms = v as u32)); + row(&list, "Sensorbredd (px)", &spin(ctx, cfg.autohide.reveal_px as f64, (1.0, 16.0), 1.0, |c, v| c.autohide.reveal_px = v as i32)); + row(&list, "Animationstid (ms)", &spin(ctx, cfg.autohide.animation_ms as f64, (0.0, 1000.0), 25.0, |c, v| c.autohide.animation_ms = v as u32)); + + let note = gtk::Label::new(Some("Per-skärm-inställningar (unika pins, only_this_output)\nredigeras i config.json under \"outputs\".")); + note.add_css_class("dim-label"); + note.set_halign(gtk::Align::Start); + note.set_margin_top(12); + list.append(¬e); + + scroll +} + +fn build_behavior_page(ctx: &Rc) -> gtk::ScrolledWindow { + let (scroll, list) = page(); + let b = ctx.cfg.borrow().behavior.clone(); + let sp = ctx.cfg.borrow().special_buttons.clone(); + + heading(&list, "Klick"); + let click_idx = match b.click { + ClickAction::FocusOrMinimize => 0, + ClickAction::Focus => 1, + ClickAction::Cycle => 2, + }; + row(&list, "Vänsterklick", &dropdown(ctx, &["Fokusera/minimera", "Bara fokusera", "Växla fönster"], click_idx, |c, i| { + c.behavior.click = match i { + 1 => ClickAction::Focus, + 2 => ClickAction::Cycle, + _ => ClickAction::FocusOrMinimize, + }; + })); + let mid_idx = match b.middle_click { + MiddleClick::LaunchNew => 0, + MiddleClick::Close => 1, + MiddleClick::None => 2, + }; + row(&list, "Mittklick", &dropdown(ctx, &["Starta ny instans", "Stäng", "Inget"], mid_idx, |c, i| { + c.behavior.middle_click = match i { + 1 => MiddleClick::Close, + 2 => MiddleClick::None, + _ => MiddleClick::LaunchNew, + }; + })); + let scroll_idx = match b.scroll { + ScrollAction::CycleWindows => 0, + ScrollAction::None => 1, + }; + row(&list, "Skroll", &dropdown(ctx, &["Växla fönster", "Inget"], scroll_idx, |c, i| { + c.behavior.scroll = if i == 1 { ScrollAction::None } else { ScrollAction::CycleWindows }; + })); + + heading(&list, "Övrigt"); + row(&list, "Gruppera appar", &switch(ctx, b.group_apps, |c, v| c.behavior.group_apps = v)); + row(&list, "Tooltips", &switch(ctx, b.tooltips, |c, v| c.behavior.tooltips = v)); + row(&list, "Urgency-puls", &switch(ctx, b.urgent_pulse, |c, v| c.behavior.urgent_pulse = v)); + + heading(&list, "Specialknappar"); + row(&list, "Visa skrivbordet", &switch(ctx, sp.show_desktop, |c, v| c.special_buttons.show_desktop = v)); + row(&list, "Översikt (scale)", &switch(ctx, sp.overview, |c, v| c.special_buttons.overview = v)); + + heading(&list, "Högerklicksmenyn"); + let entries: [(&str, MenuItem); 5] = [ + ("Minimera", MenuItem::Minimize), + ("Maximera", MenuItem::Maximize), + ("Flytta till skärm", MenuItem::MoveToOutput), + ("Pinna/släpp", MenuItem::Pin), + ("Stäng", MenuItem::Close), + ]; + for (label, mi) in entries { + let present = ctx.cfg.borrow().context_menu.contains(&mi); + let mi2 = mi.clone(); + row(&list, label, &switch(ctx, present, move |c, v| { + if v { + if !c.context_menu.contains(&mi2) { + c.context_menu.push(mi2.clone()); + } + } else { + c.context_menu.retain(|m| *m != mi2); + } + })); + } + + scroll +} + +fn build_pins_page(ctx: &Rc) -> gtk::ScrolledWindow { + let (scroll, list) = page(); + + heading(&list, "Globala pins (.desktop-id, i ordning)"); + let pins_box = gtk::Box::new(gtk::Orientation::Vertical, 4); + list.append(&pins_box); + + fn rebuild_rows(ctx: &Rc, pins_box: >k::Box) { + while let Some(c) = pins_box.first_child() { + pins_box.remove(&c); + } + let pins = ctx.cfg.borrow().pins.clone(); + for (i, pin) in pins.iter().enumerate() { + let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 6); + let name = crate::icons::lookup_pin(pin) + .map(|m| m.display_name) + .unwrap_or_else(|| "⚠ hittas inte".into()); + let lbl = gtk::Label::new(Some(&format!("{pin} ({name})"))); + lbl.set_halign(gtk::Align::Start); + lbl.set_hexpand(true); + hbox.append(&lbl); + + let up = gtk::Button::from_icon_name("go-up-symbolic"); + up.add_css_class("flat"); + let remove = gtk::Button::from_icon_name("edit-delete-symbolic"); + remove.add_css_class("flat"); + hbox.append(&up); + hbox.append(&remove); + pins_box.append(&hbox); + + let ctx2 = ctx.clone(); + let pb2 = pins_box.clone(); + up.connect_clicked(move |_| { + if i > 0 { + commit(&ctx2, |c| c.pins.swap(i, i - 1)); + rebuild_rows(&ctx2, &pb2); + } + }); + let ctx3 = ctx.clone(); + let pb3 = pins_box.clone(); + remove.connect_clicked(move |_| { + commit(&ctx3, |c| { + c.pins.remove(i); + }); + rebuild_rows(&ctx3, &pb3); + }); + } + } + rebuild_rows(ctx, &pins_box); + + let add_box = gtk::Box::new(gtk::Orientation::Horizontal, 6); + let entry = gtk::Entry::new(); + entry.set_placeholder_text(Some("t.ex. org.kde.konsole")); + entry.set_hexpand(true); + let add = gtk::Button::with_label("Lägg till"); + add_box.append(&entry); + add_box.append(&add); + list.append(&add_box); + { + let ctx = ctx.clone(); + let pins_box = pins_box.clone(); + add.connect_clicked(move |_| { + let id = entry.text().trim().to_string(); + if id.is_empty() { + return; + } + if crate::icons::lookup_pin(&id).is_none() { + entry.add_css_class("error"); + return; + } + entry.remove_css_class("error"); + entry.set_text(""); + let id2 = id.clone(); + commit(&ctx, move |c| { + if !c.pins.contains(&id2) { + c.pins.push(id2.clone()); + } + }); + rebuild_rows(&ctx, &pins_box); + }); + } + + let note = gtk::Label::new(Some("Tips: hitta id med ls /usr/share/applications | grep -i \nPer-skärm-pins redigeras i config.json under \"outputs\".")); + note.add_css_class("dim-label"); + note.set_halign(gtk::Align::Start); + note.set_margin_top(12); + list.append(¬e); + + scroll +} + +fn build_hotspots_page(ctx: &Rc) -> gtk::ScrolledWindow { + let (scroll, list) = page(); + let h = ctx.cfg.borrow().hotspots.clone(); + + heading(&list, "Hot corners"); + row(&list, "Uppe vänster", &hotspot_entry(ctx, &h.corners.top_left, |c, v| c.hotspots.corners.top_left = v)); + row(&list, "Uppe höger", &hotspot_entry(ctx, &h.corners.top_right, |c, v| c.hotspots.corners.top_right = v)); + row(&list, "Nere vänster", &hotspot_entry(ctx, &h.corners.bottom_left, |c, v| c.hotspots.corners.bottom_left = v)); + row(&list, "Nere höger", &hotspot_entry(ctx, &h.corners.bottom_right, |c, v| c.hotspots.corners.bottom_right = v)); + + heading(&list, "Hot edges"); + row(&list, "Uppe", &hotspot_entry(ctx, &h.edges.top, |c, v| c.hotspots.edges.top = v)); + row(&list, "Nere", &hotspot_entry(ctx, &h.edges.bottom, |c, v| c.hotspots.edges.bottom = v)); + row(&list, "Vänster", &hotspot_entry(ctx, &h.edges.left, |c, v| c.hotspots.edges.left = v)); + row(&list, "Höger", &hotspot_entry(ctx, &h.edges.right, |c, v| c.hotspots.edges.right = v)); + + row(&list, "Dwell-tid (ms)", &spin(ctx, h.dwell_ms as f64, (0.0, 2000.0), 50.0, |c, v| c.hotspots.dwell_ms = v as u32)); + + let note = gtk::Label::new(Some("Format: exec: eller ipc: (t.ex. ipc:scale/toggle).\nTomt fält = av. IPC kräver Wayfires ipc-plugin.")); + note.add_css_class("dim-label"); + note.set_halign(gtk::Align::Start); + note.set_margin_top(12); + list.append(¬e); + + scroll +} + +/// Öppna settings-fönstret (återanvänder ett redan öppet). +pub fn open(ctx: &Rc) { + let existing = OPEN_WINDOW.with(|w| w.borrow().as_ref().and_then(|w| w.upgrade())); + if let Some(win) = existing { + win.present(); + return; + } + + let win = gtk::Window::new(); + win.set_title(Some("waydock — inställningar")); + win.set_default_size(520, 620); + + let notebook = gtk::Notebook::new(); + notebook.append_page(&build_appearance_page(ctx), Some(>k::Label::new(Some("Utseende")))); + notebook.append_page(&build_layout_page(ctx), Some(>k::Label::new(Some("Layout")))); + notebook.append_page(&build_behavior_page(ctx), Some(>k::Label::new(Some("Beteende")))); + notebook.append_page(&build_pins_page(ctx), Some(>k::Label::new(Some("Pins")))); + notebook.append_page(&build_hotspots_page(ctx), Some(>k::Label::new(Some("Hotspots")))); + win.set_child(Some(¬ebook)); + + OPEN_WINDOW.with(|w| *w.borrow_mut() = Some(win.downgrade())); + win.present(); +} + +// hindra att Cell-importen städas bort av misstag när sidor ändras +#[allow(unused)] +fn _keep(_: Cell) {} diff --git a/src/style.rs b/src/style.rs new file mode 100644 index 0000000..014628b --- /dev/null +++ b/src/style.rs @@ -0,0 +1,180 @@ +// Config → CSS. All theming (färger, transparens, skuggor, accent, +// storlekar, animationer) genereras här och laddas i en CssProvider som +// byts ut vid live-omladdning. + +use gtk4 as gtk; +use gtk4::gdk; + +use crate::config::{Config, IndicatorStyle}; + +/// "#rrggbb" + alpha → "rgba(r, g, b, a)". Trasig färg → grå. +fn rgba(hex: &str, alpha: f64) -> String { + let h = hex.trim_start_matches('#'); + let (r, g, b) = if h.len() == 6 { + ( + u8::from_str_radix(&h[0..2], 16).unwrap_or(0x88), + u8::from_str_radix(&h[2..4], 16).unwrap_or(0x88), + u8::from_str_radix(&h[4..6], 16).unwrap_or(0x88), + ) + } else { + (0x88, 0x88, 0x88) + }; + format!("rgba({r}, {g}, {b}, {alpha:.3})") +} + +pub fn build_css(cfg: &Config) -> String { + let a = &cfg.appearance; + let bg = rgba(&a.background, a.opacity); + let border = rgba(&a.border.color, a.border.opacity); + let accent = rgba(&a.accent, 1.0); + let accent_soft = rgba(&a.accent, 0.28); + let shadow = if a.shadow.enabled { + format!( + "box-shadow: 0 2px {}px {};", + a.shadow.blur, + rgba("#000000", a.shadow.opacity) + ) + } else { + String::new() + }; + let transition = if a.animations { + "transition: all 150ms ease;" + } else { + "transition: none;" + }; + let hover_zoom = if a.hover_zoom { "-gtk-icon-transform: scale(1.15);" } else { "" }; + let item_pad = (a.icon_spacing / 2).max(0); + let (ind_w, ind_h, ind_radius) = match a.indicator.style { + IndicatorStyle::Dot => (5, 5, 3), + IndicatorStyle::Line => (16, 3, 2), + IndicatorStyle::None => (0, 0, 0), + }; + + format!( + r#" +window.waydock {{ + background: transparent; +}} + +.dock-panel {{ + background-color: {bg}; + border-radius: {radius}px; + border: {bwidth}px solid {border}; + padding: {padding}px; + {shadow} +}} + +.dock-item {{ + background: transparent; + border: none; + border-radius: {item_radius}px; + padding: 4px; + margin: 0 {item_pad}px; + min-width: {icon}px; + min-height: {icon}px; + {transition} +}} + +.dock-item:hover {{ + background-color: {accent_soft}; +}} + +.dock-item:hover image {{ + {hover_zoom} +}} + +.dock-item image {{ + {transition} +}} + +.dock-item.urgent {{ + background-color: {accent_soft}; + animation: waydock-pulse 900ms ease-in-out infinite alternate; +}} + +@keyframes waydock-pulse {{ + from {{ background-color: transparent; }} + to {{ background-color: {accent_soft}; }} +}} + +.indicator-box {{ + min-height: {ind_h}px; +}} + +.indicator {{ + background-color: {accent}; + border-radius: {ind_radius}px; + min-width: {ind_w}px; + min-height: {ind_h}px; + margin: 0 1px; +}} + +.indicator.inactive {{ + background-color: {accent_dim}; +}} + +.group-badge {{ + background-color: {accent}; + color: #ffffff; + border-radius: 8px; + font-size: 9px; + font-weight: bold; + min-width: 14px; + min-height: 14px; + padding: 0 2px; +}} + +popover.waydock-menu contents {{ + background-color: {menu_bg}; + border-radius: 10px; + border: 1px solid {border}; +}} + +popover.waydock-menu modelbutton, +popover.waydock-menu button.flat {{ + color: #eeeeee; + border-radius: 6px; + padding: 6px 10px; +}} + +popover.waydock-menu modelbutton:hover, +popover.waydock-menu button.flat:hover {{ + background-color: {accent_soft}; +}} +"#, + bg = bg, + radius = a.corner_radius, + bwidth = a.border.width, + border = border, + padding = a.padding, + shadow = shadow, + item_radius = (a.corner_radius as i32 - 4).max(4), + item_pad = item_pad, + icon = a.icon_size + 8, + transition = transition, + hover_zoom = hover_zoom, + accent = accent, + accent_soft = accent_soft, + accent_dim = rgba(&a.accent, 0.45), + ind_h = ind_h, + ind_w = ind_w, + ind_radius = ind_radius, + menu_bg = rgba(&a.background, (a.opacity + 0.2).min(1.0)), + ) +} + +/// Ladda (eller byt ut) den globala CSS-providern. +pub fn apply(cfg: &Config, previous: Option<>k::CssProvider>) -> gtk::CssProvider { + let display = gdk::Display::default().expect("ingen display"); + if let Some(old) = previous { + gtk::style_context_remove_provider_for_display(&display, old); + } + let provider = gtk::CssProvider::new(); + provider.load_from_string(&build_css(cfg)); + gtk::style_context_add_provider_for_display( + &display, + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + provider +} diff --git a/src/toplevel.rs b/src/toplevel.rs new file mode 100644 index 0000000..5cdc35a --- /dev/null +++ b/src/toplevel.rs @@ -0,0 +1,413 @@ +// Egen Wayland-anslutning som talar zwlr_foreign_toplevel_manager_v1: +// fönsterlista + states in, aktivera/minimera/maximera/stäng ut. +// Anslutningens fd hängs in i GLib-mainloopen — helt händelsestyrt. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::os::fd::{AsFd, AsRawFd}; +use std::rc::Rc; + +use gtk4::glib; +use log::{debug, info, warn}; +use wayland_client::{ + backend::ObjectId, + event_created_child, + globals::{registry_queue_init, GlobalListContents}, + protocol::{wl_output, wl_registry, wl_seat}, + Connection, Dispatch, EventQueue, Proxy, QueueHandle, +}; +use wayland_protocols_wlr::foreign_toplevel::v1::client::{ + zwlr_foreign_toplevel_handle_v1::{self, ZwlrForeignToplevelHandleV1}, + zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1}, +}; + +/// Snapshot av ett fönster som UI:t jobbar med. +#[derive(Clone)] +pub struct ToplevelInfo { + pub id: ObjectId, + pub handle: ZwlrForeignToplevelHandleV1, + pub app_id: String, + pub title: String, + pub minimized: bool, + pub maximized: bool, + pub activated: bool, + pub outputs: Vec, +} + +struct ToplevelEntry { + handle: ZwlrForeignToplevelHandleV1, + app_id: String, + title: String, + minimized: bool, + maximized: bool, + activated: bool, + outputs: Vec, + initialized: bool, // första done har kommit +} + +struct OutputEntry { + global_name: u32, + name: Option, +} + +pub struct WlState { + toplevels: Vec<(ObjectId, ToplevelEntry)>, // ordning = skapelseordning + outputs: HashMap, + seat: Option, + dirty: bool, +} + +impl WlState { + fn entry_mut(&mut self, id: &ObjectId) -> Option<&mut ToplevelEntry> { + self.toplevels + .iter_mut() + .find(|(tid, _)| tid == id) + .map(|(_, e)| e) + } + + fn snapshot(&self) -> Vec { + self.toplevels + .iter() + .filter(|(_, e)| e.initialized) + .map(|(id, e)| ToplevelInfo { + id: id.clone(), + handle: e.handle.clone(), + app_id: e.app_id.clone(), + title: e.title.clone(), + minimized: e.minimized, + maximized: e.maximized, + activated: e.activated, + outputs: e + .outputs + .iter() + .filter_map(|oid| self.outputs.get(oid).and_then(|o| o.name.clone())) + .collect(), + }) + .collect() + } +} + +impl Dispatch for WlState { + fn event( + state: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _: &GlobalListContents, + _: &Connection, + qh: &QueueHandle, + ) { + match event { + wl_registry::Event::Global { name, interface, version } => { + if interface == "wl_output" { + let output: wl_output::WlOutput = + registry.bind(name, version.min(4), qh, ()); + state.outputs.insert( + output.id(), + OutputEntry { global_name: name, name: None }, + ); + } + } + wl_registry::Event::GlobalRemove { name } => { + state.outputs.retain(|_, o| o.global_name != name); + state.dirty = true; + } + _ => {} + } + } +} + +impl Dispatch for WlState { + fn event( + state: &mut Self, + output: &wl_output::WlOutput, + event: wl_output::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if let wl_output::Event::Name { name } = event { + debug!("output: {name}"); + if let Some(entry) = state.outputs.get_mut(&output.id()) { + entry.name = Some(name); + state.dirty = true; + } + } + } +} + +impl Dispatch for WlState { + fn event( + _: &mut Self, + _: &wl_seat::WlSeat, + _: wl_seat::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for WlState { + fn event( + state: &mut Self, + _: &ZwlrForeignToplevelManagerV1, + event: zwlr_foreign_toplevel_manager_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + match event { + zwlr_foreign_toplevel_manager_v1::Event::Toplevel { toplevel } => { + state.toplevels.push(( + toplevel.id(), + ToplevelEntry { + handle: toplevel, + app_id: String::new(), + title: String::new(), + minimized: false, + maximized: false, + activated: false, + outputs: Vec::new(), + initialized: false, + }, + )); + } + zwlr_foreign_toplevel_manager_v1::Event::Finished => { + warn!("compositorn stängde foreign-toplevel-strömmen"); + } + _ => {} + } + } + + event_created_child!(WlState, ZwlrForeignToplevelManagerV1, [ + zwlr_foreign_toplevel_manager_v1::EVT_TOPLEVEL_OPCODE => (ZwlrForeignToplevelHandleV1, ()), + ]); +} + +impl Dispatch for WlState { + fn event( + state: &mut Self, + handle: &ZwlrForeignToplevelHandleV1, + event: zwlr_foreign_toplevel_handle_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + use zwlr_foreign_toplevel_handle_v1::Event; + let id = handle.id(); + match event { + Event::AppId { app_id } => { + if let Some(e) = state.entry_mut(&id) { + e.app_id = app_id; + } + } + Event::Title { title } => { + if let Some(e) = state.entry_mut(&id) { + e.title = title; + } + } + Event::OutputEnter { output } => { + if let Some(e) = state.entry_mut(&id) { + e.outputs.push(output.id()); + } + } + Event::OutputLeave { output } => { + if let Some(e) = state.entry_mut(&id) { + e.outputs.retain(|o| *o != output.id()); + } + } + Event::State { state: raw } => { + use zwlr_foreign_toplevel_handle_v1::State as S; + let mut min = false; + let mut max = false; + let mut act = false; + for chunk in raw.chunks_exact(4) { + let v = u32::from_ne_bytes(chunk.try_into().unwrap()); + match S::try_from(v) { + Ok(S::Minimized) => min = true, + Ok(S::Maximized) => max = true, + Ok(S::Activated) => act = true, + _ => {} + } + } + if let Some(e) = state.entry_mut(&id) { + e.minimized = min; + e.maximized = max; + e.activated = act; + } + } + Event::Done => { + if let Some(e) = state.entry_mut(&id) { + e.initialized = true; + } + state.dirty = true; + } + Event::Closed => { + state.toplevels.retain(|(tid, _)| *tid != id); + handle.destroy(); + state.dirty = true; + } + _ => {} + } + } +} + +/// Handtag som UI:t använder för att läsa fönsterlistan och skicka requests. +pub struct ToplevelService { + state: Rc>, + queue: Rc>>, + /// fönster som "visa skrivbord" minimerade, för återställning + desktop_stash: RefCell>, +} + +impl ToplevelService { + pub fn snapshot(&self) -> Vec { + self.state.borrow().snapshot() + } + + fn flush(&self) { + if let Err(e) = self.queue.borrow().flush() { + warn!("wayland-flush misslyckades: {e}"); + } + } + + pub fn activate(&self, info: &ToplevelInfo) { + if let Some(seat) = self.state.borrow().seat.as_ref() { + info.handle.activate(seat); + } + self.flush(); + } + + pub fn set_minimized(&self, info: &ToplevelInfo, min: bool) { + if min { + info.handle.set_minimized(); + } else { + info.handle.unset_minimized(); + } + self.flush(); + } + + pub fn set_maximized(&self, info: &ToplevelInfo, max: bool) { + if max { + info.handle.set_maximized(); + } else { + info.handle.unset_maximized(); + } + self.flush(); + } + + pub fn close(&self, info: &ToplevelInfo) { + info.handle.close(); + self.flush(); + } + + /// Visa skrivbord: minimera allt; andra klicket återställer det vi gömde. + pub fn toggle_show_desktop(&self) { + let snap = self.snapshot(); + let mut stash = self.desktop_stash.borrow_mut(); + let visible: Vec<&ToplevelInfo> = snap.iter().filter(|t| !t.minimized).collect(); + if visible.is_empty() && !stash.is_empty() { + for t in &snap { + if stash.contains(&t.id) { + t.handle.unset_minimized(); + } + } + stash.clear(); + } else { + *stash = visible.iter().map(|t| t.id.clone()).collect(); + for t in visible { + t.handle.set_minimized(); + } + } + drop(stash); + self.flush(); + } +} + +/// Starta tjänsten och häng in fd:n i GLib-mainloopen. +/// `on_update` körs varje gång fönsterlistan ändrats. +pub fn start(on_update: Rc) -> Result, String> { + let conn = Connection::connect_to_env().map_err(|e| format!("wayland: {e}"))?; + let (globals, mut queue) = + registry_queue_init::(&conn).map_err(|e| format!("registry: {e}"))?; + let qh = queue.handle(); + + let mut state = WlState { + toplevels: Vec::new(), + outputs: HashMap::new(), + seat: None, + dirty: false, + }; + + // befintliga outputs + seat binds här; nya outputs kommer via registry-events + globals.contents().with_list(|list| { + for g in list { + if g.interface == "wl_output" { + let output: wl_output::WlOutput = + globals.registry().bind(g.name, g.version.min(4), &qh, ()); + state + .outputs + .insert(output.id(), OutputEntry { global_name: g.name, name: None }); + } + } + }); + state.seat = globals.bind::(&qh, 1..=9, ()).ok(); + + let _manager: ZwlrForeignToplevelManagerV1 = globals + .bind(&qh, 1..=3, ()) + .map_err(|e| format!("compositorn saknar zwlr_foreign_toplevel_manager_v1: {e}"))?; + + queue + .roundtrip(&mut state) + .map_err(|e| format!("roundtrip: {e}"))?; + // en runda till så namn/states från nyskapade objekt hinner in + queue + .roundtrip(&mut state) + .map_err(|e| format!("roundtrip 2: {e}"))?; + state.dirty = false; + + info!( + "toplevel-tjänst uppe: {} fönster, {} outputs", + state.toplevels.len(), + state.outputs.len() + ); + + let state = Rc::new(RefCell::new(state)); + let queue = Rc::new(RefCell::new(queue)); + let service = Rc::new(ToplevelService { + state: state.clone(), + queue: queue.clone(), + desktop_stash: RefCell::new(Vec::new()), + }); + + let fd = conn.as_fd().as_raw_fd(); + { + let conn = conn.clone(); + let state = state.clone(); + let queue = queue.clone(); + glib::source::unix_fd_add_local(fd, glib::IOCondition::IN, move |_, _| { + { + let mut st = state.borrow_mut(); + let mut q = queue.borrow_mut(); + if let Some(guard) = conn.prepare_read() { + let _ = guard.read(); + } + if let Err(e) = q.dispatch_pending(&mut st) { + warn!("wayland-dispatch: {e}"); + } + let _ = q.flush(); + } + let is_dirty = { + let mut st = state.borrow_mut(); + std::mem::take(&mut st.dirty) + }; + if is_dirty { + on_update(); + } + glib::ControlFlow::Continue + }); + } + + // initial UI-uppdatering + Ok(service) +} diff --git a/src/wayfire_ipc.rs b/src/wayfire_ipc.rs new file mode 100644 index 0000000..b9f3b8c --- /dev/null +++ b/src/wayfire_ipc.rs @@ -0,0 +1,129 @@ +// Minimal synkron klient mot Wayfires IPC-socket ($WAYFIRE_SOCKET). +// Protokoll: 4 byte längd (LE) + JSON, åt båda hållen. +// Används för scale/expo-toggle, flytta-fönster-till-skärm och +// (för dodge) fönstergeometri. Allt är frivilligt — saknas socketen +// göms funktionerna som behöver den. + +use std::io::{Read, Write}; +use std::os::unix::net::UnixStream; +use std::time::Duration; + +use log::{debug, warn}; +use serde_json::{json, Value}; + +pub fn socket_path() -> Option { + if let Ok(p) = std::env::var("WAYFIRE_SOCKET") { + return Some(p); + } + // ipc-pluginen aktiverad efter sessionsstart → env saknas men + // socketen ligger på standardvägen /tmp/wayfire-.socket + let display = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-1".into()); + let fallback = format!("/tmp/wayfire-{display}.socket"); + std::path::Path::new(&fallback).exists().then_some(fallback) +} + +pub fn available() -> bool { + socket_path().is_some() +} + +/// Skicka ett IPC-anrop och vänta (kort) på svar. +pub fn call(method: &str, data: Value) -> Result { + let path = socket_path().ok_or("WAYFIRE_SOCKET saknas")?; + let mut stream = UnixStream::connect(&path).map_err(|e| format!("connect: {e}"))?; + stream + .set_read_timeout(Some(Duration::from_millis(500))) + .ok(); + stream + .set_write_timeout(Some(Duration::from_millis(500))) + .ok(); + + let mut msg = json!({ "method": method }); + if !data.is_null() { + msg["data"] = data; + } + let payload = serde_json::to_vec(&msg).map_err(|e| e.to_string())?; + let len = (payload.len() as u32).to_le_bytes(); + stream.write_all(&len).map_err(|e| format!("write: {e}"))?; + stream + .write_all(&payload) + .map_err(|e| format!("write: {e}"))?; + + let mut len_buf = [0u8; 4]; + stream + .read_exact(&mut len_buf) + .map_err(|e| format!("read len: {e}"))?; + let n = u32::from_le_bytes(len_buf) as usize; + if n > 16 * 1024 * 1024 { + return Err("orimligt stort IPC-svar".into()); + } + let mut buf = vec![0u8; n]; + stream + .read_exact(&mut buf) + .map_err(|e| format!("read: {e}"))?; + let value: Value = serde_json::from_slice(&buf).map_err(|e| e.to_string())?; + debug!("ipc {method} -> {value}"); + Ok(value) +} + +fn call_logged(method: &str, data: Value) { + if let Err(e) = call(method, data) { + warn!("wayfire-ipc {method}: {e}"); + } +} + +/// Toggla scale-vyn (Exposé) — på alla skärmar om `all` är satt. +pub fn toggle_scale() { + call_logged("scale/toggle", Value::Null); +} + +#[allow(dead_code)] // används av hotspots (M6) +pub fn toggle_expo() { + call_logged("expo/toggle", Value::Null); +} + +/// Lista Wayfires vyer (kräver ipc-rules-pluginen). Ger geometri m.m. +pub fn list_views() -> Option> { + call("window-rules/list-views", Value::Null) + .ok() + .and_then(|v| v.as_array().cloned()) +} + +pub fn list_outputs() -> Option> { + call("window-rules/list-outputs", Value::Null) + .ok() + .and_then(|v| v.as_array().cloned()) +} + +/// Hitta Wayfire-view-id för (app_id, title) — bästa möjliga matchning +/// eftersom wlr-protokollet och IPC:n inte delar id:n. +pub fn find_view_id(app_id: &str, title: &str) -> Option { + let views = list_views()?; + let matches: Vec<&Value> = views + .iter() + .filter(|v| { + v["app-id"].as_str() == Some(app_id) + && (v["title"].as_str() == Some(title) || title.is_empty()) + }) + .collect(); + matches.first().and_then(|v| v["id"].as_i64()) +} + +/// Flytta en vy till en annan output (namn t.ex. "DP-1"). +pub fn move_view_to_output(view_id: i64, output_name: &str) { + let Some(outputs) = list_outputs() else { + warn!("wayfire-ipc: kunde inte lista outputs"); + return; + }; + let Some(output_id) = outputs + .iter() + .find(|o| o["name"].as_str() == Some(output_name)) + .and_then(|o| o["id"].as_i64()) + else { + warn!("wayfire-ipc: okänd output {output_name}"); + return; + }; + call_logged( + "window-rules/configure-view", + json!({ "id": view_id, "output_id": output_id }), + ); +}