From 558c178ec4dbf4684a0cd558cd61f0d148e36258 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 30 Aug 2026 00:25:05 +0200 Subject: [PATCH] =?UTF-8?q?Per-sk=C3=A4rm-inst=C3=A4llningar=20med=20sk?= =?UTF-8?q?=C3=A4rmv=C3=A4ljare,=20kant-i-kant-marginaler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - outputs{} är nu generiska JSON-överridar: vilket configfält som helst kan sättas per skärm och deep-mergas över den globala configen (Config::for_output). Docka, autohide, dodge, beteende, hotspots och CSS (scopat på window.o-) läser skärmens effektiva config. - Settings har skärmväljare överst: klickbar förhandsgranskning av skärmarna + deras barer, 'Alla skärmar' (standard) eller enskild skärm. Bara ändrade fält sparas som override (json-diff); återställ-knapp tar bort skärmens överridar. * = har egna. - Marginaler: skuggutrymmet läggs inte längre på förankrade sidor så 0 = kant i kant; minusvärden tillåts (A ner till -64, B/C -256). Co-Authored-By: Claude Fable 5 --- doc/usage.md | 22 ++- src/config.rs | 97 +++++++++++--- src/dock.rs | 112 ++++++++++------ src/hotspots.rs | 77 ++++++----- src/main.rs | 2 +- src/menu.rs | 29 ++-- src/settings.rs | 345 ++++++++++++++++++++++++++++++++++++++++++++---- src/style.rs | 92 ++++++++----- 8 files changed, 609 insertions(+), 167 deletions(-) diff --git a/doc/usage.md b/doc/usage.md index 427069d..3eec5c6 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -35,6 +35,13 @@ Alla ändringar i filen slår igenom **live** — ingen omstart behövs. Högerklick på tom yta → **Inställningar…** Ändringar skrivs direkt till JSON-filen och appliceras live. GUI:t och handredigering kan blandas fritt. +Överst finns **skärmväljaren** med en klickbar förhandsgranskning av +skärmarna och deras barer. "Alla skärmar" (standard) redigerar den +globala configen; välj en skärm för att ge just den egna inställningar — +bara fälten du ändrar sparas som override (`outputs` i JSON:en), resten +följer globala. `*` i förhandsgranskningen = skärmen har egna +inställningar; "Återställ skärmen till globalt" tar bort dem. + ## Configreferens (`config.json`) ```jsonc @@ -42,9 +49,11 @@ JSON-filen och appliceras live. GUI:t och handredigering kan blandas fritt. "position": "bottom", // bottom | top | left | right "alignment": "center", // center | start | end — var längs kanten ön ligger "expand": false, // true = täck hela kanten (panelen fyller bredden/höjden) - "margin": 8, // A: px från kanten baren ligger mot + "margin": 8, // A: px från kanten baren ligger mot (minus = utanför) "margin_start": 0, // B: marginal i början av kanten (vänster/topp) "margin_end": 0, // C: marginal i slutet av kanten (höger/botten) + // skuggutrymme läggs aldrig på förankrade sidor, + // så 0 = verkligen kant i kant "layer": "top", // top | overlay | bottom (always-on-top = overlay) "avoid_windows": false, // true = reservera plats (exclusive zone) @@ -81,11 +90,16 @@ JSON-filen och appliceras live. GUI:t och handredigering kan blandas fritt. "pins": ["firefox", "org.kde.konsole", "org.kde.dolphin"], "special_buttons": { "show_desktop": true, "overview": true }, - "outputs": { // per-skärm-överstyrning (nyckel = connector) + "outputs": { // per-skärm-överridar (nyckel = connector). + // VILKET configfält som helst kan läggas här och gäller då bara den + // skärmen (deep-merge över globala). Extranycklar: "enabled" och + // "only_this_output" (visa bara fönster på den här skärmen). "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 + "only_this_output": true, + "position": "left", + "pins": ["steam", "discord"], + "appearance": { "accent": "#5fb878" } } }, diff --git a/src/config.rs b/src/config.rs index 0797d81..0b60136 100644 --- a/src/config.rs +++ b/src/config.rs @@ -197,13 +197,10 @@ impl Default for Behavior { } } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct OutputConfig { - pub enabled: Option, - pub only_this_output: Option, - pub pins: Option>, -} +// Per-skärm-överridar: en partiell Config i JSON-form som deep-mergas +// över den globala. Extranycklarna "enabled" och "only_this_output" är +// skärm-specifika och läses separat (de finns inte i Config). +pub type OutputOverride = serde_json::Value; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] @@ -316,7 +313,7 @@ pub struct Config { pub context_menu: Vec, pub pins: Vec, pub special_buttons: SpecialButtons, - pub outputs: HashMap, + pub outputs: HashMap, pub autohide: Autohide, pub hotspots: Hotspots, pub log: LogConfig, @@ -356,26 +353,94 @@ impl Default for Config { } } +/// Deep-merge: värden i `patch` skrivs in i `base` (objekt rekursivt, +/// allt annat ersätts). +pub fn json_merge(base: &mut serde_json::Value, patch: &serde_json::Value) { + if let (Some(base_map), Some(patch_map)) = (base.as_object_mut(), patch.as_object()) { + for (k, v) in patch_map { + match base_map.get_mut(k) { + Some(slot) if slot.is_object() && v.is_object() => json_merge(slot, v), + _ => { + base_map.insert(k.clone(), v.clone()); + } + } + } + } else { + *base = patch.clone(); + } +} + +/// Minimal diff: objektet med bara de löv som skiljer `after` från `before`. +/// Null om inget skiljer. +pub fn json_diff(before: &serde_json::Value, after: &serde_json::Value) -> serde_json::Value { + use serde_json::Value; + if before == after { + return Value::Null; + } + if let (Some(b), Some(a)) = (before.as_object(), after.as_object()) { + let mut out = serde_json::Map::new(); + for (k, av) in a { + match b.get(k) { + Some(bv) => { + let d = json_diff(bv, av); + if !d.is_null() { + out.insert(k.clone(), d); + } + } + None => { + out.insert(k.clone(), av.clone()); + } + } + } + if out.is_empty() { + Value::Null + } else { + Value::Object(out) + } + } else { + after.clone() + } +} + 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) + /// Effektiv config för en skärm: global + per-skärm-överridar mergade. + pub fn for_output(&self, output: &str) -> Config { + match self.outputs.get(output) { + None => self.clone(), + Some(over) => { + let mut base = serde_json::to_value(self).expect("config serialiserbar"); + json_merge(&mut base, over); + match serde_json::from_value::(base) { + Ok(mut cfg) => { + // undvik rekursiva överridar + cfg.outputs = self.outputs.clone(); + cfg + } + Err(e) => { + warn!("trasig override för {output} ({e}) — kör globalt"); + self.clone() + } + } + } + } + } + + /// Effektiva pins för en viss skärm. + pub fn pins_for(&self, output: &str) -> Vec { + self.for_output(output).pins } pub fn output_enabled(&self, output: &str) -> bool { self.outputs .get(output) - .and_then(|o| o.enabled) + .and_then(|o| o["enabled"].as_bool()) .unwrap_or(true) } pub fn only_this_output(&self, output: &str) -> bool { self.outputs .get(output) - .and_then(|o| o.only_this_output) + .and_then(|o| o["only_this_output"].as_bool()) .unwrap_or(false) } } diff --git a/src/dock.rs b/src/dock.rs index d5d218a..e3aa74d 100644 --- a/src/dock.rs +++ b/src/dock.rs @@ -16,8 +16,8 @@ use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; use log::{debug, info}; use crate::config::{ - Alignment, AutohideMode, ClickAction, GroupPopup, Layer as CfgLayer, MiddleClick, - Position, ScrollAction, SharedConfig, + Alignment, AutohideMode, ClickAction, Config, GroupPopup, Layer as CfgLayer, + MiddleClick, Position, ScrollAction, SharedConfig, }; use crate::icons::{self, AppMeta}; use crate::menu; @@ -90,8 +90,7 @@ fn side_edges(pos: &Position) -> (Edge, Edge) { } } -fn configure_layer(win: >k::ApplicationWindow, ctx: &Ctx, monitor: &gdk::Monitor) { - let cfg = ctx.cfg.borrow(); +fn configure_layer(win: >k::ApplicationWindow, cfg: &Config, monitor: &gdk::Monitor) { win.init_layer_shell(); win.set_namespace(Some("waydock")); win.set_monitor(Some(monitor)); @@ -167,9 +166,10 @@ pub fn rebuild_all(ctx: &Rc) { } fn build_dock(ctx: &Rc, monitor: &gdk::Monitor, connector: String) -> Rc { - let cfg = ctx.cfg.borrow(); + let cfg = ctx.cfg.borrow().for_output(&connector); let win = gtk::ApplicationWindow::new(&ctx.app); win.add_css_class("waydock"); + win.add_css_class(&crate::style::output_class(&connector)); let orient = orientation(&cfg.position); let panel = gtk::Box::new(orient, 0); @@ -199,17 +199,46 @@ fn build_dock(ctx: &Rc, monitor: &gdk::Monitor, connector: String) -> Rc ( + pad, + 0, + if anchored_start { 0 } else { pad }, + if anchored_end { 0 } else { pad }, + ), + Position::Top => ( + 0, + pad, + if anchored_start { 0 } else { pad }, + if anchored_end { 0 } else { pad }, + ), + Position::Left => ( + if anchored_start { 0 } else { pad }, + if anchored_end { 0 } else { pad }, + 0, + pad, + ), + Position::Right => ( + if anchored_start { 0 } else { pad }, + if anchored_end { 0 } else { pad }, + pad, + 0, + ), + }; + outer.set_margin_top(top); + outer.set_margin_bottom(bottom); + outer.set_margin_start(start); + outer.set_margin_end(end); outer.append(&panel); // revealern ger autohide-animationen @@ -229,9 +258,8 @@ fn build_dock(ctx: &Rc, monitor: &gdk::Monitor, connector: String) -> Rc, monitor: &gdk::Monitor, connector: String) -> Rc, dock: &Rc, monitor: &gdk::Monitor) { - let cfg = ctx.cfg.borrow(); + let cfg = ctx.cfg.borrow().for_output(&dock.connector); let sensor = gtk::ApplicationWindow::new(&ctx.app); sensor.add_css_class("waydock-sensor"); sensor.init_layer_shell(); @@ -359,7 +387,7 @@ fn set_dock_hidden(dock: &Rc, hidden: bool) { } fn should_hide(ctx: &Ctx, dock: &Dock) -> bool { - let cfg = ctx.cfg.borrow(); + let cfg = ctx.cfg.borrow().for_output(&dock.connector); if !cfg.autohide.enabled || dock.hover.get() { return false; } @@ -383,7 +411,7 @@ pub fn update_autohide(ctx: &Rc) { } fn schedule_hide(ctx: &Rc, dock: &Rc) { - let delay = ctx.cfg.borrow().autohide.hide_delay_ms; + let delay = ctx.cfg.borrow().for_output(&dock.connector).autohide.hide_delay_ms; let generation = dock.hide_generation.get() + 1; dock.hide_generation.set(generation); let ctx = ctx.clone(); @@ -397,18 +425,24 @@ fn schedule_hide(ctx: &Rc, dock: &Rc) { /// Räkna om dodge-överlapp för alla dockor via Wayfire-IPC-geometri. pub fn recompute_dodge(ctx: &Rc) { - { - let cfg = ctx.cfg.borrow(); - if !cfg.autohide.enabled || cfg.autohide.mode != AutohideMode::Dodge { - return; - } + // hämta bara geometri om någon dock alls kör dodge + let any_dodge = ctx.docks.borrow().iter().any(|d| { + let c = ctx.cfg.borrow().for_output(&d.connector); + c.autohide.enabled && c.autohide.mode == AutohideMode::Dodge + }); + if !any_dodge { + return; } let Some(views) = wayfire_ipc::list_views() else { return }; let Some(outputs) = wayfire_ipc::list_outputs() else { return }; - let position = ctx.cfg.borrow().position.clone(); - let margin = ctx.cfg.borrow().margin; for dock in ctx.docks.borrow().iter() { + let eff = ctx.cfg.borrow().for_output(&dock.connector); + if !eff.autohide.enabled || eff.autohide.mode != AutohideMode::Dodge { + dock.dodge_overlap.set(false); + continue; + } + let (position, margin) = (eff.position.clone(), eff.margin); let Some(output) = outputs .iter() .find(|o| o["name"].as_str() == Some(dock.connector.as_str())) @@ -542,7 +576,11 @@ fn special_button(icon_name: &str, tooltip: &str) -> gtk::Button { } fn add_special_buttons(ctx: &Rc, dock: &Rc) { - let specials = ctx.cfg.borrow().special_buttons.clone(); + let specials = ctx + .cfg + .borrow() + .for_output(&dock.connector) + .special_buttons; if specials.overview && wayfire_ipc::available() { let btn = special_button("view-grid-symbolic", "Översikt (scale)"); @@ -562,12 +600,11 @@ fn add_special_buttons(ctx: &Rc, dock: &Rc) { } fn build_item_widget(ctx: &Rc, connector: &str, item: Rc) -> gtk::Button { - let cfg = ctx.cfg.borrow(); + let cfg = ctx.cfg.borrow().for_output(connector); let icon_size = cfg.appearance.icon_size; let max_dots = cfg.appearance.indicator.max as usize; let tooltips = cfg.behavior.tooltips; let urgent_pulse = cfg.behavior.urgent_pulse; - drop(cfg); let btn = gtk::Button::new(); btn.add_css_class("dock-item"); @@ -639,8 +676,8 @@ fn build_item_widget(ctx: &Rc, connector: &str, item: Rc) -> gtk::But gesture.set_state(gtk::EventSequenceState::Claimed); let live = live_item(&ctx, &conn2, &item); match gesture.current_button() { - 1 => primary_action(&ctx, &live, &btn2), - 2 => middle_action(&ctx, &live), + 1 => primary_action(&ctx, &conn2, &live, &btn2), + 2 => middle_action(&ctx, &conn2, &live), 3 => menu::popup_item_menu(&ctx, &live, &btn2), _ => {} } @@ -649,9 +686,7 @@ fn build_item_widget(ctx: &Rc, connector: &str, item: Rc) -> gtk::But btn.add_controller(click); // hover-popup för grupperade appar (om valt i configen) - if item.windows.len() > 1 - && ctx.cfg.borrow().behavior.group_popup == GroupPopup::Hover - { + if item.windows.len() > 1 && cfg.behavior.group_popup == GroupPopup::Hover { let hover_ctl = gtk::EventControllerMotion::new(); let ctx2 = ctx.clone(); let item2 = item.clone(); @@ -682,7 +717,7 @@ fn build_item_widget(ctx: &Rc, connector: &str, item: Rc) -> gtk::But let item = item.clone(); let conn2 = connector.to_string(); scroll.connect_scroll(move |_, _, dy| { - if ctx.cfg.borrow().behavior.scroll == ScrollAction::CycleWindows { + if ctx.cfg.borrow().for_output(&conn2).behavior.scroll == ScrollAction::CycleWindows { let live = live_item(&ctx, &conn2, &item); cycle_windows(&ctx, &live, dy > 0.0); glib::Propagation::Stop @@ -696,28 +731,25 @@ fn build_item_widget(ctx: &Rc, connector: &str, item: Rc) -> gtk::But btn } -fn primary_action(ctx: &Rc, item: &Rc, anchor: >k::Button) { +fn primary_action(ctx: &Rc, connector: &str, item: &Rc, anchor: >k::Button) { let service = ctx.service.borrow(); let Some(service) = service.as_ref() else { icons::launch(&item.meta); return; }; + let behavior = ctx.cfg.borrow().for_output(connector).behavior; 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 { + if w.activated && !w.minimized && behavior.click == ClickAction::FocusOrMinimize { service.set_minimized(w, true); } else { service.activate(w); } } _ => { - let (click, popup) = { - let b = &ctx.cfg.borrow().behavior; - (b.click.clone(), b.group_popup.clone()) - }; + let (click, popup) = (behavior.click.clone(), behavior.group_popup.clone()); if click == ClickAction::Cycle || popup == GroupPopup::Hover { cycle_windows(ctx, item, true); } else { @@ -727,8 +759,8 @@ fn primary_action(ctx: &Rc, item: &Rc, anchor: >k::Button) { } } -fn middle_action(ctx: &Rc, item: &Rc) { - let action = ctx.cfg.borrow().behavior.middle_click.clone(); +fn middle_action(ctx: &Rc, connector: &str, item: &Rc) { + let action = ctx.cfg.borrow().for_output(connector).behavior.middle_click.clone(); match action { MiddleClick::LaunchNew => icons::launch(&item.meta), MiddleClick::Close => { diff --git a/src/hotspots.rs b/src/hotspots.rs index db15fe2..3231f2c 100644 --- a/src/hotspots.rs +++ b/src/hotspots.rs @@ -109,10 +109,25 @@ thread_local! { /// Bygg om alla hotspot-ytor enligt configen (körs vid start + configbyte). /// Idempotent: oförändrad hotspot-config + skärmuppsättning → ingen ombyggnad. pub fn rebuild(ctx: &Rc) { - let n_monitors = gdk::Display::default() - .map(|d| d.monitors().n_items()) - .unwrap_or(0); - let signature = format!("{:?}|{n_monitors}", ctx.cfg.borrow().hotspots); + let display = gdk::Display::default().expect("ingen display"); + let monitors = display.monitors(); + + // signatur = alla skärmars EFFEKTIVA hotspot-config + let mut signature = String::new(); + let mut mons: Vec<(String, gdk::Monitor)> = Vec::new(); + for i in 0..monitors.n_items() { + let Some(monitor) = monitors.item(i).and_then(|o| o.downcast::().ok()) + else { + continue; + }; + let connector = monitor + .connector() + .map(|c| c.to_string()) + .unwrap_or_else(|| format!("monitor-{i}")); + let eff = ctx.cfg.borrow().for_output(&connector); + signature += &format!("{connector}:{:?}|", eff.hotspots); + mons.push((connector, monitor)); + } let unchanged = LAST_SIGNATURE.with(|s| *s.borrow() == signature); if unchanged { return; @@ -123,41 +138,33 @@ pub fn rebuild(ctx: &Rc) { win.destroy(); } - let cfg = ctx.cfg.borrow(); - let dwell = cfg.hotspots.dwell_ms; - let c = cfg.hotspots.corners.clone(); - let e = cfg.hotspots.edges.clone(); - drop(cfg); - - let corner_defs: [(&Option, [Edge; 2]); 4] = [ - (&c.top_left, [Edge::Top, Edge::Left]), - (&c.top_right, [Edge::Top, Edge::Right]), - (&c.bottom_left, [Edge::Bottom, Edge::Left]), - (&c.bottom_right, [Edge::Bottom, Edge::Right]), - ]; - // kanterna lämnar hörnen fria (hörn-ytor ligger ovanpå ändå — overlay - // med senare mappade ytor hamnar överst, så det kvittar i praktiken) - #[allow(clippy::type_complexity)] - let edge_defs: [(&Option, [Edge; 3], (i32, i32)); 4] = [ - (&e.top, [Edge::Top, Edge::Left, Edge::Right], (-1, EDGE_PX)), - (&e.bottom, [Edge::Bottom, Edge::Left, Edge::Right], (-1, EDGE_PX)), - (&e.left, [Edge::Left, Edge::Top, Edge::Bottom], (EDGE_PX, -1)), - (&e.right, [Edge::Right, Edge::Top, Edge::Bottom], (EDGE_PX, -1)), - ]; - - let display = gdk::Display::default().expect("ingen display"); - let monitors = display.monitors(); let mut windows = Vec::new(); - for i in 0..monitors.n_items() { - let Some(monitor) = monitors.item(i).and_then(|o| o.downcast::().ok()) - else { - continue; - }; + for (connector, monitor) in &mons { + let h = ctx.cfg.borrow().for_output(connector).hotspots; + let dwell = h.dwell_ms; + let c = h.corners.clone(); + let e = h.edges.clone(); + + let corner_defs: [(&Option, [Edge; 2]); 4] = [ + (&c.top_left, [Edge::Top, Edge::Left]), + (&c.top_right, [Edge::Top, Edge::Right]), + (&c.bottom_left, [Edge::Bottom, Edge::Left]), + (&c.bottom_right, [Edge::Bottom, Edge::Right]), + ]; + // kanterna lämnar hörnen fria (hörn-ytor ligger ovanpå ändå) + #[allow(clippy::type_complexity)] + let edge_defs: [(&Option, [Edge; 3], (i32, i32)); 4] = [ + (&e.top, [Edge::Top, Edge::Left, Edge::Right], (-1, EDGE_PX)), + (&e.bottom, [Edge::Bottom, Edge::Left, Edge::Right], (-1, EDGE_PX)), + (&e.left, [Edge::Left, Edge::Top, Edge::Bottom], (EDGE_PX, -1)), + (&e.right, [Edge::Right, Edge::Top, Edge::Bottom], (EDGE_PX, -1)), + ]; + for (action, edges) in &corner_defs { if let Some(a) = action { windows.push(build_hotspot( ctx, - &monitor, + monitor, edges, (CORNER_PX, CORNER_PX), a.clone(), @@ -167,7 +174,7 @@ pub fn rebuild(ctx: &Rc) { } for (action, edges, size) in &edge_defs { if let Some(a) = action { - windows.push(build_hotspot(ctx, &monitor, edges, *size, a.clone(), dwell)); + windows.push(build_hotspot(ctx, monitor, edges, *size, a.clone(), dwell)); } } } diff --git a/src/main.rs b/src/main.rs index c9421d1..203ab7d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -113,7 +113,7 @@ fn layout_signature(cfg: &config::Config) -> String { cfg.avoid_windows, cfg.appearance.shadow.enabled, cfg.appearance.shadow.blur, - cfg.outputs.keys().collect::>(), + serde_json::to_string(&cfg.outputs).unwrap_or_default(), ) + &format!("|{:?}", cfg.autohide) } diff --git a/src/menu.rs b/src/menu.rs index bcbaf87..54515d1 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -123,24 +123,27 @@ fn toggle_pin(ctx: &Rc, connector: &str) { }; { let mut cfg = ctx.cfg.borrow_mut(); - // skriv i output-egna pins om skärmen har egna, annars i de globala + // skriv i skärmens pin-override om den har en, annars i de globala let has_own = cfg .outputs .get(connector) - .map(|o| o.pins.is_some()) + .map(|o| o["pins"].is_array()) .unwrap_or(false); - let pins: &mut Vec = if has_own { - cfg.outputs - .get_mut(connector) - .and_then(|o| o.pins.as_mut()) - .expect("has_own kollades nyss") - } else { - &mut cfg.pins + let mutate = |pins: &mut Vec| { + if pinned { + pins.retain(|p| p.to_lowercase() != key); + } else if !pins.iter().any(|p| p.to_lowercase() == key) { + pins.push(key.clone()); + } }; - if pinned { - pins.retain(|p| p.to_lowercase() != key); - } else if !pins.iter().any(|p| p.to_lowercase() == key) { - pins.push(key.clone()); + if has_own { + let over = cfg.outputs.get_mut(connector).expect("has_own kollades nyss"); + let mut pins: Vec = + serde_json::from_value(over["pins"].clone()).unwrap_or_default(); + mutate(&mut pins); + over["pins"] = serde_json::to_value(pins).expect("strängar serialiserbara"); + } else { + mutate(&mut cfg.pins); } if let Err(e) = config::save(&ctx.cfg_path, &cfg) { warn!("kunde inte spara config: {e}"); diff --git a/src/settings.rs b/src/settings.rs index dacb35b..8c8f45b 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -24,14 +24,58 @@ thread_local! { const { std::cell::RefCell::new(None) }; static ILLUSTRATION: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; + static PREVIEW: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; + /// None = "Alla skärmar" (globala configen); Some = en enskild skärm + static SCOPE: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; +} + +fn current_scope() -> Option { + SCOPE.with(|s| s.borrow().clone()) +} + +/// Configen som settings-sidorna ska visa: global eller skärmens effektiva. +fn scoped_config(ctx: &Rc) -> config::Config { + match current_scope() { + None => ctx.cfg.borrow().clone(), + Some(conn) => ctx.cfg.borrow().for_output(&conn), + } } /// Uppdatera config + applicera + spara (debouncat 300 ms). +/// Med en skärm vald skrivs bara de ändrade fälten in som override för +/// den skärmen; "Alla skärmar" redigerar den globala configen. fn commit(ctx: &Rc, mutate: impl FnOnce(&mut config::Config)) { - mutate(&mut ctx.cfg.borrow_mut()); + match current_scope() { + None => mutate(&mut ctx.cfg.borrow_mut()), + Some(conn) => { + let mut eff = ctx.cfg.borrow().for_output(&conn); + let before = serde_json::to_value(&eff).expect("config serialiserbar"); + mutate(&mut eff); + let after = serde_json::to_value(&eff).expect("config serialiserbar"); + let mut patch = config::json_diff(&before, &after); + if let Some(obj) = patch.as_object_mut() { + obj.remove("outputs"); // aldrig rekursiva överridar + } + if !patch.is_null() { + let mut cfg = ctx.cfg.borrow_mut(); + let entry = cfg + .outputs + .entry(conn) + .or_insert_with(|| serde_json::json!({})); + config::json_merge(entry, &patch); + } + } + } if let Some(cb) = ctx.on_config_changed.borrow().as_ref() { cb(); } + PREVIEW.with(|i| { + if let Some(da) = i.borrow().as_ref().and_then(|w| w.upgrade()) { + da.queue_draw(); + } + }); ILLUSTRATION.with(|i| { if let Some(da) = i.borrow().as_ref().and_then(|w| w.upgrade()) { da.queue_draw(); @@ -221,7 +265,7 @@ fn hotspot_entry( fn build_appearance_page(ctx: &Rc) -> gtk::ScrolledWindow { let (scroll, list) = page(); - let a = ctx.cfg.borrow().appearance.clone(); + let a = scoped_config(ctx).appearance; heading(&list, "Panel"); row(&list, "Panelfärg", &color_button(ctx, &a.background, |c, v| c.appearance.background = v)); @@ -267,7 +311,7 @@ fn margin_illustration(ctx: &Rc) -> gtk::DrawingArea { da.set_halign(gtk::Align::Center); let ctx2 = ctx.clone(); da.set_draw_func(move |_, cr, w, h| { - let cfg = ctx2.cfg.borrow(); + let cfg = scoped_config(&ctx2); let (w, h) = (w as f64, h as f64); // "skärmen" @@ -381,7 +425,7 @@ fn margin_illustration(ctx: &Rc) -> gtk::DrawingArea { fn build_layout_page(ctx: &Rc) -> gtk::ScrolledWindow { let (scroll, list) = page(); - let cfg = ctx.cfg.borrow().clone(); + let cfg = scoped_config(ctx); heading(&list, "Placering"); let pos_idx = match cfg.position { @@ -427,9 +471,9 @@ fn build_layout_page(ctx: &Rc) -> gtk::ScrolledWindow { heading(&list, "Marginaler"); list.append(&margin_illustration(ctx)); - row(&list, "A — Avstånd från kanten", &spin(ctx, cfg.margin as f64, (0.0, 128.0), 1.0, |c, v| c.margin = v as i32)); - row(&list, "B — Marginal start", &spin(ctx, cfg.margin_start as f64, (0.0, 512.0), 1.0, |c, v| c.margin_start = v as i32)); - row(&list, "C — Marginal slut", &spin(ctx, cfg.margin_end as f64, (0.0, 512.0), 1.0, |c, v| c.margin_end = v as i32)); + row(&list, "A — Avstånd från kanten", &spin(ctx, cfg.margin as f64, (-64.0, 256.0), 1.0, |c, v| c.margin = v as i32)); + row(&list, "B — Marginal start", &spin(ctx, cfg.margin_start as f64, (-256.0, 1024.0), 1.0, |c, v| c.margin_start = v as i32)); + row(&list, "C — Marginal slut", &spin(ctx, cfg.margin_end as f64, (-256.0, 1024.0), 1.0, |c, v| c.margin_end = v as i32)); let note = gtk::Label::new(Some( "A = avstånd från kanten baren ligger mot.\nB/C = marginal i början/slutet av kanten — indrag när baren\ntäcker kanten, annars avstånd från vald sida (vid Start/Slut).", )); @@ -461,8 +505,9 @@ fn build_layout_page(ctx: &Rc) -> gtk::ScrolledWindow { fn build_behavior_page(ctx: &Rc) -> gtk::ScrolledWindow { let (scroll, list) = page(); - let b = ctx.cfg.borrow().behavior.clone(); - let sp = ctx.cfg.borrow().special_buttons.clone(); + let scoped = scoped_config(ctx); + let b = scoped.behavior.clone(); + let sp = scoped.special_buttons.clone(); heading(&list, "Klick"); let click_idx = match b.click { @@ -534,7 +579,7 @@ fn build_behavior_page(ctx: &Rc) -> gtk::ScrolledWindow { ("Stäng", MenuItem::Close), ]; for (label, mi) in entries { - let present = ctx.cfg.borrow().context_menu.contains(&mi); + let present = scoped.context_menu.contains(&mi); let mi2 = mi.clone(); row(&list, label, &switch(ctx, present, move |c, v| { if v { @@ -561,7 +606,7 @@ fn build_pins_page(ctx: &Rc) -> gtk::ScrolledWindow { while let Some(c) = pins_box.first_child() { pins_box.remove(&c); } - let pins = ctx.cfg.borrow().pins.clone(); + let pins = scoped_config(ctx).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) @@ -592,7 +637,9 @@ fn build_pins_page(ctx: &Rc) -> gtk::ScrolledWindow { let pb3 = pins_box.clone(); remove.connect_clicked(move |_| { commit(&ctx3, |c| { - c.pins.remove(i); + if i < c.pins.len() { + c.pins.remove(i); + } }); rebuild_rows(&ctx3, &pb3); }); @@ -643,7 +690,7 @@ fn build_pins_page(ctx: &Rc) -> gtk::ScrolledWindow { fn build_hotspots_page(ctx: &Rc) -> gtk::ScrolledWindow { let (scroll, list) = page(); - let h = ctx.cfg.borrow().hotspots.clone(); + let h = scoped_config(ctx).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)); @@ -668,6 +715,142 @@ fn build_hotspots_page(ctx: &Rc) -> gtk::ScrolledWindow { scroll } +/// Skärmarna som gdk ser: (connector, logisk geometri). +fn monitor_list() -> Vec<(String, gdk::Rectangle)> { + let mut out = Vec::new(); + let Some(display) = gdk::Display::default() else { + return out; + }; + let monitors = display.monitors(); + for i in 0..monitors.n_items() { + let Some(m) = monitors.item(i).and_then(|o| o.downcast::().ok()) else { + continue; + }; + let name = m + .connector() + .map(|c| c.to_string()) + .unwrap_or_else(|| format!("monitor-{i}")); + out.push((name, m.geometry())); + } + out +} + +/// Normalisera skärmrektanglarna till förhandsgranskningens yta. +fn preview_layout(mons: &[(String, gdk::Rectangle)], w: f64, h: f64) + -> Vec<(String, (f64, f64, f64, f64))> { + if mons.is_empty() { + return Vec::new(); + } + let min_x = mons.iter().map(|(_, g)| g.x()).min().unwrap_or(0) as f64; + let min_y = mons.iter().map(|(_, g)| g.y()).min().unwrap_or(0) as f64; + let max_x = mons.iter().map(|(_, g)| g.x() + g.width()).max().unwrap_or(1) as f64; + let max_y = mons.iter().map(|(_, g)| g.y() + g.height()).max().unwrap_or(1) as f64; + let scale = ((w - 16.0) / (max_x - min_x)).min((h - 16.0) / (max_y - min_y)); + let off_x = (w - (max_x - min_x) * scale) / 2.0; + let off_y = (h - (max_y - min_y) * scale) / 2.0; + mons.iter() + .map(|(name, g)| { + ( + name.clone(), + ( + off_x + (g.x() as f64 - min_x) * scale, + off_y + (g.y() as f64 - min_y) * scale, + g.width() as f64 * scale, + g.height() as f64 * scale, + ), + ) + }) + .collect() +} + +type RefreshSlot = Rc>>>; + +fn build_preview(ctx: &Rc, refresh: RefreshSlot) -> gtk::DrawingArea { + let da = gtk::DrawingArea::new(); + da.set_content_width(400); + da.set_content_height(96); + da.set_halign(gtk::Align::Center); + let ctx2 = ctx.clone(); + da.set_draw_func(move |_, cr, w, h| { + let scope = current_scope(); + let mons = monitor_list(); + for (name, (x, y, mw, mh)) in preview_layout(&mons, w as f64, h as f64) { + let eff = ctx2.cfg.borrow().for_output(&name); + // skärmen + cr.set_source_rgba(0.15, 0.15, 0.17, 1.0); + cr.rectangle(x, y, mw, mh); + let _ = cr.fill(); + let selected = scope.is_none() || scope.as_deref() == Some(name.as_str()); + if selected { + cr.set_source_rgba(0.79, 0.33, 0.36, 1.0); + cr.set_line_width(2.0); + } else { + cr.set_source_rgba(0.45, 0.45, 0.5, 1.0); + cr.set_line_width(1.0); + } + cr.rectangle(x, y, mw, mh); + let _ = cr.stroke(); + // barens mini-representation + let horizontal = matches!(eff.position, Position::Top | Position::Bottom); + let full = if horizontal { mw - 8.0 } else { mh - 8.0 }; + let len = if eff.expand { full } else { full * 0.45 }; + let along = match (eff.expand, &eff.alignment) { + (true, _) | (false, Alignment::Center) => (full - len) / 2.0 + 4.0, + (false, Alignment::Start) => 4.0, + (false, Alignment::End) => full - len + 4.0, + }; + cr.set_source_rgba(0.9, 0.9, 0.92, 0.95); + let t = 3.0; + match eff.position { + Position::Bottom => cr.rectangle(x + along, y + mh - 6.0, len, t), + Position::Top => cr.rectangle(x + along, y + 3.0, len, t), + Position::Left => cr.rectangle(x + 3.0, y + along, t, len), + Position::Right => cr.rectangle(x + mw - 6.0, y + along, t, len), + } + let _ = cr.fill(); + // namn + markering om skärmen har egna inställningar + cr.set_source_rgba(0.85, 0.85, 0.88, 1.0); + cr.set_font_size(10.0); + cr.move_to(x + 5.0, y + 14.0); + let has_override = ctx2.cfg.borrow().outputs.contains_key(&name); + let text = if has_override { format!("{name} *") } else { name.clone() }; + let _ = cr.show_text(&text); + } + }); + + // klick väljer skärm (utanför = alla) + let click = gtk::GestureClick::new(); + { + let da2 = da.clone(); + click.connect_pressed(move |_, _, px, py| { + let mons = monitor_list(); + let rects = preview_layout(&mons, da2.width() as f64, da2.height() as f64); + let hit = rects + .iter() + .find(|(_, (x, y, w, h))| px >= *x && px <= x + w && py >= *y && py <= y + h) + .map(|(n, _)| n.clone()); + SCOPE.with(|s| *s.borrow_mut() = hit); + if let Some(f) = refresh.borrow().as_ref() { + f(); + } + }); + } + da.add_controller(click); + PREVIEW.with(|p| *p.borrow_mut() = Some(da.downgrade())); + da +} + +fn build_notebook(ctx: &Rc) -> gtk::Notebook { + let notebook = gtk::Notebook::new(); + notebook.set_vexpand(true); + 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")))); + notebook +} + /// Öppna settings-fönstret (återanvänder ett redan öppet). pub fn open(ctx: &Rc) { let existing = OPEN_WINDOW.with(|w| w.borrow().as_ref().and_then(|w| w.upgrade())); @@ -675,24 +858,136 @@ pub fn open(ctx: &Rc) { win.present(); return; } + SCOPE.with(|s| *s.borrow_mut() = None); let win = gtk::Window::new(); win.set_title(Some("waydock — inställningar")); win.set_icon_name(Some("waydock")); - win.set_default_size(520, 620); + win.set_default_size(560, 760); - 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)); + let root = gtk::Box::new(gtk::Orientation::Vertical, 6); + root.set_margin_top(10); + root.set_margin_bottom(4); + root.set_margin_start(10); + root.set_margin_end(10); + + let refresh_slot: RefreshSlot = Rc::new(std::cell::RefCell::new(None)); + + // väljarrad: "Alla skärmar" + en toggle per skärm + återställ-knapp + let scope_row = gtk::Box::new(gtk::Orientation::Horizontal, 6); + scope_row.set_halign(gtk::Align::Center); + let updating = Rc::new(Cell::new(false)); + let mut scope_values: Vec> = vec![None]; + for (name, _) in monitor_list() { + scope_values.push(Some(name)); + } + #[allow(clippy::type_complexity)] + let toggles: Rc, gtk::ToggleButton)>>> = + Rc::new(std::cell::RefCell::new(Vec::new())); + for value in &scope_values { + let label = match value { + None => "Alla skärmar".to_string(), + Some(n) => n.clone(), + }; + let btn = gtk::ToggleButton::with_label(&label); + btn.set_active(value.is_none()); + { + let value = value.clone(); + let updating = updating.clone(); + let refresh_slot = refresh_slot.clone(); + btn.connect_toggled(move |b| { + if updating.get() { + return; + } + if b.is_active() { + SCOPE.with(|s| *s.borrow_mut() = value.clone()); + if let Some(f) = refresh_slot.borrow().as_ref() { + f(); + } + } else if current_scope() == value { + // hindra av-toggling av den aktiva + updating.set(true); + b.set_active(true); + updating.set(false); + } + }); + } + scope_row.append(&btn); + toggles.borrow_mut().push((value.clone(), btn)); + } + + let reset_btn = gtk::Button::with_label("Återställ skärmen till globalt"); + reset_btn.add_css_class("destructive-action"); + reset_btn.set_halign(gtk::Align::Center); + reset_btn.set_visible(false); + { + let ctx2 = ctx.clone(); + let refresh_slot = refresh_slot.clone(); + reset_btn.connect_clicked(move |_| { + if let Some(conn) = current_scope() { + commit(&ctx2, |_| {}); // no-op: säkrar att ev. debounce inte skriver gammalt + ctx2.cfg.borrow_mut().outputs.remove(&conn); + if let Err(e) = config::save(&ctx2.cfg_path, &ctx2.cfg.borrow()) { + warn!("kunde inte spara config: {e}"); + } + if let Some(cb) = ctx2.on_config_changed.borrow().as_ref() { + cb(); + } + if let Some(f) = refresh_slot.borrow().as_ref() { + f(); + } + } + }); + } + + let preview = build_preview(ctx, refresh_slot.clone()); + let hint = gtk::Label::new(Some( + "Klicka på en skärm för att ställa in just den (utanför = alla).\n\ + * = skärmen har egna inställningar. Som standard gäller allt alla skärmar.", + )); + hint.add_css_class("dim-label"); + hint.set_halign(gtk::Align::Center); + hint.set_justify(gtk::Justification::Center); + + let holder = gtk::Box::new(gtk::Orientation::Vertical, 0); + holder.set_vexpand(true); + holder.append(&build_notebook(ctx)); + + // refresh: uppdatera togglar/preview/reset + bygg om sidorna + { + let ctx2 = ctx.clone(); + let holder2 = holder.clone(); + let toggles2 = toggles.clone(); + let updating2 = updating.clone(); + let reset2 = reset_btn.clone(); + let refresh: Rc = Rc::new(move || { + let scope = current_scope(); + updating2.set(true); + for (value, btn) in toggles2.borrow().iter() { + btn.set_active(*value == scope); + } + updating2.set(false); + reset2.set_visible(scope.is_some()); + while let Some(c) = holder2.first_child() { + holder2.remove(&c); + } + holder2.append(&build_notebook(&ctx2)); + PREVIEW.with(|p| { + if let Some(da) = p.borrow().as_ref().and_then(|w| w.upgrade()) { + da.queue_draw(); + } + }); + }); + *refresh_slot.borrow_mut() = Some(refresh); + } + + root.append(&scope_row); + root.append(&preview); + root.append(&hint); + root.append(&reset_btn); + root.append(&holder); + win.set_child(Some(&root)); OPEN_WINDOW.with(|w| *w.borrow_mut() = Some(win.downgrade())); win.present(); } - -// hindra att Cell-importen städas bort av misstag när sidor ändras -#[allow(unused)] -fn _keep(_: Cell) {} diff --git a/src/style.rs b/src/style.rs index dabae9c..98bd516 100644 --- a/src/style.rs +++ b/src/style.rs @@ -1,12 +1,22 @@ // 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. +// byts ut vid live-omladdning. Skärmar med per-skärm-överridar får egna +// CSS-block scopade på dockfönstrets output-klass (o-). use gtk4 as gtk; use gtk4::gdk; use crate::config::{Config, IndicatorStyle}; +/// CSS-klass för en viss skärms dockfönster. +pub fn output_class(connector: &str) -> String { + let safe: String = connector + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + format!("o-{safe}") +} + /// "#rrggbb" + alpha → "rgba(r, g, b, a)". Trasig färg → grå. fn rgba(hex: &str, alpha: f64) -> String { let h = hex.trim_start_matches('#'); @@ -22,7 +32,9 @@ fn rgba(hex: &str, alpha: f64) -> String { format!("rgba({r}, {g}, {b}, {alpha:.3})") } -pub fn build_css(cfg: &Config) -> String { +/// De utseende-styrda reglerna, med valfritt selektor-prefix +/// ("" globalt, "window.o-DP-1 " för en enskild skärm). +fn css_body(cfg: &Config, p: &str) -> String { let a = &cfg.appearance; let bg = rgba(&a.background, a.opacity); let border = rgba(&a.border.color, a.border.opacity); @@ -52,18 +64,7 @@ pub fn build_css(cfg: &Config) -> String { format!( r#" -window.waydock, -window.waydock-sensor, -window.waydock-hotspot {{ - background: transparent; -}} - -window.waydock-sensor box, -window.waydock-hotspot box {{ - background-color: rgba(0, 0, 0, 0.004); -}} - -.dock-panel {{ +{p}.dock-panel {{ background-color: {bg}; border-radius: {radius}px; border: {bwidth}px solid {border}; @@ -71,7 +72,7 @@ window.waydock-hotspot box {{ {shadow} }} -.dock-item {{ +{p}.dock-item {{ background: transparent; border: none; border-radius: {item_radius}px; @@ -82,33 +83,28 @@ window.waydock-hotspot box {{ {transition} }} -.dock-item:hover {{ +{p}.dock-item:hover {{ background-color: {accent_soft}; }} -.dock-item:hover image {{ +{p}.dock-item:hover image {{ {hover_zoom} }} -.dock-item image {{ +{p}.dock-item image {{ {transition} }} -.dock-item.urgent {{ +{p}.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 {{ +{p}.indicator-box {{ min-height: {ind_h}px; }} -.indicator {{ +{p}.indicator {{ background-color: {accent}; border-radius: {ind_radius}px; min-width: {ind_w}px; @@ -116,11 +112,11 @@ window.waydock-hotspot box {{ margin: 0 1px; }} -.indicator.inactive {{ +{p}.indicator.inactive {{ background-color: {accent_dim}; }} -.group-badge {{ +{p}.group-badge {{ background-color: {accent}; color: #ffffff; border-radius: 8px; @@ -131,24 +127,25 @@ window.waydock-hotspot box {{ padding: 0 2px; }} -popover.waydock-menu contents {{ +{p}popover.waydock-menu contents {{ background-color: {menu_bg}; border-radius: 10px; border: 1px solid {border}; }} -popover.waydock-menu modelbutton, -popover.waydock-menu button.flat {{ +{p}popover.waydock-menu modelbutton, +{p}popover.waydock-menu button.flat {{ color: #eeeeee; border-radius: 6px; padding: 6px 10px; }} -popover.waydock-menu modelbutton:hover, -popover.waydock-menu button.flat:hover {{ +{p}popover.waydock-menu modelbutton:hover, +{p}popover.waydock-menu button.flat:hover {{ background-color: {accent_soft}; }} "#, + p = p, bg = bg, radius = a.corner_radius, bwidth = a.border.width, @@ -170,6 +167,35 @@ popover.waydock-menu button.flat:hover {{ ) } +pub fn build_css(cfg: &Config) -> String { + let mut css = String::from( + r#" +window.waydock, +window.waydock-sensor, +window.waydock-hotspot { + background: transparent; +} + +window.waydock-sensor box, +window.waydock-hotspot box { + background-color: rgba(0, 0, 0, 0.004); +} + +/* utan "to" animeras tillbaka till regelns egen bakgrund (accenten) */ +@keyframes waydock-pulse { + from { background-color: transparent; } +} +"#, + ); + css += &css_body(cfg, ""); + // skärmar med överridar får egna, mer specifika block + for name in cfg.outputs.keys() { + let eff = cfg.for_output(name); + css += &css_body(&eff, &format!("window.{} ", output_class(name))); + } + css +} + /// 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");