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

434
src/config.rs Normal file
View File

@@ -0,0 +1,434 @@
// 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,
}
#[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,
}
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,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct OutputConfig {
pub enabled: Option<bool>,
pub only_this_output: Option<bool>,
pub pins: Option<Vec<String>>,
}
#[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<HotspotAction>,
pub top_right: Option<HotspotAction>,
pub bottom_left: Option<HotspotAction>,
pub bottom_right: Option<HotspotAction>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Edges {
pub top: Option<HotspotAction>,
pub bottom: Option<HotspotAction>,
pub left: Option<HotspotAction>,
pub right: Option<HotspotAction>,
}
#[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<String>,
}
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 centered: bool,
pub margin: i32,
pub layer: Layer,
pub avoid_windows: bool,
pub appearance: Appearance,
pub behavior: Behavior,
pub context_menu: Vec<MenuItem>,
pub pins: Vec<String>,
pub special_buttons: SpecialButtons,
pub outputs: HashMap<String, OutputConfig>,
pub autohide: Autohide,
pub hotspots: Hotspots,
pub log: LogConfig,
}
impl Default for Config {
fn default() -> Self {
Self {
position: Position::Bottom,
centered: true,
margin: 8,
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(),
}
}
}
impl Config {
/// Effektiva pins för en viss skärm (output-överstyrning eller globala).
pub fn pins_for(&self, output: &str) -> &[String] {
self.outputs
.get(output)
.and_then(|o| o.pins.as_deref())
.unwrap_or(&self.pins)
}
pub fn output_enabled(&self, output: &str) -> bool {
self.outputs
.get(output)
.and_then(|o| o.enabled)
.unwrap_or(true)
}
pub fn only_this_output(&self, output: &str) -> bool {
self.outputs
.get(output)
.and_then(|o| o.only_this_output)
.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::<Config>(&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<dyn Fn()>) {
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<notify::Event>| {
if let Ok(ev) = res {
let hit = 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<RefCell<Config>>;