waydock v0.1: panel, pins, fönstergrupper, menyer, settings-GUI, live-config
Some checks failed
check / check (push) Failing after 52s

M0–M5 ur doc/plan.md: layer-shell-dock per skärm med transparent
#222222-ö, pins + körande appar (gruppering, badges, indikatorer),
klick/skroll/mittklick, konfigurerbar högerklicksmeny, tomyta-meny,
settings-GUI som redigerar JSON-configen live, filewatcher,
Wayfire-IPC-stubbar (scale, flytta-till-skärm), CI + release-flöde.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 23:24:29 +02:00
commit bc2fa65558
21 changed files with 4579 additions and 0 deletions

529
src/settings.rs Normal file
View File

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