// Settings-GUI:t: ett GTK4-fönster som redigerar hela JSON-configen. // Varje widgetändring uppdaterar delade configen, applicerar direkt // (snabb feedback) och sparar filen debouncat — samma fil som kan // handredigeras, filewatchern håller allt i synk. use std::cell::Cell; use std::rc::Rc; use std::time::Duration; use gtk4 as gtk; use gtk4::gdk; use gtk4::glib; use gtk4::prelude::*; use log::warn; use crate::config::{ self, AutohideMode, ClickAction, HotspotAction, IndicatorStyle, Layer, MenuItem, MiddleClick, Position, ScrollAction, }; use crate::dock::Ctx; thread_local! { static OPEN_WINDOW: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; } /// Uppdatera config + applicera + spara (debouncat 300 ms). fn commit(ctx: &Rc, mutate: impl FnOnce(&mut config::Config)) { mutate(&mut ctx.cfg.borrow_mut()); if let Some(cb) = ctx.on_config_changed.borrow().as_ref() { cb(); } let generation = ctx.save_generation.get() + 1; ctx.save_generation.set(generation); let ctx = ctx.clone(); glib::timeout_add_local_once(Duration::from_millis(300), move || { if ctx.save_generation.get() == generation { if let Err(e) = config::save(&ctx.cfg_path, &ctx.cfg.borrow()) { warn!("kunde inte spara config: {e}"); } } }); } fn hex_of(rgba: &gdk::RGBA) -> String { format!( "#{:02x}{:02x}{:02x}", (rgba.red() * 255.0) as u8, (rgba.green() * 255.0) as u8, (rgba.blue() * 255.0) as u8 ) } // -- widgethjälpare ------------------------------------------------------ fn page() -> (gtk::ScrolledWindow, gtk::Box) { let list = gtk::Box::new(gtk::Orientation::Vertical, 6); list.set_margin_top(12); list.set_margin_bottom(12); list.set_margin_start(14); list.set_margin_end(14); let scroll = gtk::ScrolledWindow::new(); scroll.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic); scroll.set_child(Some(&list)); (scroll, list) } fn row(list: >k::Box, label: &str, widget: &impl IsA) { let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 10); let lbl = gtk::Label::new(Some(label)); lbl.set_halign(gtk::Align::Start); lbl.set_hexpand(true); hbox.append(&lbl); hbox.append(widget); list.append(&hbox); } fn heading(list: >k::Box, text: &str) { let lbl = gtk::Label::new(None); lbl.set_markup(&format!("{text}")); lbl.set_halign(gtk::Align::Start); lbl.set_margin_top(10); list.append(&lbl); } fn switch(ctx: &Rc, active: bool, set: impl Fn(&mut config::Config, bool) + 'static) -> gtk::Switch { let sw = gtk::Switch::new(); sw.set_active(active); sw.set_valign(gtk::Align::Center); let ctx = ctx.clone(); let set = Rc::new(set); sw.connect_active_notify(move |sw| { let v = sw.is_active(); let set = set.clone(); commit(&ctx, move |c| set(c, v)); }); sw } fn spin( ctx: &Rc, value: f64, range: (f64, f64), step: f64, set: impl Fn(&mut config::Config, f64) + 'static, ) -> gtk::SpinButton { let sb = gtk::SpinButton::with_range(range.0, range.1, step); sb.set_value(value); let ctx = ctx.clone(); let set = Rc::new(set); sb.connect_value_changed(move |sb| { let v = sb.value(); let set = set.clone(); commit(&ctx, move |c| set(c, v)); }); sb } fn scale01(ctx: &Rc, value: f64, set: impl Fn(&mut config::Config, f64) + 'static) -> gtk::Scale { let sc = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 0.01); sc.set_value(value); sc.set_size_request(160, -1); sc.set_draw_value(false); let ctx = ctx.clone(); let set = Rc::new(set); sc.connect_value_changed(move |sc| { let v = sc.value(); let set = set.clone(); commit(&ctx, move |c| set(c, v)); }); sc } fn color_button( ctx: &Rc, hex: &str, set: impl Fn(&mut config::Config, String) + 'static, ) -> gtk::ColorDialogButton { let btn = gtk::ColorDialogButton::new(Some(gtk::ColorDialog::new())); if let Ok(rgba) = gdk::RGBA::parse(hex) { btn.set_rgba(&rgba); } let ctx = ctx.clone(); let set = Rc::new(set); btn.connect_rgba_notify(move |btn| { let hex = hex_of(&btn.rgba()); let set = set.clone(); commit(&ctx, move |c| set(c, hex)); }); btn } fn dropdown( ctx: &Rc, options: &[&str], selected: usize, set: impl Fn(&mut config::Config, usize) + 'static, ) -> gtk::DropDown { let dd = gtk::DropDown::from_strings(options); dd.set_selected(selected as u32); let ctx = ctx.clone(); let set = Rc::new(set); dd.connect_selected_notify(move |dd| { let i = dd.selected() as usize; let set = set.clone(); commit(&ctx, move |c| set(c, i)); }); dd } // -- hotspot-hjälpare ---------------------------------------------------- fn hotspot_to_text(a: &Option) -> String { match a { Some(HotspotAction::Exec(cmd)) => format!("exec:{cmd}"), Some(HotspotAction::Ipc(m)) => format!("ipc:{m}"), None => String::new(), } } fn text_to_hotspot(text: &str) -> Option { let t = text.trim(); if let Some(cmd) = t.strip_prefix("exec:") { Some(HotspotAction::Exec(cmd.trim().to_string())) } else if let Some(m) = t.strip_prefix("ipc:") { Some(HotspotAction::Ipc(m.trim().to_string())) } else if t.is_empty() { None } else { Some(HotspotAction::Exec(t.to_string())) } } fn hotspot_entry( ctx: &Rc, current: &Option, set: impl Fn(&mut config::Config, Option) + 'static, ) -> gtk::Entry { let entry = gtk::Entry::new(); entry.set_text(&hotspot_to_text(current)); entry.set_placeholder_text(Some("exec:kommando eller ipc:scale/toggle")); entry.set_width_chars(24); let ctx = ctx.clone(); let set = Rc::new(set); entry.connect_changed(move |e| { let action = text_to_hotspot(&e.text()); let set = set.clone(); commit(&ctx, move |c| set(c, action)); }); entry } // -- sidorna ------------------------------------------------------------- fn build_appearance_page(ctx: &Rc) -> gtk::ScrolledWindow { let (scroll, list) = page(); let a = ctx.cfg.borrow().appearance.clone(); heading(&list, "Panel"); row(&list, "Panelfärg", &color_button(ctx, &a.background, |c, v| c.appearance.background = v)); row(&list, "Transparens", &scale01(ctx, a.opacity, |c, v| c.appearance.opacity = v)); row(&list, "Hörnradie", &spin(ctx, a.corner_radius as f64, (0.0, 40.0), 1.0, |c, v| c.appearance.corner_radius = v as u32)); row(&list, "Accentfärg", &color_button(ctx, &a.accent, |c, v| c.appearance.accent = v)); heading(&list, "Kant & skugga"); row(&list, "Kantfärg", &color_button(ctx, &a.border.color, |c, v| c.appearance.border.color = v)); row(&list, "Kantopacitet", &scale01(ctx, a.border.opacity, |c, v| c.appearance.border.opacity = v)); row(&list, "Kantbredd", &spin(ctx, a.border.width as f64, (0.0, 8.0), 1.0, |c, v| c.appearance.border.width = v as u32)); row(&list, "Skugga", &switch(ctx, a.shadow.enabled, |c, v| c.appearance.shadow.enabled = v)); row(&list, "Skuggstorlek", &spin(ctx, a.shadow.blur as f64, (0.0, 60.0), 1.0, |c, v| c.appearance.shadow.blur = v as u32)); row(&list, "Skuggopacitet", &scale01(ctx, a.shadow.opacity, |c, v| c.appearance.shadow.opacity = v)); heading(&list, "Ikoner"); row(&list, "Ikonstorlek", &spin(ctx, a.icon_size as f64, (16.0, 96.0), 2.0, |c, v| c.appearance.icon_size = v as i32)); row(&list, "Ikonavstånd", &spin(ctx, a.icon_spacing as f64, (0.0, 32.0), 1.0, |c, v| c.appearance.icon_spacing = v as i32)); row(&list, "Panel-padding", &spin(ctx, a.padding as f64, (0.0, 32.0), 1.0, |c, v| c.appearance.padding = v as i32)); row(&list, "Hover-zoom", &switch(ctx, a.hover_zoom, |c, v| c.appearance.hover_zoom = v)); row(&list, "Animationer", &switch(ctx, a.animations, |c, v| c.appearance.animations = v)); let ind_idx = match a.indicator.style { IndicatorStyle::Dot => 0, IndicatorStyle::Line => 1, IndicatorStyle::None => 2, }; row(&list, "Indikatorstil", &dropdown(ctx, &["Prickar", "Linje", "Ingen"], ind_idx, |c, i| { c.appearance.indicator.style = match i { 1 => IndicatorStyle::Line, 2 => IndicatorStyle::None, _ => IndicatorStyle::Dot, }; })); row(&list, "Max indikatorer", &spin(ctx, a.indicator.max as f64, (1.0, 5.0), 1.0, |c, v| c.appearance.indicator.max = v as u32)); scroll } fn build_layout_page(ctx: &Rc) -> gtk::ScrolledWindow { let (scroll, list) = page(); let cfg = ctx.cfg.borrow().clone(); heading(&list, "Placering"); let pos_idx = match cfg.position { Position::Bottom => 0, Position::Top => 1, Position::Left => 2, Position::Right => 3, }; row(&list, "Kant", &dropdown(ctx, &["Nere", "Uppe", "Vänster", "Höger"], pos_idx, |c, i| { c.position = match i { 1 => Position::Top, 2 => Position::Left, 3 => Position::Right, _ => Position::Bottom, }; })); row(&list, "Centrerad ö", &switch(ctx, cfg.centered, |c, v| c.centered = v)); row(&list, "Marginal från kant", &spin(ctx, cfg.margin as f64, (0.0, 64.0), 1.0, |c, v| c.margin = v as i32)); let layer_idx = match cfg.layer { Layer::Top => 0, Layer::Overlay => 1, Layer::Bottom => 2, }; row(&list, "Lager", &dropdown(ctx, &["Ovanpå fönster", "Alltid överst (overlay)", "Under fönster"], layer_idx, |c, i| { c.layer = match i { 1 => Layer::Overlay, 2 => Layer::Bottom, _ => Layer::Top, }; })); row(&list, "Reservera plats (undvik fönster)", &switch(ctx, cfg.avoid_windows, |c, v| c.avoid_windows = v)); heading(&list, "Autohide"); row(&list, "Aktiverad", &switch(ctx, cfg.autohide.enabled, |c, v| c.autohide.enabled = v)); let mode_idx = match cfg.autohide.mode { AutohideMode::Dodge => 0, AutohideMode::Always => 1, }; row(&list, "Läge", &dropdown(ctx, &["Dodge (göm vid överlapp)", "Göm alltid"], mode_idx, |c, i| { c.autohide.mode = if i == 1 { AutohideMode::Always } else { AutohideMode::Dodge }; })); row(&list, "Fördröjning (ms)", &spin(ctx, cfg.autohide.hide_delay_ms as f64, (0.0, 3000.0), 50.0, |c, v| c.autohide.hide_delay_ms = v as u32)); row(&list, "Sensorbredd (px)", &spin(ctx, cfg.autohide.reveal_px as f64, (1.0, 16.0), 1.0, |c, v| c.autohide.reveal_px = v as i32)); row(&list, "Animationstid (ms)", &spin(ctx, cfg.autohide.animation_ms as f64, (0.0, 1000.0), 25.0, |c, v| c.autohide.animation_ms = v as u32)); let note = gtk::Label::new(Some("Per-skärm-inställningar (unika pins, only_this_output)\nredigeras i config.json under \"outputs\".")); note.add_css_class("dim-label"); note.set_halign(gtk::Align::Start); note.set_margin_top(12); list.append(¬e); scroll } fn build_behavior_page(ctx: &Rc) -> gtk::ScrolledWindow { let (scroll, list) = page(); let b = ctx.cfg.borrow().behavior.clone(); let sp = ctx.cfg.borrow().special_buttons.clone(); heading(&list, "Klick"); let click_idx = match b.click { ClickAction::FocusOrMinimize => 0, ClickAction::Focus => 1, ClickAction::Cycle => 2, }; row(&list, "Vänsterklick", &dropdown(ctx, &["Fokusera/minimera", "Bara fokusera", "Växla fönster"], click_idx, |c, i| { c.behavior.click = match i { 1 => ClickAction::Focus, 2 => ClickAction::Cycle, _ => ClickAction::FocusOrMinimize, }; })); let mid_idx = match b.middle_click { MiddleClick::LaunchNew => 0, MiddleClick::Close => 1, MiddleClick::None => 2, }; row(&list, "Mittklick", &dropdown(ctx, &["Starta ny instans", "Stäng", "Inget"], mid_idx, |c, i| { c.behavior.middle_click = match i { 1 => MiddleClick::Close, 2 => MiddleClick::None, _ => MiddleClick::LaunchNew, }; })); let scroll_idx = match b.scroll { ScrollAction::CycleWindows => 0, ScrollAction::None => 1, }; row(&list, "Skroll", &dropdown(ctx, &["Växla fönster", "Inget"], scroll_idx, |c, i| { c.behavior.scroll = if i == 1 { ScrollAction::None } else { ScrollAction::CycleWindows }; })); heading(&list, "Övrigt"); row(&list, "Gruppera appar", &switch(ctx, b.group_apps, |c, v| c.behavior.group_apps = v)); row(&list, "Tooltips", &switch(ctx, b.tooltips, |c, v| c.behavior.tooltips = v)); row(&list, "Urgency-puls", &switch(ctx, b.urgent_pulse, |c, v| c.behavior.urgent_pulse = v)); heading(&list, "Specialknappar"); row(&list, "Visa skrivbordet", &switch(ctx, sp.show_desktop, |c, v| c.special_buttons.show_desktop = v)); row(&list, "Översikt (scale)", &switch(ctx, sp.overview, |c, v| c.special_buttons.overview = v)); heading(&list, "Högerklicksmenyn"); let entries: [(&str, MenuItem); 5] = [ ("Minimera", MenuItem::Minimize), ("Maximera", MenuItem::Maximize), ("Flytta till skärm", MenuItem::MoveToOutput), ("Pinna/släpp", MenuItem::Pin), ("Stäng", MenuItem::Close), ]; for (label, mi) in entries { let present = ctx.cfg.borrow().context_menu.contains(&mi); let mi2 = mi.clone(); row(&list, label, &switch(ctx, present, move |c, v| { if v { if !c.context_menu.contains(&mi2) { c.context_menu.push(mi2.clone()); } } else { c.context_menu.retain(|m| *m != mi2); } })); } scroll } fn build_pins_page(ctx: &Rc) -> gtk::ScrolledWindow { let (scroll, list) = page(); heading(&list, "Globala pins (.desktop-id, i ordning)"); let pins_box = gtk::Box::new(gtk::Orientation::Vertical, 4); list.append(&pins_box); fn rebuild_rows(ctx: &Rc, pins_box: >k::Box) { while let Some(c) = pins_box.first_child() { pins_box.remove(&c); } let pins = ctx.cfg.borrow().pins.clone(); for (i, pin) in pins.iter().enumerate() { let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 6); let name = crate::icons::lookup_pin(pin) .map(|m| m.display_name) .unwrap_or_else(|| "⚠ hittas inte".into()); let lbl = gtk::Label::new(Some(&format!("{pin} ({name})"))); lbl.set_halign(gtk::Align::Start); lbl.set_hexpand(true); hbox.append(&lbl); let up = gtk::Button::from_icon_name("go-up-symbolic"); up.add_css_class("flat"); let remove = gtk::Button::from_icon_name("edit-delete-symbolic"); remove.add_css_class("flat"); hbox.append(&up); hbox.append(&remove); pins_box.append(&hbox); let ctx2 = ctx.clone(); let pb2 = pins_box.clone(); up.connect_clicked(move |_| { if i > 0 { commit(&ctx2, |c| c.pins.swap(i, i - 1)); rebuild_rows(&ctx2, &pb2); } }); let ctx3 = ctx.clone(); let pb3 = pins_box.clone(); remove.connect_clicked(move |_| { commit(&ctx3, |c| { c.pins.remove(i); }); rebuild_rows(&ctx3, &pb3); }); } } rebuild_rows(ctx, &pins_box); let add_box = gtk::Box::new(gtk::Orientation::Horizontal, 6); let entry = gtk::Entry::new(); entry.set_placeholder_text(Some("t.ex. org.kde.konsole")); entry.set_hexpand(true); let add = gtk::Button::with_label("Lägg till"); add_box.append(&entry); add_box.append(&add); list.append(&add_box); { let ctx = ctx.clone(); let pins_box = pins_box.clone(); add.connect_clicked(move |_| { let id = entry.text().trim().to_string(); if id.is_empty() { return; } if crate::icons::lookup_pin(&id).is_none() { entry.add_css_class("error"); return; } entry.remove_css_class("error"); entry.set_text(""); let id2 = id.clone(); commit(&ctx, move |c| { if !c.pins.contains(&id2) { c.pins.push(id2.clone()); } }); rebuild_rows(&ctx, &pins_box); }); } let note = gtk::Label::new(Some("Tips: hitta id med ls /usr/share/applications | grep -i \nPer-skärm-pins redigeras i config.json under \"outputs\".")); note.add_css_class("dim-label"); note.set_halign(gtk::Align::Start); note.set_margin_top(12); list.append(¬e); scroll } fn build_hotspots_page(ctx: &Rc) -> gtk::ScrolledWindow { let (scroll, list) = page(); let h = ctx.cfg.borrow().hotspots.clone(); heading(&list, "Hot corners"); row(&list, "Uppe vänster", &hotspot_entry(ctx, &h.corners.top_left, |c, v| c.hotspots.corners.top_left = v)); row(&list, "Uppe höger", &hotspot_entry(ctx, &h.corners.top_right, |c, v| c.hotspots.corners.top_right = v)); row(&list, "Nere vänster", &hotspot_entry(ctx, &h.corners.bottom_left, |c, v| c.hotspots.corners.bottom_left = v)); row(&list, "Nere höger", &hotspot_entry(ctx, &h.corners.bottom_right, |c, v| c.hotspots.corners.bottom_right = v)); heading(&list, "Hot edges"); row(&list, "Uppe", &hotspot_entry(ctx, &h.edges.top, |c, v| c.hotspots.edges.top = v)); row(&list, "Nere", &hotspot_entry(ctx, &h.edges.bottom, |c, v| c.hotspots.edges.bottom = v)); row(&list, "Vänster", &hotspot_entry(ctx, &h.edges.left, |c, v| c.hotspots.edges.left = v)); row(&list, "Höger", &hotspot_entry(ctx, &h.edges.right, |c, v| c.hotspots.edges.right = v)); row(&list, "Dwell-tid (ms)", &spin(ctx, h.dwell_ms as f64, (0.0, 2000.0), 50.0, |c, v| c.hotspots.dwell_ms = v as u32)); let note = gtk::Label::new(Some("Format: exec: eller ipc: (t.ex. ipc:scale/toggle).\nTomt fält = av. IPC kräver Wayfires ipc-plugin.")); note.add_css_class("dim-label"); note.set_halign(gtk::Align::Start); note.set_margin_top(12); list.append(¬e); scroll } /// Öppna settings-fönstret (återanvänder ett redan öppet). pub fn open(ctx: &Rc) { let existing = OPEN_WINDOW.with(|w| w.borrow().as_ref().and_then(|w| w.upgrade())); if let Some(win) = existing { win.present(); return; } let win = gtk::Window::new(); win.set_title(Some("waydock — inställningar")); win.set_default_size(520, 620); let notebook = gtk::Notebook::new(); notebook.append_page(&build_appearance_page(ctx), Some(>k::Label::new(Some("Utseende")))); notebook.append_page(&build_layout_page(ctx), Some(>k::Label::new(Some("Layout")))); notebook.append_page(&build_behavior_page(ctx), Some(>k::Label::new(Some("Beteende")))); notebook.append_page(&build_pins_page(ctx), Some(>k::Label::new(Some("Pins")))); notebook.append_page(&build_hotspots_page(ctx), Some(>k::Label::new(Some("Hotspots")))); win.set_child(Some(¬ebook)); OPEN_WINDOW.with(|w| *w.borrow_mut() = Some(win.downgrade())); win.present(); } // hindra att Cell-importen städas bort av misstag när sidor ändras #[allow(unused)] fn _keep(_: Cell) {}