// 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, } /// Var längs kanten ön ligger (start = vänster/topp, end = höger/botten). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum Alignment { Center, Start, End, } /// Hur fönsterlistan för en grupperad app öppnas. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum GroupPopup { Click, Hover, } /// Vad skroll gör på en app som inte är igång (pinnad, 0 fönster). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum ScrollStopped { None, Launch, } /// Vad som händer när man hovrar en rad i fönsterlistan. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum ListHover { /// tona ner alla andra fönster så bara det hovrade syns tydligt Peek, /// fokusera/lyft fönstret direkt vid hover Focus, None, } #[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, pub group_popup: GroupPopup, pub list_hover: ListHover, pub scroll_stopped: ScrollStopped, } 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, group_popup: GroupPopup::Click, list_hover: ListHover::Peek, scroll_stopped: ScrollStopped::None, } } } // 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)] pub struct SpecialButtons { pub show_desktop: bool, pub overview: bool, } impl Default for SpecialButtons { fn default() -> Self { Self { show_desktop: true, overview: true } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum AutohideMode { Dodge, Always, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct Autohide { pub enabled: bool, pub mode: AutohideMode, pub hide_delay_ms: u32, pub reveal_px: i32, pub animation_ms: u32, } impl Default for Autohide { fn default() -> Self { Self { enabled: false, mode: AutohideMode::Dodge, hide_delay_ms: 500, reveal_px: 2, animation_ms: 200, } } } /// En hotspot-action: kör ett kommando eller anropa Wayfire IPC. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum HotspotAction { Exec(String), Ipc(String), } #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default)] pub struct Corners { pub top_left: Option, pub top_right: Option, pub bottom_left: Option, pub bottom_right: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default)] pub struct Edges { pub top: Option, pub bottom: Option, pub left: Option, pub right: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct Hotspots { pub dwell_ms: u32, pub corners: Corners, pub edges: Edges, } impl Default for Hotspots { fn default() -> Self { Self { dwell_ms: 300, corners: Corners::default(), edges: Edges::default() } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct LogConfig { pub level: String, pub file: Option, } impl Default for LogConfig { fn default() -> Self { Self { level: "info".into(), file: None } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct Config { pub position: Position, pub alignment: Alignment, /// false = ö som är så stor som innehållet; true = täck hela kanten pub expand: bool, /// avstånd från kanten baren ligger mot (px) pub margin: i32, /// extra marginal ovanpå det reserverade området (px). 0 = fönster /// maximeras ända till panelens kant; minus låter dem gå närmare /// (in i skuggan), plus ger luft mellan panel och fönster pub reserve_margin: i32, /// marginal i början av kanten: vänster (topp/botten) resp. topp (vänster/höger) pub margin_start: i32, /// marginal i slutet av kanten: höger resp. botten pub margin_end: i32, pub layer: Layer, pub avoid_windows: bool, pub appearance: Appearance, pub behavior: Behavior, pub context_menu: Vec, pub pins: Vec, pub special_buttons: SpecialButtons, pub outputs: HashMap, pub autohide: Autohide, pub hotspots: Hotspots, pub log: LogConfig, } impl Default for Config { fn default() -> Self { Self { position: Position::Bottom, alignment: Alignment::Center, expand: false, margin: 8, reserve_margin: 0, margin_start: 0, margin_end: 0, 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(), } } } /// 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 { /// 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"].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"].as_bool()) .unwrap_or(false) } } pub fn default_path() -> PathBuf { glib::user_config_dir().join("waydock").join("config.json") } /// Läs configen; skapa default-fil om den saknas. Trasig fil → defaults. pub fn load_or_create(path: &Path) -> Config { match std::fs::read_to_string(path) { Ok(text) => match serde_json::from_str::(&text) { Ok(cfg) => { info!("läste config från {}", path.display()); cfg } Err(e) => { error!("trasig config ({e}) — kör med defaults, filen lämnas orörd"); Config::default() } }, Err(_) => { let cfg = Config::default(); if let Err(e) = save(path, &cfg) { warn!("kunde inte skriva default-config: {e}"); } else { info!("skapade default-config i {}", path.display()); } cfg } } } pub fn save(path: &Path, cfg: &Config) -> std::io::Result<()> { if let Some(dir) = path.parent() { std::fs::create_dir_all(dir)?; } let json = serde_json::to_string_pretty(cfg).expect("config är alltid serialiserbar"); // atomiskt: skriv temp + rename, så filewatchers aldrig ser en halv fil let tmp = path.with_extension("json.tmp"); std::fs::write(&tmp, json)?; std::fs::rename(&tmp, path) } /// Filewatcher med debounce: anropar `on_change` på GTK-tråden när /// configfilen ändrats (oavsett om det var settings-GUI:t eller en editor). pub fn watch(path: &Path, on_change: Rc) { use notify::{RecursiveMode, Watcher}; use std::cell::Cell; let (tx, rx) = async_channel::unbounded::<()>(); let file_name = path.file_name().map(|s| s.to_os_string()); let dir = path.parent().unwrap_or(Path::new(".")).to_path_buf(); let mut watcher = match notify::recommended_watcher(move |res: notify::Result| { use notify::event::{AccessKind, AccessMode, EventKind}; if let Ok(ev) = res { // VIKTIGT: bara riktiga skrivningar. Access-events (öppna/läsa) // måste ignoreras — annars triggar vår egen läsning en ny // omladdning i all oändlighet. let relevant = matches!( ev.kind, EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_) | EventKind::Access(AccessKind::Close(AccessMode::Write)) ); let hit = relevant && 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>;