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>
554 lines
16 KiB
Rust
554 lines
16 KiB
Rust
// 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 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,
|
|
}
|
|
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,
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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<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 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,
|
|
/// 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<MenuItem>,
|
|
pub pins: Vec<String>,
|
|
pub special_buttons: SpecialButtons,
|
|
pub outputs: HashMap<String, OutputOverride>,
|
|
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,
|
|
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::<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 {
|
|
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::<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>| {
|
|
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<RefCell<Config>>;
|