Per-skärm-inställningar med skärmväljare, kant-i-kant-marginaler
All checks were successful
check / check (push) Successful in 1m39s

- 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-<connector>) 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 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 00:25:05 +02:00
parent 04d999642c
commit 558c178ec4
8 changed files with 609 additions and 167 deletions

View File

@@ -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 Högerklick på tom yta → **Inställningar…** Ändringar skrivs direkt till
JSON-filen och appliceras live. GUI:t och handredigering kan blandas fritt. 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`) ## Configreferens (`config.json`)
```jsonc ```jsonc
@@ -42,9 +49,11 @@ JSON-filen och appliceras live. GUI:t och handredigering kan blandas fritt.
"position": "bottom", // bottom | top | left | right "position": "bottom", // bottom | top | left | right
"alignment": "center", // center | start | end — var längs kanten ön ligger "alignment": "center", // center | start | end — var längs kanten ön ligger
"expand": false, // true = täck hela kanten (panelen fyller bredden/höjden) "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_start": 0, // B: marginal i början av kanten (vänster/topp)
"margin_end": 0, // C: marginal i slutet av kanten (höger/botten) "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) "layer": "top", // top | overlay | bottom (always-on-top = overlay)
"avoid_windows": false, // true = reservera plats (exclusive zone) "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"], "pins": ["firefox", "org.kde.konsole", "org.kde.dolphin"],
"special_buttons": { "show_desktop": true, "overview": true }, "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": { "DP-1": {
"enabled": true, "enabled": true,
"only_this_output": true, // visa bara fönster på den här skärmen "only_this_output": true,
"pins": ["steam", "discord"] // null/utelämnad = ärver globala pins "position": "left",
"pins": ["steam", "discord"],
"appearance": { "accent": "#5fb878" }
} }
}, },

View File

@@ -197,13 +197,10 @@ impl Default for Behavior {
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] // Per-skärm-överridar: en partiell Config i JSON-form som deep-mergas
#[serde(default)] // över den globala. Extranycklarna "enabled" och "only_this_output" är
pub struct OutputConfig { // skärm-specifika och läses separat (de finns inte i Config).
pub enabled: Option<bool>, pub type OutputOverride = serde_json::Value;
pub only_this_output: Option<bool>,
pub pins: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
@@ -316,7 +313,7 @@ pub struct Config {
pub context_menu: Vec<MenuItem>, pub context_menu: Vec<MenuItem>,
pub pins: Vec<String>, pub pins: Vec<String>,
pub special_buttons: SpecialButtons, pub special_buttons: SpecialButtons,
pub outputs: HashMap<String, OutputConfig>, pub outputs: HashMap<String, OutputOverride>,
pub autohide: Autohide, pub autohide: Autohide,
pub hotspots: Hotspots, pub hotspots: Hotspots,
pub log: LogConfig, 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 { impl Config {
/// Effektiva pins för en viss skärm (output-överstyrning eller globala). /// Effektiv config för en skärm: global + per-skärm-överridar mergade.
pub fn pins_for(&self, output: &str) -> &[String] { pub fn for_output(&self, output: &str) -> Config {
self.outputs match self.outputs.get(output) {
.get(output) None => self.clone(),
.and_then(|o| o.pins.as_deref()) Some(over) => {
.unwrap_or(&self.pins) let mut base = serde_json::to_value(self).expect("config serialiserbar");
json_merge(&mut base, over);
match serde_json::from_value::<Config>(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<String> {
self.for_output(output).pins
} }
pub fn output_enabled(&self, output: &str) -> bool { pub fn output_enabled(&self, output: &str) -> bool {
self.outputs self.outputs
.get(output) .get(output)
.and_then(|o| o.enabled) .and_then(|o| o["enabled"].as_bool())
.unwrap_or(true) .unwrap_or(true)
} }
pub fn only_this_output(&self, output: &str) -> bool { pub fn only_this_output(&self, output: &str) -> bool {
self.outputs self.outputs
.get(output) .get(output)
.and_then(|o| o.only_this_output) .and_then(|o| o["only_this_output"].as_bool())
.unwrap_or(false) .unwrap_or(false)
} }
} }

View File

@@ -16,8 +16,8 @@ use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
use log::{debug, info}; use log::{debug, info};
use crate::config::{ use crate::config::{
Alignment, AutohideMode, ClickAction, GroupPopup, Layer as CfgLayer, MiddleClick, Alignment, AutohideMode, ClickAction, Config, GroupPopup, Layer as CfgLayer,
Position, ScrollAction, SharedConfig, MiddleClick, Position, ScrollAction, SharedConfig,
}; };
use crate::icons::{self, AppMeta}; use crate::icons::{self, AppMeta};
use crate::menu; use crate::menu;
@@ -90,8 +90,7 @@ fn side_edges(pos: &Position) -> (Edge, Edge) {
} }
} }
fn configure_layer(win: &gtk::ApplicationWindow, ctx: &Ctx, monitor: &gdk::Monitor) { fn configure_layer(win: &gtk::ApplicationWindow, cfg: &Config, monitor: &gdk::Monitor) {
let cfg = ctx.cfg.borrow();
win.init_layer_shell(); win.init_layer_shell();
win.set_namespace(Some("waydock")); win.set_namespace(Some("waydock"));
win.set_monitor(Some(monitor)); win.set_monitor(Some(monitor));
@@ -167,9 +166,10 @@ pub fn rebuild_all(ctx: &Rc<Ctx>) {
} }
fn build_dock(ctx: &Rc<Ctx>, monitor: &gdk::Monitor, connector: String) -> Rc<Dock> { fn build_dock(ctx: &Rc<Ctx>, monitor: &gdk::Monitor, connector: String) -> Rc<Dock> {
let cfg = ctx.cfg.borrow(); let cfg = ctx.cfg.borrow().for_output(&connector);
let win = gtk::ApplicationWindow::new(&ctx.app); let win = gtk::ApplicationWindow::new(&ctx.app);
win.add_css_class("waydock"); win.add_css_class("waydock");
win.add_css_class(&crate::style::output_class(&connector));
let orient = orientation(&cfg.position); let orient = orientation(&cfg.position);
let panel = gtk::Box::new(orient, 0); let panel = gtk::Box::new(orient, 0);
@@ -199,17 +199,46 @@ fn build_dock(ctx: &Rc<Ctx>, monitor: &gdk::Monitor, connector: String) -> Rc<Do
} }
panel.append(&items_box); panel.append(&items_box);
// yttre marginal = plats för skuggan att rita // yttre marginal = plats för skuggan att rita — men INTE på sidor som
// är förankrade mot skärmkanten, så att margin = 0 verkligen når kanten
let outer = gtk::Box::new(orient, 0); let outer = gtk::Box::new(orient, 0);
let pad = if cfg.appearance.shadow.enabled { let pad = if cfg.appearance.shadow.enabled {
cfg.appearance.shadow.blur as i32 cfg.appearance.shadow.blur as i32
} else { } else {
0 0
}; };
outer.set_margin_top(pad); let anchored_start = cfg.expand || cfg.alignment == Alignment::Start;
outer.set_margin_bottom(pad); let anchored_end = cfg.expand || cfg.alignment == Alignment::End;
outer.set_margin_start(pad); let (top, bottom, start, end) = match cfg.position {
outer.set_margin_end(pad); Position::Bottom => (
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); outer.append(&panel);
// revealern ger autohide-animationen // revealern ger autohide-animationen
@@ -229,9 +258,8 @@ fn build_dock(ctx: &Rc<Ctx>, monitor: &gdk::Monitor, connector: String) -> Rc<Do
revealer.set_child(Some(&outer)); revealer.set_child(Some(&outer));
win.set_child(Some(&revealer)); win.set_child(Some(&revealer));
let autohide_enabled = cfg.autohide.enabled; let autohide_enabled = cfg.autohide.enabled;
drop(cfg);
configure_layer(&win, ctx, monitor); configure_layer(&win, &cfg, monitor);
menu::setup_dock_actions(ctx, &win); menu::setup_dock_actions(ctx, &win);
let dock = Rc::new(Dock { let dock = Rc::new(Dock {
@@ -289,7 +317,7 @@ fn build_dock(ctx: &Rc<Ctx>, monitor: &gdk::Monitor, connector: String) -> Rc<Do
/// Den tunna remsan vid skärmkanten som väcker en gömd dock. /// Den tunna remsan vid skärmkanten som väcker en gömd dock.
fn build_sensor(ctx: &Rc<Ctx>, dock: &Rc<Dock>, monitor: &gdk::Monitor) { fn build_sensor(ctx: &Rc<Ctx>, dock: &Rc<Dock>, monitor: &gdk::Monitor) {
let cfg = ctx.cfg.borrow(); let cfg = ctx.cfg.borrow().for_output(&dock.connector);
let sensor = gtk::ApplicationWindow::new(&ctx.app); let sensor = gtk::ApplicationWindow::new(&ctx.app);
sensor.add_css_class("waydock-sensor"); sensor.add_css_class("waydock-sensor");
sensor.init_layer_shell(); sensor.init_layer_shell();
@@ -359,7 +387,7 @@ fn set_dock_hidden(dock: &Rc<Dock>, hidden: bool) {
} }
fn should_hide(ctx: &Ctx, dock: &Dock) -> 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() { if !cfg.autohide.enabled || dock.hover.get() {
return false; return false;
} }
@@ -383,7 +411,7 @@ pub fn update_autohide(ctx: &Rc<Ctx>) {
} }
fn schedule_hide(ctx: &Rc<Ctx>, dock: &Rc<Dock>) { fn schedule_hide(ctx: &Rc<Ctx>, dock: &Rc<Dock>) {
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; let generation = dock.hide_generation.get() + 1;
dock.hide_generation.set(generation); dock.hide_generation.set(generation);
let ctx = ctx.clone(); let ctx = ctx.clone();
@@ -397,18 +425,24 @@ fn schedule_hide(ctx: &Rc<Ctx>, dock: &Rc<Dock>) {
/// Räkna om dodge-överlapp för alla dockor via Wayfire-IPC-geometri. /// Räkna om dodge-överlapp för alla dockor via Wayfire-IPC-geometri.
pub fn recompute_dodge(ctx: &Rc<Ctx>) { pub fn recompute_dodge(ctx: &Rc<Ctx>) {
{ // hämta bara geometri om någon dock alls kör dodge
let cfg = ctx.cfg.borrow(); let any_dodge = ctx.docks.borrow().iter().any(|d| {
if !cfg.autohide.enabled || cfg.autohide.mode != AutohideMode::Dodge { let c = ctx.cfg.borrow().for_output(&d.connector);
c.autohide.enabled && c.autohide.mode == AutohideMode::Dodge
});
if !any_dodge {
return; return;
} }
}
let Some(views) = wayfire_ipc::list_views() else { return }; let Some(views) = wayfire_ipc::list_views() else { return };
let Some(outputs) = wayfire_ipc::list_outputs() 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() { 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 let Some(output) = outputs
.iter() .iter()
.find(|o| o["name"].as_str() == Some(dock.connector.as_str())) .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<Ctx>, dock: &Rc<Dock>) { fn add_special_buttons(ctx: &Rc<Ctx>, dock: &Rc<Dock>) {
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() { if specials.overview && wayfire_ipc::available() {
let btn = special_button("view-grid-symbolic", "Översikt (scale)"); let btn = special_button("view-grid-symbolic", "Översikt (scale)");
@@ -562,12 +600,11 @@ fn add_special_buttons(ctx: &Rc<Ctx>, dock: &Rc<Dock>) {
} }
fn build_item_widget(ctx: &Rc<Ctx>, connector: &str, item: Rc<Item>) -> gtk::Button { fn build_item_widget(ctx: &Rc<Ctx>, connector: &str, item: Rc<Item>) -> gtk::Button {
let cfg = ctx.cfg.borrow(); let cfg = ctx.cfg.borrow().for_output(connector);
let icon_size = cfg.appearance.icon_size; let icon_size = cfg.appearance.icon_size;
let max_dots = cfg.appearance.indicator.max as usize; let max_dots = cfg.appearance.indicator.max as usize;
let tooltips = cfg.behavior.tooltips; let tooltips = cfg.behavior.tooltips;
let urgent_pulse = cfg.behavior.urgent_pulse; let urgent_pulse = cfg.behavior.urgent_pulse;
drop(cfg);
let btn = gtk::Button::new(); let btn = gtk::Button::new();
btn.add_css_class("dock-item"); btn.add_css_class("dock-item");
@@ -639,8 +676,8 @@ fn build_item_widget(ctx: &Rc<Ctx>, connector: &str, item: Rc<Item>) -> gtk::But
gesture.set_state(gtk::EventSequenceState::Claimed); gesture.set_state(gtk::EventSequenceState::Claimed);
let live = live_item(&ctx, &conn2, &item); let live = live_item(&ctx, &conn2, &item);
match gesture.current_button() { match gesture.current_button() {
1 => primary_action(&ctx, &live, &btn2), 1 => primary_action(&ctx, &conn2, &live, &btn2),
2 => middle_action(&ctx, &live), 2 => middle_action(&ctx, &conn2, &live),
3 => menu::popup_item_menu(&ctx, &live, &btn2), 3 => menu::popup_item_menu(&ctx, &live, &btn2),
_ => {} _ => {}
} }
@@ -649,9 +686,7 @@ fn build_item_widget(ctx: &Rc<Ctx>, connector: &str, item: Rc<Item>) -> gtk::But
btn.add_controller(click); btn.add_controller(click);
// hover-popup för grupperade appar (om valt i configen) // hover-popup för grupperade appar (om valt i configen)
if item.windows.len() > 1 if item.windows.len() > 1 && cfg.behavior.group_popup == GroupPopup::Hover {
&& ctx.cfg.borrow().behavior.group_popup == GroupPopup::Hover
{
let hover_ctl = gtk::EventControllerMotion::new(); let hover_ctl = gtk::EventControllerMotion::new();
let ctx2 = ctx.clone(); let ctx2 = ctx.clone();
let item2 = item.clone(); let item2 = item.clone();
@@ -682,7 +717,7 @@ fn build_item_widget(ctx: &Rc<Ctx>, connector: &str, item: Rc<Item>) -> gtk::But
let item = item.clone(); let item = item.clone();
let conn2 = connector.to_string(); let conn2 = connector.to_string();
scroll.connect_scroll(move |_, _, dy| { 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); let live = live_item(&ctx, &conn2, &item);
cycle_windows(&ctx, &live, dy > 0.0); cycle_windows(&ctx, &live, dy > 0.0);
glib::Propagation::Stop glib::Propagation::Stop
@@ -696,28 +731,25 @@ fn build_item_widget(ctx: &Rc<Ctx>, connector: &str, item: Rc<Item>) -> gtk::But
btn btn
} }
fn primary_action(ctx: &Rc<Ctx>, item: &Rc<Item>, anchor: &gtk::Button) { fn primary_action(ctx: &Rc<Ctx>, connector: &str, item: &Rc<Item>, anchor: &gtk::Button) {
let service = ctx.service.borrow(); let service = ctx.service.borrow();
let Some(service) = service.as_ref() else { let Some(service) = service.as_ref() else {
icons::launch(&item.meta); icons::launch(&item.meta);
return; return;
}; };
let behavior = ctx.cfg.borrow().for_output(connector).behavior;
match item.windows.len() { match item.windows.len() {
0 => icons::launch(&item.meta), 0 => icons::launch(&item.meta),
1 => { 1 => {
let w = &item.windows[0]; let w = &item.windows[0];
let click = ctx.cfg.borrow().behavior.click.clone(); if w.activated && !w.minimized && behavior.click == ClickAction::FocusOrMinimize {
if w.activated && !w.minimized && click == ClickAction::FocusOrMinimize {
service.set_minimized(w, true); service.set_minimized(w, true);
} else { } else {
service.activate(w); service.activate(w);
} }
} }
_ => { _ => {
let (click, popup) = { let (click, popup) = (behavior.click.clone(), behavior.group_popup.clone());
let b = &ctx.cfg.borrow().behavior;
(b.click.clone(), b.group_popup.clone())
};
if click == ClickAction::Cycle || popup == GroupPopup::Hover { if click == ClickAction::Cycle || popup == GroupPopup::Hover {
cycle_windows(ctx, item, true); cycle_windows(ctx, item, true);
} else { } else {
@@ -727,8 +759,8 @@ fn primary_action(ctx: &Rc<Ctx>, item: &Rc<Item>, anchor: &gtk::Button) {
} }
} }
fn middle_action(ctx: &Rc<Ctx>, item: &Rc<Item>) { fn middle_action(ctx: &Rc<Ctx>, connector: &str, item: &Rc<Item>) {
let action = ctx.cfg.borrow().behavior.middle_click.clone(); let action = ctx.cfg.borrow().for_output(connector).behavior.middle_click.clone();
match action { match action {
MiddleClick::LaunchNew => icons::launch(&item.meta), MiddleClick::LaunchNew => icons::launch(&item.meta),
MiddleClick::Close => { MiddleClick::Close => {

View File

@@ -109,10 +109,25 @@ thread_local! {
/// Bygg om alla hotspot-ytor enligt configen (körs vid start + configbyte). /// Bygg om alla hotspot-ytor enligt configen (körs vid start + configbyte).
/// Idempotent: oförändrad hotspot-config + skärmuppsättning → ingen ombyggnad. /// Idempotent: oförändrad hotspot-config + skärmuppsättning → ingen ombyggnad.
pub fn rebuild(ctx: &Rc<Ctx>) { pub fn rebuild(ctx: &Rc<Ctx>) {
let n_monitors = gdk::Display::default() let display = gdk::Display::default().expect("ingen display");
.map(|d| d.monitors().n_items()) let monitors = display.monitors();
.unwrap_or(0);
let signature = format!("{:?}|{n_monitors}", ctx.cfg.borrow().hotspots); // 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::<gdk::Monitor>().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); let unchanged = LAST_SIGNATURE.with(|s| *s.borrow() == signature);
if unchanged { if unchanged {
return; return;
@@ -123,11 +138,12 @@ pub fn rebuild(ctx: &Rc<Ctx>) {
win.destroy(); win.destroy();
} }
let cfg = ctx.cfg.borrow(); let mut windows = Vec::new();
let dwell = cfg.hotspots.dwell_ms; for (connector, monitor) in &mons {
let c = cfg.hotspots.corners.clone(); let h = ctx.cfg.borrow().for_output(connector).hotspots;
let e = cfg.hotspots.edges.clone(); let dwell = h.dwell_ms;
drop(cfg); let c = h.corners.clone();
let e = h.edges.clone();
let corner_defs: [(&Option<HotspotAction>, [Edge; 2]); 4] = [ let corner_defs: [(&Option<HotspotAction>, [Edge; 2]); 4] = [
(&c.top_left, [Edge::Top, Edge::Left]), (&c.top_left, [Edge::Top, Edge::Left]),
@@ -135,8 +151,7 @@ pub fn rebuild(ctx: &Rc<Ctx>) {
(&c.bottom_left, [Edge::Bottom, Edge::Left]), (&c.bottom_left, [Edge::Bottom, Edge::Left]),
(&c.bottom_right, [Edge::Bottom, Edge::Right]), (&c.bottom_right, [Edge::Bottom, Edge::Right]),
]; ];
// kanterna lämnar hörnen fria (hörn-ytor ligger ovanpå ändå — overlay // kanterna lämnar hörnen fria (hörn-ytor ligger ovanpå ändå)
// med senare mappade ytor hamnar överst, så det kvittar i praktiken)
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
let edge_defs: [(&Option<HotspotAction>, [Edge; 3], (i32, i32)); 4] = [ let edge_defs: [(&Option<HotspotAction>, [Edge; 3], (i32, i32)); 4] = [
(&e.top, [Edge::Top, Edge::Left, Edge::Right], (-1, EDGE_PX)), (&e.top, [Edge::Top, Edge::Left, Edge::Right], (-1, EDGE_PX)),
@@ -145,19 +160,11 @@ pub fn rebuild(ctx: &Rc<Ctx>) {
(&e.right, [Edge::Right, 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::<gdk::Monitor>().ok())
else {
continue;
};
for (action, edges) in &corner_defs { for (action, edges) in &corner_defs {
if let Some(a) = action { if let Some(a) = action {
windows.push(build_hotspot( windows.push(build_hotspot(
ctx, ctx,
&monitor, monitor,
edges, edges,
(CORNER_PX, CORNER_PX), (CORNER_PX, CORNER_PX),
a.clone(), a.clone(),
@@ -167,7 +174,7 @@ pub fn rebuild(ctx: &Rc<Ctx>) {
} }
for (action, edges, size) in &edge_defs { for (action, edges, size) in &edge_defs {
if let Some(a) = action { 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));
} }
} }
} }

View File

@@ -113,7 +113,7 @@ fn layout_signature(cfg: &config::Config) -> String {
cfg.avoid_windows, cfg.avoid_windows,
cfg.appearance.shadow.enabled, cfg.appearance.shadow.enabled,
cfg.appearance.shadow.blur, cfg.appearance.shadow.blur,
cfg.outputs.keys().collect::<Vec<_>>(), serde_json::to_string(&cfg.outputs).unwrap_or_default(),
) + &format!("|{:?}", cfg.autohide) ) + &format!("|{:?}", cfg.autohide)
} }

View File

@@ -123,25 +123,28 @@ fn toggle_pin(ctx: &Rc<Ctx>, connector: &str) {
}; };
{ {
let mut cfg = ctx.cfg.borrow_mut(); 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 let has_own = cfg
.outputs .outputs
.get(connector) .get(connector)
.map(|o| o.pins.is_some()) .map(|o| o["pins"].is_array())
.unwrap_or(false); .unwrap_or(false);
let pins: &mut Vec<String> = if has_own { let mutate = |pins: &mut Vec<String>| {
cfg.outputs
.get_mut(connector)
.and_then(|o| o.pins.as_mut())
.expect("has_own kollades nyss")
} else {
&mut cfg.pins
};
if pinned { if pinned {
pins.retain(|p| p.to_lowercase() != key); pins.retain(|p| p.to_lowercase() != key);
} else if !pins.iter().any(|p| p.to_lowercase() == key) { } else if !pins.iter().any(|p| p.to_lowercase() == key) {
pins.push(key.clone()); pins.push(key.clone());
} }
};
if has_own {
let over = cfg.outputs.get_mut(connector).expect("has_own kollades nyss");
let mut pins: Vec<String> =
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) { if let Err(e) = config::save(&ctx.cfg_path, &cfg) {
warn!("kunde inte spara config: {e}"); warn!("kunde inte spara config: {e}");
} }

View File

@@ -24,14 +24,58 @@ thread_local! {
const { std::cell::RefCell::new(None) }; const { std::cell::RefCell::new(None) };
static ILLUSTRATION: std::cell::RefCell<Option<glib::WeakRef<gtk::DrawingArea>>> = static ILLUSTRATION: std::cell::RefCell<Option<glib::WeakRef<gtk::DrawingArea>>> =
const { std::cell::RefCell::new(None) }; const { std::cell::RefCell::new(None) };
static PREVIEW: std::cell::RefCell<Option<glib::WeakRef<gtk::DrawingArea>>> =
const { std::cell::RefCell::new(None) };
/// None = "Alla skärmar" (globala configen); Some = en enskild skärm
static SCOPE: std::cell::RefCell<Option<String>> =
const { std::cell::RefCell::new(None) };
}
fn current_scope() -> Option<String> {
SCOPE.with(|s| s.borrow().clone())
}
/// Configen som settings-sidorna ska visa: global eller skärmens effektiva.
fn scoped_config(ctx: &Rc<Ctx>) -> 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). /// 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<Ctx>, mutate: impl FnOnce(&mut config::Config)) { fn commit(ctx: &Rc<Ctx>, 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() { if let Some(cb) = ctx.on_config_changed.borrow().as_ref() {
cb(); cb();
} }
PREVIEW.with(|i| {
if let Some(da) = i.borrow().as_ref().and_then(|w| w.upgrade()) {
da.queue_draw();
}
});
ILLUSTRATION.with(|i| { ILLUSTRATION.with(|i| {
if let Some(da) = i.borrow().as_ref().and_then(|w| w.upgrade()) { if let Some(da) = i.borrow().as_ref().and_then(|w| w.upgrade()) {
da.queue_draw(); da.queue_draw();
@@ -221,7 +265,7 @@ fn hotspot_entry(
fn build_appearance_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow { fn build_appearance_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow {
let (scroll, list) = page(); let (scroll, list) = page();
let a = ctx.cfg.borrow().appearance.clone(); let a = scoped_config(ctx).appearance;
heading(&list, "Panel"); heading(&list, "Panel");
row(&list, "Panelfärg", &color_button(ctx, &a.background, |c, v| c.appearance.background = v)); row(&list, "Panelfärg", &color_button(ctx, &a.background, |c, v| c.appearance.background = v));
@@ -267,7 +311,7 @@ fn margin_illustration(ctx: &Rc<Ctx>) -> gtk::DrawingArea {
da.set_halign(gtk::Align::Center); da.set_halign(gtk::Align::Center);
let ctx2 = ctx.clone(); let ctx2 = ctx.clone();
da.set_draw_func(move |_, cr, w, h| { 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); let (w, h) = (w as f64, h as f64);
// "skärmen" // "skärmen"
@@ -381,7 +425,7 @@ fn margin_illustration(ctx: &Rc<Ctx>) -> gtk::DrawingArea {
fn build_layout_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow { fn build_layout_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow {
let (scroll, list) = page(); let (scroll, list) = page();
let cfg = ctx.cfg.borrow().clone(); let cfg = scoped_config(ctx);
heading(&list, "Placering"); heading(&list, "Placering");
let pos_idx = match cfg.position { let pos_idx = match cfg.position {
@@ -427,9 +471,9 @@ fn build_layout_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow {
heading(&list, "Marginaler"); heading(&list, "Marginaler");
list.append(&margin_illustration(ctx)); 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, "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, (0.0, 512.0), 1.0, |c, v| c.margin_start = 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, (0.0, 512.0), 1.0, |c, v| c.margin_end = 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( 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).", "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<Ctx>) -> gtk::ScrolledWindow {
fn build_behavior_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow { fn build_behavior_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow {
let (scroll, list) = page(); let (scroll, list) = page();
let b = ctx.cfg.borrow().behavior.clone(); let scoped = scoped_config(ctx);
let sp = ctx.cfg.borrow().special_buttons.clone(); let b = scoped.behavior.clone();
let sp = scoped.special_buttons.clone();
heading(&list, "Klick"); heading(&list, "Klick");
let click_idx = match b.click { let click_idx = match b.click {
@@ -534,7 +579,7 @@ fn build_behavior_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow {
("Stäng", MenuItem::Close), ("Stäng", MenuItem::Close),
]; ];
for (label, mi) in entries { 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(); let mi2 = mi.clone();
row(&list, label, &switch(ctx, present, move |c, v| { row(&list, label, &switch(ctx, present, move |c, v| {
if v { if v {
@@ -561,7 +606,7 @@ fn build_pins_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow {
while let Some(c) = pins_box.first_child() { while let Some(c) = pins_box.first_child() {
pins_box.remove(&c); 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() { for (i, pin) in pins.iter().enumerate() {
let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 6); let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 6);
let name = crate::icons::lookup_pin(pin) let name = crate::icons::lookup_pin(pin)
@@ -592,7 +637,9 @@ fn build_pins_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow {
let pb3 = pins_box.clone(); let pb3 = pins_box.clone();
remove.connect_clicked(move |_| { remove.connect_clicked(move |_| {
commit(&ctx3, |c| { commit(&ctx3, |c| {
if i < c.pins.len() {
c.pins.remove(i); c.pins.remove(i);
}
}); });
rebuild_rows(&ctx3, &pb3); rebuild_rows(&ctx3, &pb3);
}); });
@@ -643,7 +690,7 @@ fn build_pins_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow {
fn build_hotspots_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow { fn build_hotspots_page(ctx: &Rc<Ctx>) -> gtk::ScrolledWindow {
let (scroll, list) = page(); let (scroll, list) = page();
let h = ctx.cfg.borrow().hotspots.clone(); let h = scoped_config(ctx).hotspots.clone();
heading(&list, "Hot corners"); 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 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<Ctx>) -> gtk::ScrolledWindow {
scroll 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::<gdk::Monitor>().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<std::cell::RefCell<Option<Rc<dyn Fn()>>>>;
fn build_preview(ctx: &Rc<Ctx>, 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<Ctx>) -> gtk::Notebook {
let notebook = gtk::Notebook::new();
notebook.set_vexpand(true);
notebook.append_page(&build_appearance_page(ctx), Some(&gtk::Label::new(Some("Utseende"))));
notebook.append_page(&build_layout_page(ctx), Some(&gtk::Label::new(Some("Layout"))));
notebook.append_page(&build_behavior_page(ctx), Some(&gtk::Label::new(Some("Beteende"))));
notebook.append_page(&build_pins_page(ctx), Some(&gtk::Label::new(Some("Pins"))));
notebook.append_page(&build_hotspots_page(ctx), Some(&gtk::Label::new(Some("Hotspots"))));
notebook
}
/// Öppna settings-fönstret (återanvänder ett redan öppet). /// Öppna settings-fönstret (återanvänder ett redan öppet).
pub fn open(ctx: &Rc<Ctx>) { pub fn open(ctx: &Rc<Ctx>) {
let existing = OPEN_WINDOW.with(|w| w.borrow().as_ref().and_then(|w| w.upgrade())); let existing = OPEN_WINDOW.with(|w| w.borrow().as_ref().and_then(|w| w.upgrade()));
@@ -675,24 +858,136 @@ pub fn open(ctx: &Rc<Ctx>) {
win.present(); win.present();
return; return;
} }
SCOPE.with(|s| *s.borrow_mut() = None);
let win = gtk::Window::new(); let win = gtk::Window::new();
win.set_title(Some("waydock — inställningar")); win.set_title(Some("waydock — inställningar"));
win.set_icon_name(Some("waydock")); win.set_icon_name(Some("waydock"));
win.set_default_size(520, 620); win.set_default_size(560, 760);
let notebook = gtk::Notebook::new(); let root = gtk::Box::new(gtk::Orientation::Vertical, 6);
notebook.append_page(&build_appearance_page(ctx), Some(&gtk::Label::new(Some("Utseende")))); root.set_margin_top(10);
notebook.append_page(&build_layout_page(ctx), Some(&gtk::Label::new(Some("Layout")))); root.set_margin_bottom(4);
notebook.append_page(&build_behavior_page(ctx), Some(&gtk::Label::new(Some("Beteende")))); root.set_margin_start(10);
notebook.append_page(&build_pins_page(ctx), Some(&gtk::Label::new(Some("Pins")))); root.set_margin_end(10);
notebook.append_page(&build_hotspots_page(ctx), Some(&gtk::Label::new(Some("Hotspots"))));
win.set_child(Some(&notebook)); 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<Option<String>> = vec![None];
for (name, _) in monitor_list() {
scope_values.push(Some(name));
}
#[allow(clippy::type_complexity)]
let toggles: Rc<std::cell::RefCell<Vec<(Option<String>, 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<dyn Fn()> = 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())); OPEN_WINDOW.with(|w| *w.borrow_mut() = Some(win.downgrade()));
win.present(); win.present();
} }
// hindra att Cell-importen städas bort av misstag när sidor ändras
#[allow(unused)]
fn _keep(_: Cell<u8>) {}

View File

@@ -1,12 +1,22 @@
// Config → CSS. All theming (färger, transparens, skuggor, accent, // Config → CSS. All theming (färger, transparens, skuggor, accent,
// storlekar, animationer) genereras här och laddas i en CssProvider som // 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-<connector>).
use gtk4 as gtk; use gtk4 as gtk;
use gtk4::gdk; use gtk4::gdk;
use crate::config::{Config, IndicatorStyle}; 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å. /// "#rrggbb" + alpha → "rgba(r, g, b, a)". Trasig färg → grå.
fn rgba(hex: &str, alpha: f64) -> String { fn rgba(hex: &str, alpha: f64) -> String {
let h = hex.trim_start_matches('#'); let h = hex.trim_start_matches('#');
@@ -22,7 +32,9 @@ fn rgba(hex: &str, alpha: f64) -> String {
format!("rgba({r}, {g}, {b}, {alpha:.3})") 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 a = &cfg.appearance;
let bg = rgba(&a.background, a.opacity); let bg = rgba(&a.background, a.opacity);
let border = rgba(&a.border.color, a.border.opacity); let border = rgba(&a.border.color, a.border.opacity);
@@ -52,18 +64,7 @@ pub fn build_css(cfg: &Config) -> String {
format!( format!(
r#" r#"
window.waydock, {p}.dock-panel {{
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 {{
background-color: {bg}; background-color: {bg};
border-radius: {radius}px; border-radius: {radius}px;
border: {bwidth}px solid {border}; border: {bwidth}px solid {border};
@@ -71,7 +72,7 @@ window.waydock-hotspot box {{
{shadow} {shadow}
}} }}
.dock-item {{ {p}.dock-item {{
background: transparent; background: transparent;
border: none; border: none;
border-radius: {item_radius}px; border-radius: {item_radius}px;
@@ -82,33 +83,28 @@ window.waydock-hotspot box {{
{transition} {transition}
}} }}
.dock-item:hover {{ {p}.dock-item:hover {{
background-color: {accent_soft}; background-color: {accent_soft};
}} }}
.dock-item:hover image {{ {p}.dock-item:hover image {{
{hover_zoom} {hover_zoom}
}} }}
.dock-item image {{ {p}.dock-item image {{
{transition} {transition}
}} }}
.dock-item.urgent {{ {p}.dock-item.urgent {{
background-color: {accent_soft}; background-color: {accent_soft};
animation: waydock-pulse 900ms ease-in-out infinite alternate; animation: waydock-pulse 900ms ease-in-out infinite alternate;
}} }}
@keyframes waydock-pulse {{ {p}.indicator-box {{
from {{ background-color: transparent; }}
to {{ background-color: {accent_soft}; }}
}}
.indicator-box {{
min-height: {ind_h}px; min-height: {ind_h}px;
}} }}
.indicator {{ {p}.indicator {{
background-color: {accent}; background-color: {accent};
border-radius: {ind_radius}px; border-radius: {ind_radius}px;
min-width: {ind_w}px; min-width: {ind_w}px;
@@ -116,11 +112,11 @@ window.waydock-hotspot box {{
margin: 0 1px; margin: 0 1px;
}} }}
.indicator.inactive {{ {p}.indicator.inactive {{
background-color: {accent_dim}; background-color: {accent_dim};
}} }}
.group-badge {{ {p}.group-badge {{
background-color: {accent}; background-color: {accent};
color: #ffffff; color: #ffffff;
border-radius: 8px; border-radius: 8px;
@@ -131,24 +127,25 @@ window.waydock-hotspot box {{
padding: 0 2px; padding: 0 2px;
}} }}
popover.waydock-menu contents {{ {p}popover.waydock-menu contents {{
background-color: {menu_bg}; background-color: {menu_bg};
border-radius: 10px; border-radius: 10px;
border: 1px solid {border}; border: 1px solid {border};
}} }}
popover.waydock-menu modelbutton, {p}popover.waydock-menu modelbutton,
popover.waydock-menu button.flat {{ {p}popover.waydock-menu button.flat {{
color: #eeeeee; color: #eeeeee;
border-radius: 6px; border-radius: 6px;
padding: 6px 10px; padding: 6px 10px;
}} }}
popover.waydock-menu modelbutton:hover, {p}popover.waydock-menu modelbutton:hover,
popover.waydock-menu button.flat:hover {{ {p}popover.waydock-menu button.flat:hover {{
background-color: {accent_soft}; background-color: {accent_soft};
}} }}
"#, "#,
p = p,
bg = bg, bg = bg,
radius = a.corner_radius, radius = a.corner_radius,
bwidth = a.border.width, 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. /// Ladda (eller byt ut) den globala CSS-providern.
pub fn apply(cfg: &Config, previous: Option<&gtk::CssProvider>) -> gtk::CssProvider { pub fn apply(cfg: &Config, previous: Option<&gtk::CssProvider>) -> gtk::CssProvider {
let display = gdk::Display::default().expect("ingen display"); let display = gdk::Display::default().expect("ingen display");