waydock v0.1: panel, pins, fönstergrupper, menyer, settings-GUI, live-config
Some checks failed
check / check (push) Failing after 52s
Some checks failed
check / check (push) Failing after 52s
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 <noreply@anthropic.com>
This commit is contained in:
38
.gitea/workflows/check.yaml
Normal file
38
.gitea/workflows/check.yaml
Normal file
@@ -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
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
build/
|
||||||
|
target/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
14
.vscode/launch.json
vendored
Normal file
14
.vscode/launch.json
vendored
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
37
.vscode/tasks.json
vendored
Normal file
37
.vscode/tasks.json
vendored
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1319
Cargo.lock
generated
Normal file
1319
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
Cargo.toml
Normal file
25
Cargo.toml
Normal file
@@ -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"
|
||||||
61
README.md
Normal file
61
README.md
Normal file
@@ -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.
|
||||||
67
doc/architecture.md
Normal file
67
doc/architecture.md
Normal file
@@ -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
|
||||||
82
doc/plan.md
Normal file
82
doc/plan.md
Normal file
@@ -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.
|
||||||
109
doc/usage.md
Normal file
109
doc/usage.md
Normal file
@@ -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 <app>`.
|
||||||
25
install.sh
Normal file
25
install.sh
Normal file
@@ -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)"
|
||||||
56
release.sh
Normal file
56
release.sh
Normal file
@@ -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"
|
||||||
434
src/config.rs
Normal file
434
src/config.rs
Normal file
@@ -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<bool>,
|
||||||
|
pub only_this_output: Option<bool>,
|
||||||
|
pub pins: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<HotspotAction>,
|
||||||
|
pub top_right: Option<HotspotAction>,
|
||||||
|
pub bottom_left: Option<HotspotAction>,
|
||||||
|
pub bottom_right: Option<HotspotAction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Edges {
|
||||||
|
pub top: Option<HotspotAction>,
|
||||||
|
pub bottom: Option<HotspotAction>,
|
||||||
|
pub left: Option<HotspotAction>,
|
||||||
|
pub right: Option<HotspotAction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
}
|
||||||
|
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<MenuItem>,
|
||||||
|
pub pins: Vec<String>,
|
||||||
|
pub special_buttons: SpecialButtons,
|
||||||
|
pub outputs: HashMap<String, OutputConfig>,
|
||||||
|
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::<Config>(&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<dyn Fn()>) {
|
||||||
|
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<notify::Event>| {
|
||||||
|
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<RefCell<Config>>;
|
||||||
402
src/dock.rs
Normal file
402
src/dock.rs
Normal file
@@ -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<Option<Rc<ToplevelService>>>,
|
||||||
|
pub docks: RefCell<Vec<Dock>>,
|
||||||
|
/// målet för den öppna kontextmenyn (menu.rs läser)
|
||||||
|
pub menu_target: RefCell<Option<menu::MenuTarget>>,
|
||||||
|
pub app: gtk::Application,
|
||||||
|
/// körs när configen ändrats (sätts av main; applicerar CSS/rebuild)
|
||||||
|
pub on_config_changed: RefCell<Option<Rc<dyn Fn()>>>,
|
||||||
|
/// generationsräknare för settings-GUI:ts debouncade sparningar
|
||||||
|
pub save_generation: std::cell::Cell<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ToplevelInfo>,
|
||||||
|
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<Ctx>) {
|
||||||
|
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::<gdk::Monitor>().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<Ctx>, 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<Item> {
|
||||||
|
let cfg = ctx.cfg.borrow();
|
||||||
|
let only_here = cfg.only_this_output(connector);
|
||||||
|
let group = cfg.behavior.group_apps;
|
||||||
|
|
||||||
|
let mut items: Vec<Item> = 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<Ctx>) {
|
||||||
|
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<Ctx>, 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<Ctx>, item: Rc<Item>) -> 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<Ctx>, item: &Rc<Item>, 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<Ctx>, item: &Rc<Item>) {
|
||||||
|
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<Ctx>, 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]);
|
||||||
|
}
|
||||||
107
src/icons.rs
Normal file
107
src/icons.rs
Normal file
@@ -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<gio::Icon>,
|
||||||
|
pub app_info: Option<gio::DesktopAppInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static CACHE: RefCell<HashMap<String, AppMeta>> = RefCell::new(HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_desktop(app_id: &str) -> Option<gio::DesktopAppInfo> {
|
||||||
|
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<gio::Icon> = 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<AppMeta> {
|
||||||
|
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
|
||||||
|
}
|
||||||
232
src/main.rs
Normal file
232
src/main.rs
Normal file
@@ -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<PathBuf>,
|
||||||
|
log: Option<String>,
|
||||||
|
log_file: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <fil> annan configfil (default ~/.config/waydock/config.json)\n \
|
||||||
|
--log <nivå> error|warn|info|debug|trace (även env WAYDOCK_LOG)\n \
|
||||||
|
--log-file <fil> 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<Box<dyn simplelog::SharedLogger>> = 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::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<RefCell<Option<gtk::CssProvider>>> = 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<dyn Fn()> = 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<dyn Fn()> = 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
316
src/menu.rs
Normal file
316
src/menu.rs
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
// Kontextmenyer: högerklick 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<ToplevelInfo>,
|
||||||
|
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<Ctx>, 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::<String>()) 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<Ctx>, 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<String> = 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<gtk::Popover>) {
|
||||||
|
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<Ctx>, item: &Rc<Item>, 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::<gtk::Widget>())
|
||||||
|
.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<Ctx>, item: &Rc<Item>, 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<Ctx>, 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);
|
||||||
|
}
|
||||||
529
src/settings.rs
Normal file
529
src/settings.rs
Normal file
@@ -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<Option<glib::WeakRef<gtk::Window>>> =
|
||||||
|
const { std::cell::RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uppdatera config + applicera + spara (debouncat 300 ms).
|
||||||
|
fn commit(ctx: &Rc<Ctx>, 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<gtk::Widget>) {
|
||||||
|
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!("<b>{text}</b>"));
|
||||||
|
lbl.set_halign(gtk::Align::Start);
|
||||||
|
lbl.set_margin_top(10);
|
||||||
|
list.append(&lbl);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn switch(ctx: &Rc<Ctx>, 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<Ctx>,
|
||||||
|
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<Ctx>, 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<Ctx>,
|
||||||
|
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<Ctx>,
|
||||||
|
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<HotspotAction>) -> 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<HotspotAction> {
|
||||||
|
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<Ctx>,
|
||||||
|
current: &Option<HotspotAction>,
|
||||||
|
set: impl Fn(&mut config::Config, Option<HotspotAction>) + '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<Ctx>) -> 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<Ctx>) -> 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<Ctx>) -> 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<Ctx>) -> 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<Ctx>, 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 <app>\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<Ctx>) -> 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:<kommando> eller ipc:<metod> (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<Ctx>) {
|
||||||
|
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<u8>) {}
|
||||||
180
src/style.rs
Normal file
180
src/style.rs
Normal file
@@ -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
|
||||||
|
}
|
||||||
413
src/toplevel.rs
Normal file
413
src/toplevel.rs
Normal file
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ToplevelEntry {
|
||||||
|
handle: ZwlrForeignToplevelHandleV1,
|
||||||
|
app_id: String,
|
||||||
|
title: String,
|
||||||
|
minimized: bool,
|
||||||
|
maximized: bool,
|
||||||
|
activated: bool,
|
||||||
|
outputs: Vec<ObjectId>,
|
||||||
|
initialized: bool, // första done har kommit
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OutputEntry {
|
||||||
|
global_name: u32,
|
||||||
|
name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WlState {
|
||||||
|
toplevels: Vec<(ObjectId, ToplevelEntry)>, // ordning = skapelseordning
|
||||||
|
outputs: HashMap<ObjectId, OutputEntry>,
|
||||||
|
seat: Option<wl_seat::WlSeat>,
|
||||||
|
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<ToplevelInfo> {
|
||||||
|
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<wl_registry::WlRegistry, GlobalListContents> for WlState {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
registry: &wl_registry::WlRegistry,
|
||||||
|
event: wl_registry::Event,
|
||||||
|
_: &GlobalListContents,
|
||||||
|
_: &Connection,
|
||||||
|
qh: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
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<wl_output::WlOutput, ()> for WlState {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
output: &wl_output::WlOutput,
|
||||||
|
event: wl_output::Event,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
_: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
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<wl_seat::WlSeat, ()> for WlState {
|
||||||
|
fn event(
|
||||||
|
_: &mut Self,
|
||||||
|
_: &wl_seat::WlSeat,
|
||||||
|
_: wl_seat::Event,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
_: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<ZwlrForeignToplevelManagerV1, ()> for WlState {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
_: &ZwlrForeignToplevelManagerV1,
|
||||||
|
event: zwlr_foreign_toplevel_manager_v1::Event,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
_: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
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<ZwlrForeignToplevelHandleV1, ()> for WlState {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
handle: &ZwlrForeignToplevelHandleV1,
|
||||||
|
event: zwlr_foreign_toplevel_handle_v1::Event,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
_: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
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<RefCell<WlState>>,
|
||||||
|
queue: Rc<RefCell<EventQueue<WlState>>>,
|
||||||
|
/// fönster som "visa skrivbord" minimerade, för återställning
|
||||||
|
desktop_stash: RefCell<Vec<ObjectId>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToplevelService {
|
||||||
|
pub fn snapshot(&self) -> Vec<ToplevelInfo> {
|
||||||
|
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<dyn Fn()>) -> Result<Rc<ToplevelService>, String> {
|
||||||
|
let conn = Connection::connect_to_env().map_err(|e| format!("wayland: {e}"))?;
|
||||||
|
let (globals, mut queue) =
|
||||||
|
registry_queue_init::<WlState>(&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::<wl_seat::WlSeat, _, _>(&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)
|
||||||
|
}
|
||||||
129
src/wayfire_ipc.rs
Normal file
129
src/wayfire_ipc.rs
Normal file
@@ -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<String> {
|
||||||
|
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-<display>.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<Value, String> {
|
||||||
|
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<Vec<Value>> {
|
||||||
|
call("window-rules/list-views", Value::Null)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_array().cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_outputs() -> Option<Vec<Value>> {
|
||||||
|
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<i64> {
|
||||||
|
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 }),
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user