git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

src/server/config.rs (159.2K)

   1 // KDL config parsing for monolithic cce server
   2  
   3 use serde::Deserialize;
   4 use std::collections::HashMap;
   5 use std::fs;
   6 use crate::tiling::TilingMode;
   7 
   8 #[derive(Debug, Clone)]
   9 pub struct Layout {
  10     pub gap: i32,           // inter-window spacing
  11     pub gap_top: i32,       // screen edge inset, top (above bar)
  12     pub gap_left: i32,      // screen edge inset, left
  13     pub gap_right: i32,     // screen edge inset, right
  14     pub gap_bottom: i32,    // screen edge inset, bottom
  15     pub cascade_offset: i32,
  16     pub bar_height: i32,
  17     pub border_width: i32,
  18     pub fullscreen_border_width: i32,
  19     pub cascade_border_width: i32,
  20     pub grid_border_width: i32,
  21     pub floating_border_width: i32,
  22     /// Premultiplied-alpha RGBA, 0.0–1.0 per channel (scenefx convention).
  23     pub border_color: [f32; 4],
  24     /// Border color for the focused window; defaults to `border_color`.
  25     pub border_color_focused: [f32; 4],
  26     /// Border color while the pointer hovers the border (the grab surface);
  27     /// defaults to a lightened `border_color_focused`.
  28     pub border_color_hover: [f32; 4],
  29     /// Visual gap between the 8 border zone segments.
  30     pub border_segment_gap: i32,
  31     /// How thin the resize-handle ring gets at the corners, as a fraction of
  32     /// its thickness at the middle of a side. 1.0 is an even ring; smaller
  33     /// values swell the middle of each side, like a picture-frame moulding.
  34     pub border_taper: f32,
  35     /// Thickness of the resize-handle ring at the middle of a side, in SCREEN
  36     /// px. Deliberately its own knob rather than derived from `width`: the
  37     /// ring has to be thick enough to see and hit while the desktop is zoomed
  38     /// out, and the window's visible border is a much finer line than that.
  39     pub border_handle_width: f32,
  40     /// Opacity a Floating window is dimmed to while it overlaps the window
  41     /// whose resize handles are up (adjust mode), 0..1. 1.0 disables.
  42     pub border_overlap_opacity: f32,
  43     /// How long a window or overlay dissolves in when it maps, in ms. 0
  44     /// disables the open fade and windows appear at full strength.
  45     pub fade_in_ms: u32,
  46     /// How long a client that asked to close dissolves out over, in ms. This
  47     /// is also the deadline the client waits on before it exits, so it is
  48     /// answered back over the control socket rather than assumed — see the
  49     /// `fade-out` command. 0 disables the close fade.
  50     pub fade_out_ms: u32,
  51     /// Shape of the handle ring's swell along a side. Below 1 the ring gains
  52     /// its thickness early — a corner that visibly swells, then a long slow
  53     /// approach to the middle. Above 1 stays thin near the corner and gains
  54     /// late. 1.0 is the plain eased ramp.
  55     pub border_swell_curve: f32,
  56     /// Radius of the round pad on each corner of the handle ring, in screen
  57     /// px. 0 disables the pads and leaves the plain moulding.
  58     pub border_corner_bulge: f32,
  59     /// Corner zone length measured from the outer corner along each band;
  60     /// 0 = auto (max(2 * width, 16)).
  61     pub border_corner_length: i32,
  62     pub background_r: u32,
  63     pub background_g: u32,
  64     pub background_b: u32,
  65     pub background_a: u32,
  66     pub border_font_size: i32,
  67     pub transition_duration: i32,
  68     pub grid_gap: i32,
  69     pub border_blur: bool,
  70     pub window_blur: bool,
  71     /// THE window corner radius, logical px before span widening —
  72     /// `style.surface.plate.root.corner_radius`, the same key cce-ui's
  73     /// root plates and `window_corner_radius` read. Every rounded thing a
  74     /// window has derives from it (`Window::root_plate_radius_base`): the
  75     /// content clip, the root plate, the border ring, frame and corner
  76     /// discs, and the desktop grid's cells (`background_spec`), so a tiled
  77     /// window's arc sits on its cell's. There is no second radius: the
  78     /// border's own `corner_radius` key was dead config and was removed
  79     /// on 2026-09-28.
  80     pub root_plate_corner_radius: i32,
  81     pub overlay_behavior: String,
  82     pub overlay_width: i32,
  83     pub overlay_position: String,
  84     pub overlay_border_gap: i32,
  85     pub status_normal_color: String,
  86     pub status_background_blur: f32,
  87     pub desktop_gap_color: String,
  88     pub transparency_opacity: f32,
  89     pub window_opacity: bool,
  90     pub desktop_cell_color: [f32; 4],
  91     /// Desktop grid cell width/height in virtual units (each axis's period
  92     /// is cell + gap). Config: `grid_cell_width` / `grid_cell_height`, with
  93     /// the legacy `grid_cell_size` setting both.
  94     pub desktop_cell_width: f64,
  95     pub desktop_cell_height: f64,
  96     pub desktop_gap_width: i32,
  97     /// Grid-line lip width in logical px; None = follow the DE-wide relief
  98     /// material (bevel_thickness clamped to the rail), 0 = no lip.
  99     pub desktop_line_relief: Option<f64>,
 100     pub desktop_cell_fade_inset: i64,
 101     pub desktop_grid_fade_mode: String,
 102     /// Chess-style coordinates on the desktop squares while overview is
 103     /// open. KDL: `surface { desktop cell_labels=(bool)false }`.
 104     pub desktop_cell_labels: bool,
 105     /// Drop shadow under cce/ssd windows (scenefx box-shadow node).
 106     pub shadow_enabled: bool,
 107     /// Gaussian spread in logical px.
 108     pub shadow_sigma: f32,
 109     /// Premultiplied-alpha RGBA, like `border_color`.
 110     pub shadow_color: [f32; 4],
 111     /// Displacement of the cast shadow in logical px — should point away from
 112     /// `light_source_position` (down-right for the default top-left light).
 113     pub shadow_offset_x: i32,
 114     pub shadow_offset_y: i32,
 115     /// Whether tiled (and maximized — an alias of Tiled) windows cast the
 116     /// shadow at all. Off leaves shadows to floating windows only, so a
 117     /// tiled layout reads as a flat sheet with no smearing across cell gaps.
 118     pub shadow_tiled: bool,
 119     /// Edge bevel: a lit chamfer drawn around the INSIDE of a decorated
 120     /// window's edge (scenefx bevel node), lit from `bevel_light_*` — the
 121     /// same top-left source the drop shadow is offset away from.
 122     pub bevel_enabled: bool,
 123     /// Rim width in logical px.
 124     pub bevel_thickness: f32,
 125     /// Direction toward the light; y is down, so the default is up-left.
 126     pub bevel_light_x: f32,
 127     pub bevel_light_y: f32,
 128     /// Strength of the lit and shaded sides, 0..1.
 129     pub bevel_light_intensity: f32,
 130     pub bevel_shade_intensity: f32,
 131     /// 0 = hard flat chamfer, 1 = fully rounded shoulder.
 132     pub bevel_shoulder: f32,
 133     /// Highlight tint, premultiplied RGBA; alpha scales the whole effect.
 134     pub bevel_color: [f32; 4],
 135     /// Focused-window rim accent: the bevel highlight wraps all four sides
 136     /// in this color for the focused window (the DE focus glint).
 137     pub bevel_focus_color: [f32; 3],
 138     /// How sharply the focused rim's glint falls off across the bevel:
 139     /// the exponent on the rim slope. 1 is a linear ramp over the whole
 140     /// thickness (reads as a wash); higher concentrates the light at the
 141     /// silhouette, at some cost in peak brightness, since the outermost
 142     /// rendered sample is already below 1.0. Past ~12 it only dims.
 143     pub bevel_focus_sharpness: f32,
 144     /// Magnetic grid snap for interactive move/resize.
 145     pub desktop_snap: bool,
 146     /// Speed ramp + duration (ms) for the overview enter/exit transition.
 147     /// `None` (no/invalid `desktop { overview_ramp= }`) falls back to the
 148     /// exponential-approach camera animation.
 149     pub overview_anim: Option<(crate::policy::ramp::SpeedRamp, f64)>,
 150     /// Snap radius in virtual units.
 151     pub desktop_snap_threshold: f64,
 152     /// Edge auto-pan: dragging/resizing against a screen edge scrolls the
 153     /// desktop underneath the pinned cursor.
 154     pub desktop_edge_pan: bool,
 155     /// Width of the trigger band inside each output edge, in layout px.
 156     pub desktop_edge_pan_band: f64,
 157     /// Full-tilt pan speed at the screen edge, in screen px/s (the tick
 158     /// divides by zoom; speed ramps linearly across the band).
 159     pub desktop_edge_pan_speed: f64,
 160     pub scenefx_optimized_blur: bool,
 161     pub status_backdrop_blur_ignore_transparent: bool,
 162     pub window_backdrop_blur_ignore_transparent: bool,
 163     pub status_module_hide_mode_preview: i64,
 164     /// Gap between adjacent status segments; the bar's own config file
 165     /// (`~/.config/cce/cce-status-interface/config.kdl`, `module { spacing }`)
 166     /// overrides the shared `style { status module_spacing }`.
 167     pub status_module_spacing: i64,
 168     /// The bar's `module { droplet }` spec string when present — the water-
 169     /// drop module style. The compositor drives a scenefx droplet node
 170     /// (backdrop refraction) per status segment from the SAME spec the bar
 171     /// draws its drops from, so the two silhouettes cannot drift.
 172     pub status_droplet: Option<String>,
 173     /// Backdrop compression for status segments, from the bar's
 174     /// `module { backdrop_compress }` (the minimum WCAG contrast ratio the
 175     /// module text must hold against any backdrop pixel) and its
 176     /// `module { text_color }`: `(ceil, knee, invert)` in linear luminance,
 177     /// see `backdrop_compress_params`. `None` = off.
 178     pub status_backdrop_compress: Option<(f32, f32, bool)>,
 179     pub cloud_position_default: Option<[i32; 2]>,
 180 }
 181 
 182 impl Layout {
 183     /// The desktop background as the policy crate's declarative spec. The
 184     /// desktop is always the grid; per-frame geometry comes from
 185     /// `policy::background::grid_frame`.
 186     pub fn background_spec(&self) -> crate::policy::api::BackgroundSpec {
 187         use crate::policy::api::{BackgroundSpec, GridFadeMode, GridSpec, Rgba};
 188         BackgroundSpec::Grid(GridSpec {
 189             gap_color: Rgba(parse_hex_color_rgba(&self.desktop_gap_color)),
 190             cell_color: Rgba(self.desktop_cell_color),
 191             cell_w: self.desktop_cell_width,
 192             cell_h: self.desktop_cell_height,
 193             gap_width: self.desktop_gap_width as f64,
 194             // Cells inherit the window root plate radius: a tiled window's
 195             // content covers exactly the visible cell box, so its arc sits
 196             // precisely on the cell's arc underneath.
 197             cell_corner_radius: self.root_plate_corner_radius,
 198             cell_fade_inset: self.desktop_cell_fade_inset as i32,
 199             fade_mode: GridFadeMode::from_name(&self.desktop_grid_fade_mode),
 200         })
 201     }
 202 
 203     /// Snap parameters for interactive ops. A zero threshold (snap
 204     /// disabled) makes every snap function a no-op.
 205     pub fn snap_params(&self) -> crate::policy::snap::SnapParams {
 206         crate::policy::snap::SnapParams {
 207             cell_w: self.desktop_cell_width,
 208             cell_h: self.desktop_cell_height,
 209             gap_width: self.desktop_gap_width as f64,
 210             cell_inset: self.desktop_cell_fade_inset as f64,
 211             threshold: if self.desktop_snap { self.desktop_snap_threshold } else { 0.0 },
 212         }
 213     }
 214 }
 215 
 216 impl Default for Layout {
 217     fn default() -> Self {
 218         Layout {
 219             gap: 48,
 220             gap_top: 48,
 221             gap_left: 48,
 222             gap_right: 48,
 223             gap_bottom: 48,
 224             cascade_offset: 20,
 225             bar_height: 24,
 226             border_width: 0,
 227             fullscreen_border_width: 0,
 228             cascade_border_width: 0,
 229             grid_border_width: 0,
 230             floating_border_width: 0,
 231             border_color: [62.0 / 255.0, 62.0 / 255.0, 62.0 / 255.0, 1.0],
 232             border_color_focused: [62.0 / 255.0, 62.0 / 255.0, 62.0 / 255.0, 1.0],
 233             border_color_hover: lighten_premultiplied([62.0 / 255.0, 62.0 / 255.0, 62.0 / 255.0, 1.0], HOVER_LIGHTEN),
 234             border_segment_gap: 4,
 235             border_taper: 0.35,
 236             border_handle_width: 32.0,
 237             border_overlap_opacity: 0.4,
 238             fade_in_ms: 140,
 239             fade_out_ms: 120,
 240             border_swell_curve: 0.45,
 241             border_corner_bulge: 48.0,
 242             border_corner_length: 0,
 243             background_r: 0x1C1C1C1Cu32,
 244             background_g: 0x20202020u32,
 245             background_b: 0x20202020u32,
 246             background_a: 0xFFFFFFFFu32,
 247             border_font_size: 11,
 248             transition_duration: 300,
 249             grid_gap: 18,
 250             border_blur: false,
 251             window_blur: false,
 252             root_plate_corner_radius: 12,
 253             overlay_behavior: "inline".to_string(),
 254             overlay_width: 360,
 255             overlay_position: "left".to_string(),
 256             overlay_border_gap: 0,
 257             status_normal_color: "#ccccd8".to_string(),
 258             status_background_blur: 0.8,
 259             desktop_gap_color: "#000000".to_string(),
 260             transparency_opacity: 0.9,
 261             window_opacity: true,
 262             desktop_cell_color: [0.05, 0.05, 0.05, 0.05],
 263             desktop_cell_width: 100.0,
 264             desktop_cell_height: 100.0,
 265             desktop_gap_width: 1,
 266             desktop_line_relief: None,
 267             desktop_cell_fade_inset: 0,
 268             desktop_grid_fade_mode: "linear".to_string(),
 269             desktop_cell_labels: true,
 270             bevel_enabled: true,
 271             bevel_thickness: 10.0,
 272             bevel_light_x: -0.7071,
 273             bevel_light_y: -0.7071,
 274             bevel_light_intensity: 0.6,
 275             bevel_shade_intensity: 0.5,
 276             bevel_shoulder: 0.55,
 277             bevel_color: [1.0, 1.0, 1.0, 1.0],
 278             bevel_focus_color: [0.35, 0.78, 0.78],
 279             bevel_focus_sharpness: 3.0,
 280             shadow_enabled: true,
 281             shadow_sigma: 22.0,
 282             shadow_color: [0.0, 0.0, 0.0, 0.55],
 283             shadow_offset_x: 7,
 284             shadow_offset_y: 7,
 285             shadow_tiled: true,
 286             desktop_snap: true,
 287             overview_anim: None,
 288             desktop_snap_threshold: 24.0,
 289             desktop_edge_pan: true,
 290             desktop_edge_pan_band: 32.0,
 291             desktop_edge_pan_speed: 1000.0,
 292             scenefx_optimized_blur: true,
 293             status_backdrop_blur_ignore_transparent: true,
 294             window_backdrop_blur_ignore_transparent: true,
 295             status_module_hide_mode_preview: 4,
 296             status_module_spacing: 12,
 297             status_droplet: None,
 298             status_backdrop_compress: None,
 299             cloud_position_default: None,
 300         }
 301     }
 302 }
 303 
 304 #[derive(Debug, Clone)]
 305 pub struct ModeRule {
 306     pub mode: TilingMode,
 307     pub app_id_pattern: String,
 308     pub title_pattern: Option<String>,
 309     pub single_instance: bool,
 310     pub tag: i32,
 311     pub circular: bool,
 312     pub ssd: Option<bool>,
 313     /// The window is a satellite of its app's main window (a settings
 314     /// window opened as a parentless toplevel): it is never restored from
 315     /// saved state, sizes itself, and maps centred over a mapped sibling of
 316     /// the same app_id. See `Window::try_center_on_sibling`.
 317     pub over_sibling: bool,
 318     /// The window opens centred on the current view, whatever position is
 319     /// remembered for it: a prompt the user has to answer now (1Password's
 320     /// authorization popup). Placement only — the remembered size still
 321     /// applies, and a self-sizing window is re-centred once its real size
 322     /// lands. See `Window::try_center_on_view`.
 323     pub center: bool,
 324 }
 325 
 326 pub use cce_window_manager::api::Action;
 327 
 328 #[derive(Debug, Deserialize, Clone)]
 329 pub struct KeybindConfig {
 330     pub mods: String,
 331     pub key: String,
 332     pub action: String,
 333     pub command: Option<String>,
 334 }
 335 
 336 #[derive(Debug, Deserialize, Clone)]
 337 pub struct PointerBindConfig {
 338     pub mods: String,
 339     pub button: String,
 340     pub action: String,
 341 }
 342 
 343 #[derive(Debug, Deserialize, Clone)]
 344 pub struct GestureBindConfig {
 345     #[serde(default)]
 346     pub mods: Option<String>,
 347     #[serde(rename = "type")]
 348     pub gesture_type: String,
 349     pub fingers: u32,
 350     pub direction: String,
 351     pub action: String,
 352     pub command: Option<String>,
 353 }
 354 
 355 #[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
 356 pub struct StartupConfig {
 357     pub exec: String,
 358     #[serde(default)]
 359     pub once: bool,
 360     #[serde(default)]
 361     pub restart: bool,
 362 }
 363 
 364 // The compositor's resolved keybind is the policy crate's `Binding`
 365 // (mods, keysym, action, command), kept under its historical name here.
 366 pub use cce_window_manager::bindings::Binding as Keybind;
 367 
 368 #[derive(Debug, Clone, PartialEq, Eq)]
 369 pub struct PointerBind {
 370     pub mods: u32,
 371     pub button: u32,
 372     pub action: Action,
 373 }
 374 
 375 #[derive(Debug, Clone, PartialEq, Eq)]
 376 pub struct GestureBind {
 377     pub mods: u32,
 378     pub gesture_type: String,
 379     pub fingers: u32,
 380     pub direction: String,
 381     pub action: Action,
 382     pub command: Option<String>,
 383 }
 384 
 385 #[derive(Debug, Deserialize, Clone)]
 386 pub struct OutputConfig {
 387     #[serde(default = "default_scenefx_optimized_blur")]
 388     pub scenefx_optimized_blur: bool,
 389 }
 390 
 391 fn default_scenefx_optimized_blur() -> bool {
 392     true
 393 }
 394 
 395 #[derive(Debug, Deserialize, Clone)]
 396 pub struct InputDeviceConfigRule {
 397     pub name: String,
 398     pub scroll_factor: Option<f64>,
 399 }
 400 
 401 #[derive(Debug, Deserialize, Clone)]
 402 pub struct WindowManagerConfig {
 403     pub close_window: Option<String>,
 404     pub toggle_fullscreen: Option<String>,
 405     pub toggle_overview: Option<String>,
 406     pub window_switcher: Option<String>,
 407     pub window_switcher_prev: Option<String>,
 408     /// Whether a newly spawned window pulls the viewport over to it. `None` = the default,
 409     /// which is to centre (what the compositor has always done).
 410     pub center_on_spawn: Option<bool>,
 411     /// Camera reaction when the focused window goes away (app exit, close,
 412     /// minimize): "focus_previous" (pan to the fallback focus — the historic
 413     /// behavior), "overview", or "none". Menu-typed in config.kdl so the
 414     /// settings tree renders a dropdown.
 415     pub on_app_exit: Option<String>,
 416     /// Corner-shape exponent for scenefx's rounded-corner cuts (window
 417     /// surfaces, blur, shadows' clip): 2 = circular arc, > 2 = superellipse
 418     /// squircle. The same `window_manager.corner_shape` key the cce-ui
 419     /// clients read, so the compositor's cut lands on the corners they draw.
 420     pub corner_shape: Option<f64>,
 421     /// Extra app_ids (beyond cce-* apps and SSD requesters) that get the full
 422     /// decorated-window treatment: rounded corner clip, blur-behind, shadow.
 423     /// KDL: `rounded_apps "*claude*" "org.example.App"`.
 424     ///
 425     /// Entries are matched case-insensitively by `app_id_matches`, and one
 426     /// containing `*` is a glob. Prefer a glob for anything third-party: an
 427     /// app_id is not a stable identifier, and an exact entry that stops
 428     /// matching after a rename takes the corners, blur, shadow and bevel with
 429     /// it in silence. `ccectl windows` reports the outcome as `decorated=`.
 430     pub rounded_apps: Option<Vec<String>>,
 431     /// Which apps the compositor draws an edge BEVEL on. Separate from
 432     /// `rounded_apps` because drawing one is only right for apps that do not
 433     /// bevel themselves — every cce-ui app already draws its own, so beveling
 434     /// them compositor-side doubles the rim. Unset falls back to
 435     /// `rounded_apps` (never the implicit cce-* set).
 436     /// KDL: `bevel_apps "*claude*"`. Globbed like `rounded_apps`; reported by
 437     /// `ccectl windows` as `beveled=`.
 438     pub bevel_apps: Option<Vec<String>>,
 439     /// Present Xwayland with a PHYSICAL-pixel screen (the xdg-output global is
 440     /// hidden from it, so it sizes its root from the wl_output mode) and draw
 441     /// X11 surfaces at 1/scale, so HiDPI-aware X11 apps render sharp instead
 442     /// of being upscaled from logical size. Default on. KDL:
 443     /// `xwayland_hidpi (bool)false` to get the old blurry-but-1:1 behaviour.
 444     pub xwayland_hidpi: Option<bool>,
 445     /// Per-app exception to `xwayland_hidpi`: windows matching one of these
 446     /// patterns are configured and drawn in the logical world (factor 1)
 447     /// while every other X11 window stays physical. Meant for games and other
 448     /// X11 clients that size themselves to the whole screen and would render
 449     /// scale² times the pixels for nothing. A pattern is tried against the
 450     /// window's WM_CLASS class, its WM_CLASS instance and its title, since
 451     /// every Proton window shares the class `steam_proton`; `*` wildcards as
 452     /// in `rounded_apps`. KDL: `xwayland_hidpi_except "Trackmania"`.
 453     ///
 454     /// A named window is treated as the full-screen X11 game it is
 455     /// (`xwayland_window::window_is_hidpi_exempt`): it is drawn at 1 in the
 456     /// logical world, it is NOT restored to a saved size at map (it sizes
 457     /// itself to the screen — a restored 1214x689 is what Trackmania then
 458     /// pinned in its hints), its own position requests are granted (its
 459     /// "windowedfull" asks for the desktop origin, and refusing that was a
 460     /// ~170/s configure loop), and only a compositor fullscreen or tiling
 461     /// overrides its size.
 462     pub xwayland_hidpi_except: Option<Vec<String>>,
 463     /// Apps whose windows turn trackpad input into a view drag (Space +
 464     /// button) — see `cursor::ViewDrag`. A scroll over one of their popups
 465     /// becomes a wheel under a held Ctrl instead — see `cursor::PopupWheel`.
 466     /// KDL: `touchpad_view_apps "Houdini FX"`.
 467     pub touchpad_view_apps: Option<Vec<String>>,
 468     /// What an unmodified two-finger swipe does there: "pan" (default) or
 469     /// "tumble"; Shift does the other. KDL: `touchpad_view_swipe "tumble"`.
 470     pub touchpad_view_swipe: Option<String>,
 471     /// Finger-to-pointer distance factor for the emulated drag (default 1).
 472     pub touchpad_view_sensitivity: Option<f64>,
 473     /// How far the desktop leans toward a directional swipe bind by the
 474     /// time the swipe reaches its threshold, screen px (default 60; 0
 475     /// turns the lean off). KDL: `swipe_peek (f64)60.0`. Lives here, not
 476     /// under `input`, because input.kdl's `input {}` block replaces
 477     /// config.kdl's wholesale. See "Swipe binds peek" in CLAUDE.md.
 478     pub swipe_peek: Option<f64>,
 479     /// The lean toward each FURTHER step of a swipe that has already
 480     /// switched focus, screen px at `swipe_repeat_threshold` (default half
 481     /// of `swipe_peek`; 0 turns it off). Slower than the first step's
 482     /// lean, so a swipe that has just switched reads as settled. KDL:
 483     /// `swipe_repeat_peek (f64)30.0`.
 484     pub swipe_repeat_peek: Option<f64>,
 485     /// How far off the swipe's own direction (degrees) a window center may
 486     /// lie and still take focus from a three-finger focus swipe (default
 487     /// 45; clamped to 1-89). A swipe toward nothing within it changes
 488     /// nothing. KDL: `swipe_focus_cone (f64)45.0`.
 489     pub swipe_focus_cone: Option<f64>,
 490     /// Accumulated travel (libinput units, roughly mm) at which a swipe
 491     /// bind fires (default 70). KDL: `swipe_threshold (f64)70.0`.
 492     pub swipe_threshold: Option<f64>,
 493     /// Travel each further fire of the same swipe needs after its first
 494     /// (default four times `swipe_threshold`), so a swipe that has just
 495     /// stepped focus meets more resistance before stepping again. KDL:
 496     /// `swipe_repeat_threshold (f64)280.0`.
 497     pub swipe_repeat_threshold: Option<f64>,
 498     /// Reverse the drag direction, on top of the natural-scroll correction
 499     /// the drag already makes. KDL: `touchpad_view_invert (bool)true`.
 500     pub touchpad_view_invert: Option<bool>,
 501     /// Apps whose native widgets scroll sideways only under Shift and read a
 502     /// horizontal wheel as a vertical one (Houdini's spreadsheet, list and
 503     /// parameter panes): a horizontal two-finger scroll over their windows is
 504     /// delivered as a vertical scroll with Shift held for the gesture. KDL:
 505     /// `touchpad_hscroll_shift_apps "Houdini FX"`.
 506     pub touchpad_hscroll_shift_apps: Option<Vec<String>>,
 507     /// Show the on-screen keyboard when a touch activates a text field, and
 508     /// hide it when the field lets go (`osk.rs`). Default on. KDL:
 509     /// `osk_on_touch (bool)false`.
 510     pub osk_on_touch: Option<bool>,
 511 }
 512 
 513 #[derive(Debug, Deserialize, Clone, Default, PartialEq, Eq)]
 514 pub struct GesturesConfig {
 515     pub swipe: Option<bool>,
 516     pub pinch: Option<bool>,
 517 }
 518 
 519 #[derive(Debug, Deserialize, Clone, Default, PartialEq)]
 520 pub struct TouchpadConfig {
 521     pub tap_to_click: Option<bool>,
 522     pub natural_scroll: Option<bool>,
 523     pub dwt: Option<bool>,
 524     pub dwtp: Option<bool>,
 525     pub gestures: Option<GesturesConfig>,
 526     pub accel_speed: Option<f64>,
 527     pub accel_profile: Option<String>,
 528     pub scroll_factor: Option<f64>,
 529 }
 530 
 531 #[derive(Debug, Deserialize, Clone, Default, PartialEq)]
 532 pub struct TrackpointConfig {
 533     pub accel_speed: Option<f64>,
 534     pub accel_profile: Option<String>,
 535     pub scroll_factor: Option<f64>,
 536     /// libinput scroll method: `"none"`, `"button"` (scroll while the
 537     /// middle button is held) or `"two_finger"` / `"edge"` for devices that
 538     /// support them. libinput defaults a pointing stick to `"button"`, which
 539     /// withholds every middle press until the release to see whether it was
 540     /// a scroll: clients then get a press and release in the same instant,
 541     /// so a middle *click* never registers and a middle *drag* scrolls.
 542     pub scroll_method: Option<String>,
 543 }
 544 
 545 #[derive(Debug, Deserialize, Clone, Default, PartialEq)]
 546 pub struct MouseConfig {
 547     pub accel_speed: Option<f64>,
 548     pub accel_profile: Option<String>,
 549     pub scroll_factor: Option<f64>,
 550     /// See `TrackpointConfig::scroll_method`.
 551     pub scroll_method: Option<String>,
 552 }
 553 
 554 /// Pointer device configuration. Per-class blocks (`mouse` / `touchpad` /
 555 /// `trackpad` alias / `trackpoint`) override the top-level values for
 556 /// devices of that class; a device is a trackpoint if its name says so, a
 557 /// touchpad if it supports tap, and a mouse otherwise.
 558 #[derive(Debug, Deserialize, Clone, Default)]
 559 pub struct InputConfig {
 560     pub accel_speed: Option<f64>,
 561     pub accel_profile: Option<String>,
 562     pub scroll_factor: Option<f64>,
 563     /// Desktop-pan smooth scrolling (the compositor's own consumption of the
 564     /// wheel: background/overview/super pans and ctrl+super zoom). Same keys
 565     /// and defaults as cce-ui's `widget::scroll_motion`, so a wheel notch
 566     /// glides the same way over a list and over the desktop.
 567     /// `scroll_ease`: wheel-glide rate, 1/s (default 12).
 568     pub scroll_ease: Option<f64>,
 569     /// `kinetic_scroll`: a trackpad flick keeps panning after the lift (default true).
 570     pub kinetic_scroll: Option<bool>,
 571     /// `scroll_friction`: coast decay, 1/s (default 6).
 572     pub scroll_friction: Option<f64>,
 573     /// `repeat_rate`: key repeats per second once repeating starts
 574     /// (default `keyboard::DEFAULT_REPEAT_RATE`; 0 disables repeat).
 575     pub repeat_rate: Option<i64>,
 576     /// `repeat_delay`: ms a key is held before it starts repeating
 577     /// (default `keyboard::DEFAULT_REPEAT_DELAY`).
 578     pub repeat_delay: Option<i64>,
 579     pub mouse: Option<MouseConfig>,
 580     pub touchpad: Option<TouchpadConfig>,
 581     pub trackpoint: Option<TrackpointConfig>,
 582 }
 583 
 584 impl InputConfig {
 585     /// `(rate, delay)` for hardware keyboards, defaults filled in. Negative
 586     /// values are clamped to 0, which `wl_keyboard.repeat_info` reads as
 587     /// "no repeat" for the rate; the protocol rejects negatives outright.
 588     pub fn repeat_info(&self) -> (i32, i32) {
 589         let rate = self.repeat_rate.map_or(crate::keyboard::DEFAULT_REPEAT_RATE, |v| v.clamp(0, i32::MAX as i64) as i32);
 590         let delay = self.repeat_delay.map_or(crate::keyboard::DEFAULT_REPEAT_DELAY, |v| v.clamp(0, i32::MAX as i64) as i32);
 591         (rate, delay)
 592     }
 593 }
 594 
 595 #[derive(Debug, Deserialize, Clone, Default)]
 596 pub struct TransparencyConfig {
 597     pub opacity: Option<f64>,
 598 }
 599 
 600 #[derive(Debug, Deserialize, Clone)]
 601 pub struct SurfaceConfig {
 602     #[serde(default = "default_desktop_gap_color")]
 603     pub desktop_gap_color: String,
 604     #[serde(default = "default_desktop_cell_color")]
 605     pub desktop_cell_color: String,
 606     #[serde(default = "default_desktop_grid_scale")]
 607     pub desktop_grid_scale: i64,
 608     /// Per-axis cell sizes; None falls back to `desktop_grid_scale`
 609     /// (the legacy square `grid_cell_size`).
 610     #[serde(default)]
 611     pub grid_cell_width: Option<i64>,
 612     #[serde(default)]
 613     pub grid_cell_height: Option<i64>,
 614     #[serde(default = "default_desktop_gap_width")]
 615     pub desktop_gap_width: i64,
 616     /// Negative = unset (follow the DE-wide relief material).
 617     #[serde(default = "default_desktop_line_relief")]
 618     pub desktop_line_relief: i64,
 619     #[serde(default = "default_desktop_cell_fade_inset")]
 620     pub desktop_cell_fade_inset: i64,
 621     #[serde(default = "default_desktop_grid_fade_mode")]
 622     pub desktop_grid_fade_mode: String,
 623     #[serde(default = "default_desktop_cell_labels")]
 624     pub desktop_cell_labels: bool,
 625     #[serde(default = "default_desktop_snap")]
 626     pub desktop_snap: bool,
 627     #[serde(default)]
 628     pub desktop_overview_ramp: String,
 629     #[serde(default = "default_desktop_overview_ms")]
 630     pub desktop_overview_ms: i64,
 631     #[serde(default = "default_desktop_snap_threshold")]
 632     pub desktop_snap_threshold: i64,
 633     #[serde(default = "default_desktop_edge_pan")]
 634     pub desktop_edge_pan: bool,
 635     #[serde(default = "default_desktop_edge_pan_band")]
 636     pub desktop_edge_pan_band: i64,
 637     #[serde(default = "default_desktop_edge_pan_speed")]
 638     pub desktop_edge_pan_speed: i64,
 639     #[serde(default = "default_root_plate_color")]
 640     pub root_plate_color: String,
 641     #[serde(default = "default_root_plate_blur")]
 642     pub root_plate_blur: f64,
 643     #[serde(default = "default_root_plate_corner_radius")]
 644     pub root_plate_corner_radius: i64,
 645     #[serde(default = "default_border_width")]
 646     pub border_width: i64,
 647     #[serde(default = "default_border_color")]
 648     pub border_color: String,
 649     /// `None` falls back to `border_color`.
 650     #[serde(default)]
 651     pub border_color_focused: Option<String>,
 652     /// `None` falls back to a lightened `border_color_focused`.
 653     #[serde(default)]
 654     pub border_color_hover: Option<String>,
 655     #[serde(default = "default_border_segment_gap")]
 656     pub border_segment_gap: i64,
 657     #[serde(default = "default_border_taper")]
 658     pub border_taper: f64,
 659     #[serde(default = "default_border_handle_width")]
 660     pub border_handle_width: f64,
 661     #[serde(default = "default_border_overlap_opacity")]
 662     pub border_overlap_opacity: f64,
 663     /// `surface { fade in_ms=.. out_ms=.. }` — the DE-wide open/close
 664     /// dissolve. Milliseconds; 0 on either disables that direction.
 665     #[serde(default = "default_fade_in_ms")]
 666     pub fade_in_ms: i64,
 667     #[serde(default = "default_fade_out_ms")]
 668     pub fade_out_ms: i64,
 669     #[serde(default = "default_border_swell_curve")]
 670     pub border_swell_curve: f64,
 671     #[serde(default = "default_border_corner_bulge")]
 672     pub border_corner_bulge: f64,
 673     /// 0 = auto (max(2 * width, 16)).
 674     #[serde(default)]
 675     pub border_corner_length: i64,
 676     #[serde(default = "default_cloud_position_default")]
 677     pub cloud_position_default: Option<[i32; 2]>,
 678     #[serde(default = "default_shadow_enabled")]
 679     pub shadow_enabled: bool,
 680     #[serde(default = "default_shadow_sigma")]
 681     pub shadow_sigma: f64,
 682     #[serde(default = "default_shadow_color")]
 683     pub shadow_color: String,
 684     #[serde(default = "default_shadow_offset_x")]
 685     pub shadow_offset_x: i64,
 686     #[serde(default = "default_shadow_offset_y")]
 687     pub shadow_offset_y: i64,
 688     #[serde(default = "default_shadow_tiled")]
 689     pub shadow_tiled: bool,
 690     #[serde(default = "default_bevel_enabled")]
 691     pub bevel_enabled: bool,
 692     #[serde(default = "default_bevel_thickness")]
 693     pub bevel_thickness: f64,
 694     #[serde(default = "default_bevel_light")]
 695     pub bevel_light: String,
 696     #[serde(default = "default_bevel_light_intensity")]
 697     pub bevel_light_intensity: f64,
 698     #[serde(default = "default_bevel_shade_intensity")]
 699     pub bevel_shade_intensity: f64,
 700     #[serde(default = "default_bevel_shoulder")]
 701     pub bevel_shoulder: f64,
 702     #[serde(default = "default_bevel_color")]
 703     pub bevel_color: String,
 704     #[serde(default = "default_bevel_focus_color")]
 705     pub bevel_focus_color: String,
 706     #[serde(default = "default_bevel_focus_sharpness")]
 707     pub bevel_focus_sharpness: f64,
 708 }
 709 
 710 fn default_shadow_enabled() -> bool { true }
 711 fn default_shadow_sigma() -> f64 { 22.0 }
 712 fn default_shadow_color() -> String { "#0000008c".to_string() }
 713 fn default_shadow_offset_x() -> i64 { 7 }
 714 fn default_shadow_offset_y() -> i64 { 7 }
 715 fn default_shadow_tiled() -> bool { true }
 716 fn default_bevel_enabled() -> bool { true }
 717 fn default_bevel_thickness() -> f64 { 10.0 }
 718 /// Compass point the light comes FROM, matching the shadow's top-left source.
 719 fn default_bevel_light() -> String { "top-left".to_string() }
 720 fn default_bevel_light_intensity() -> f64 { 0.6 }
 721 fn default_bevel_shade_intensity() -> f64 { 0.5 }
 722 fn default_bevel_shoulder() -> f64 { 0.55 }
 723 fn default_bevel_color() -> String { "#ffffffff".to_string() }
 724 fn default_bevel_focus_color() -> String { "#59c7c7".to_string() }
 725 fn default_bevel_focus_sharpness() -> f64 { 3.0 }
 726 
 727 /// Map a compass point to a unit vector pointing TOWARD the light, in screen
 728 /// space (y down). Anything unrecognized keeps the DE's top-left default.
 729 pub fn parse_light_direction(s: &str) -> (f32, f32) {
 730     let d = 0.7071_f32;
 731     match s.trim().to_ascii_lowercase().replace('_', "-").as_str() {
 732         "top" | "up" | "north" => (0.0, -1.0),
 733         "bottom" | "down" | "south" => (0.0, 1.0),
 734         "left" | "west" => (-1.0, 0.0),
 735         "right" | "east" => (1.0, 0.0),
 736         "top-right" | "up-right" | "north-east" => (d, -d),
 737         "bottom-left" | "down-left" | "south-west" => (-d, d),
 738         "bottom-right" | "down-right" | "south-east" => (d, d),
 739         _ => (-d, -d),
 740     }
 741 }
 742 
 743 impl Default for SurfaceConfig {
 744     fn default() -> Self {
 745         Self {
 746             desktop_gap_color: default_desktop_gap_color(),
 747             desktop_cell_color: default_desktop_cell_color(),
 748             desktop_grid_scale: default_desktop_grid_scale(),
 749             grid_cell_width: None,
 750             grid_cell_height: None,
 751             desktop_gap_width: default_desktop_gap_width(),
 752             desktop_line_relief: default_desktop_line_relief(),
 753             desktop_cell_fade_inset: default_desktop_cell_fade_inset(),
 754             desktop_grid_fade_mode: default_desktop_grid_fade_mode(),
 755             desktop_cell_labels: default_desktop_cell_labels(),
 756             desktop_snap: default_desktop_snap(),
 757             desktop_overview_ramp: String::new(),
 758             desktop_overview_ms: default_desktop_overview_ms(),
 759             desktop_snap_threshold: default_desktop_snap_threshold(),
 760             desktop_edge_pan: default_desktop_edge_pan(),
 761             desktop_edge_pan_band: default_desktop_edge_pan_band(),
 762             desktop_edge_pan_speed: default_desktop_edge_pan_speed(),
 763             root_plate_color: default_root_plate_color(),
 764             root_plate_blur: default_root_plate_blur(),
 765             root_plate_corner_radius: default_root_plate_corner_radius(),
 766             border_width: default_border_width(),
 767             border_color: default_border_color(),
 768             border_color_focused: None,
 769             border_color_hover: None,
 770             border_segment_gap: default_border_segment_gap(),
 771             border_taper: default_border_taper(),
 772             border_handle_width: default_border_handle_width(),
 773             border_overlap_opacity: default_border_overlap_opacity(),
 774             fade_in_ms: default_fade_in_ms(),
 775             fade_out_ms: default_fade_out_ms(),
 776             border_swell_curve: default_border_swell_curve(),
 777             border_corner_bulge: default_border_corner_bulge(),
 778             border_corner_length: 0,
 779             cloud_position_default: default_cloud_position_default(),
 780             shadow_enabled: default_shadow_enabled(),
 781             shadow_sigma: default_shadow_sigma(),
 782             shadow_color: default_shadow_color(),
 783             shadow_offset_x: default_shadow_offset_x(),
 784             shadow_offset_y: default_shadow_offset_y(),
 785             shadow_tiled: default_shadow_tiled(),
 786             bevel_enabled: default_bevel_enabled(),
 787             bevel_thickness: default_bevel_thickness(),
 788             bevel_light: default_bevel_light(),
 789             bevel_light_intensity: default_bevel_light_intensity(),
 790             bevel_shade_intensity: default_bevel_shade_intensity(),
 791             bevel_shoulder: default_bevel_shoulder(),
 792             bevel_color: default_bevel_color(),
 793             bevel_focus_color: default_bevel_focus_color(),
 794             bevel_focus_sharpness: default_bevel_focus_sharpness(),
 795         }
 796      }
 797 }
 798 
 799 fn default_cloud_position_default() -> Option<[i32; 2]> {
 800     None
 801 }
 802 
 803 fn default_desktop_gap_color() -> String {
 804 
 805     "#000000".to_string()
 806 }
 807 
 808 fn default_desktop_cell_color() -> String {
 809     "#ffffff0d".to_string()
 810 }
 811 
 812 fn default_desktop_grid_scale() -> i64 {
 813     100
 814 }
 815 
 816 fn default_desktop_gap_width() -> i64 {
 817     1
 818 }
 819 
 820 fn default_desktop_line_relief() -> i64 {
 821     -1
 822 }
 823 
 824 fn default_desktop_cell_fade_inset() -> i64 {
 825     0
 826 }
 827 
 828 fn default_desktop_cell_labels() -> bool {
 829     true
 830 }
 831 
 832 fn default_desktop_grid_fade_mode() -> String {
 833     "linear".to_string()
 834 }
 835 
 836 fn default_desktop_overview_ms() -> i64 {
 837     350
 838 }
 839 
 840 fn default_desktop_snap() -> bool {
 841     true
 842 }
 843 
 844 fn default_desktop_snap_threshold() -> i64 {
 845     24
 846 }
 847 
 848 fn default_desktop_edge_pan() -> bool {
 849     true
 850 }
 851 
 852 fn default_desktop_edge_pan_band() -> i64 {
 853     32
 854 }
 855 
 856 fn default_desktop_edge_pan_speed() -> i64 {
 857     1000
 858 }
 859 
 860 fn default_root_plate_color() -> String {
 861     "#151520e6".to_string()
 862 }
 863 
 864 fn default_root_plate_blur() -> f64 {
 865     0.8
 866 }
 867 
 868 fn default_root_plate_corner_radius() -> i64 {
 869     12
 870 }
 871 
 872 fn default_border_width() -> i64 {
 873     0
 874 }
 875 
 876 fn default_border_color() -> String {
 877     "#3e3e3e".to_string()
 878 }
 879 
 880 fn default_border_taper() -> f64 { 0.35 }
 881 fn default_border_handle_width() -> f64 { 32.0 }
 882 fn default_border_overlap_opacity() -> f64 { 0.4 }
 883 fn default_fade_in_ms() -> i64 { 140 }
 884 fn default_fade_out_ms() -> i64 { 120 }
 885 fn default_border_swell_curve() -> f64 { 0.45 }
 886 fn default_border_corner_bulge() -> f64 { 48.0 }
 887 
 888 fn default_border_segment_gap() -> i64 {
 889     4
 890 }
 891 
 892 /// How far the default hover color moves toward white.
 893 const HOVER_LIGHTEN: f32 = 0.35;
 894 
 895 /// Mix a premultiplied-alpha color toward white (which is `[a, a, a, a]` in
 896 /// premultiplied space), keeping the alpha.
 897 /// Curvature-matched corner-span factor for window-scale squircle corners,
 898 /// mirroring cce-ui's `layout::corner_span_factor()` exactly: a raw
 899 /// superellipse of exponent n at a circle's nominal radius turns tighter at
 900 /// the diagonal, so the corner span is scaled by (n − 1)·2^(1/n)/√2 to make
 901 /// the diagonal curvature equal the configured radius. The clients widen
 902 /// their plate corners by this factor, so every compositor-side corner cut
 903 /// (blur, shadow, surface clip, background rect) must widen the same way or
 904 /// the cuts land outside the corners the clients draw. Exactly 1 at n = 2.
 905 /// Written on config load/reload (same place the exponent is pushed into
 906 /// scenefx), read on the render paths — f64 bits in an atomic, like
 907 /// scenefx's own global_corner_shape.
 908 static CORNER_SPAN_FACTOR: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0x3FF0000000000000); // 1.0f64
 909 
 910 pub fn corner_span_factor() -> f64 {
 911     f64::from_bits(CORNER_SPAN_FACTOR.load(std::sync::atomic::Ordering::Relaxed))
 912 }
 913 
 914 pub fn lighten_premultiplied(c: [f32; 4], t: f32) -> [f32; 4] {
 915     [
 916         c[0] + (c[3] - c[0]) * t,
 917         c[1] + (c[3] - c[1]) * t,
 918         c[2] + (c[3] - c[2]) * t,
 919         c[3],
 920     ]
 921 }
 922 
 923 
 924 #[derive(Debug, Deserialize)]
 925 pub struct Config {
 926     #[serde(default)]
 927     pub layout: LayoutConfig,
 928     #[serde(default)]
 929     pub env: HashMap<String, String>,
 930     #[serde(default, rename = "key_bindings")]
 931     pub key_bindings: Vec<KeybindConfig>,
 932     #[serde(default)]
 933     pub pointer_bind: Vec<PointerBindConfig>,
 934     #[serde(default)]
 935     pub mode_rule: Vec<ModeRuleConfig>,
 936     #[serde(default)]
 937     pub tag_layout: Vec<TagLayoutConfig>,
 938     #[serde(default)]
 939     pub startup: Vec<StartupConfig>,
 940     #[serde(default)]
 941     pub output: Option<OutputConfig>,
 942     #[serde(default)]
 943     pub display: HashMap<String, f64>,
 944     #[serde(default)]
 945     pub device: Vec<InputDeviceConfigRule>,
 946     #[serde(default)]
 947     pub input: Option<InputConfig>,
 948     #[serde(default)]
 949     pub gesture_bind: Vec<GestureBindConfig>,
 950     #[serde(default)]
 951     pub transparency: Option<TransparencyConfig>,
 952     #[serde(default)]
 953     pub surface: SurfaceConfig,
 954     #[serde(default)]
 955     pub window_manager: Option<WindowManagerConfig>,
 956     /// The `idle { }` block: display-off and sleep timeouts (seconds).
 957     #[serde(skip)]
 958     pub idle: crate::idle::IdleConfig,
 959 }
 960 
 961 #[derive(Debug, Deserialize)]
 962 pub struct LayoutConfig {
 963     #[serde(default = "default_gap")]
 964     pub gap: i64,
 965     #[serde(default = "default_gap_top")]
 966     pub gap_top: i64,
 967     #[serde(default = "default_gap_left")]
 968     pub gap_left: i64,
 969     #[serde(default = "default_gap_right")]
 970     pub gap_right: i64,
 971     #[serde(default = "default_gap_bottom")]
 972     pub gap_bottom: i64,
 973     #[serde(default = "default_cascade_offset")]
 974     pub cascade_offset: i64,
 975     #[serde(default = "default_bar_height")]
 976     pub bar_height: i64,
 977     #[serde(default = "default_transition_duration")]
 978     pub transition_duration: i64,
 979     #[serde(default = "default_grid_gap")]
 980     pub grid_gap: i64,
 981     #[serde(default = "default_window_blur")]
 982     pub window_blur: bool,
 983     #[serde(default = "default_overlay_behavior", alias = "pinned_behavior", alias = "side_panel_behavior")]
 984     pub overlay_behavior: String,
 985     #[serde(default = "default_overlay_width", alias = "pinned_width", alias = "side_panel_width")]
 986     pub overlay_width: i64,
 987     #[serde(default = "default_overlay_position", alias = "pinned_position", alias = "side_panel_position")]
 988     pub overlay_position: String,
 989     #[serde(default = "default_overlay_border_gap", alias = "pinned_border_gap", alias = "side_panel_border_gap")]
 990     pub overlay_border_gap: i64,
 991     #[serde(default = "default_status_normal_color")]
 992     pub status_normal_color: String,
 993     #[serde(default = "default_status_background_blur")]
 994     pub status_background_blur: f64,
 995     #[serde(default = "default_window_opacity")]
 996     pub window_opacity: bool,
 997     #[serde(default = "default_status_backdrop_blur_ignore_transparent")]
 998     pub status_backdrop_blur_ignore_transparent: bool,
 999     #[serde(default = "default_window_backdrop_blur_ignore_transparent")]
1000     pub window_backdrop_blur_ignore_transparent: bool,
1001     #[serde(default = "default_status_module_hide_mode_preview")]
1002     pub status_module_hide_mode_preview: i64,
1003     #[serde(default = "default_status_module_spacing")]
1004     pub status_module_spacing: i64,
1005     /// The bar's `module { droplet }` spec string (presence enables the
1006     /// droplet style; the compositor's per-segment backdrop-refraction node
1007     /// reads the same spec the bar draws from).
1008     #[serde(default)]
1009     pub status_droplet: Option<String>,
1010     /// Backdrop compression for status segments, from the bar's
1011     /// `module { backdrop_compress }` (the minimum WCAG contrast ratio the
1012     /// module text must hold against any backdrop pixel) and its
1013     /// `module { text_color }`: `(ceil, knee, invert)` in linear luminance,
1014     /// see `backdrop_compress_params`. `None` = off.
1015     #[serde(default)]
1016     pub status_backdrop_compress: Option<(f32, f32, bool)>,
1017 }
1018 
1019 impl Default for LayoutConfig {
1020     fn default() -> Self {
1021         Self {
1022             gap: default_gap(),
1023             gap_top: default_gap_top(),
1024             gap_left: default_gap_left(),
1025             gap_right: default_gap_right(),
1026             gap_bottom: default_gap_bottom(),
1027             cascade_offset: default_cascade_offset(),
1028             bar_height: default_bar_height(),
1029             transition_duration: default_transition_duration(),
1030             grid_gap: default_grid_gap(),
1031             window_blur: default_window_blur(),
1032             overlay_behavior: default_overlay_behavior(),
1033             overlay_width: default_overlay_width(),
1034             overlay_position: default_overlay_position(),
1035             overlay_border_gap: default_overlay_border_gap(),
1036             status_normal_color: default_status_normal_color(),
1037             status_background_blur: default_status_background_blur(),
1038             window_opacity: default_window_opacity(),
1039             status_backdrop_blur_ignore_transparent: default_status_backdrop_blur_ignore_transparent(),
1040             window_backdrop_blur_ignore_transparent: default_window_backdrop_blur_ignore_transparent(),
1041             status_module_hide_mode_preview: default_status_module_hide_mode_preview(),
1042             status_module_spacing: default_status_module_spacing(),
1043             status_droplet: None,
1044             status_backdrop_compress: None,
1045         }
1046     }
1047 }
1048 
1049 fn default_gap() -> i64 { 48 }
1050 fn default_gap_top() -> i64 { 48 }
1051 fn default_gap_left() -> i64 { 48 }
1052 fn default_gap_right() -> i64 { 48 }
1053 fn default_gap_bottom() -> i64 { 48 }
1054 fn default_cascade_offset() -> i64 { 20 }
1055 fn default_bar_height() -> i64 { 24 }
1056 fn default_transition_duration() -> i64 { 300 }
1057 fn default_grid_gap() -> i64 { 18 }
1058 fn default_window_blur() -> bool { false }
1059 fn default_overlay_behavior() -> String { "inline".to_string() }
1060 fn default_overlay_width() -> i64 { 360 }
1061 fn default_overlay_position() -> String { "left".to_string() }
1062 fn default_overlay_border_gap() -> i64 { 0 }
1063 fn default_status_normal_color() -> String { "#ccccd8".to_string() }
1064 fn default_status_background_blur() -> f64 { 0.8 }
1065 fn default_window_opacity() -> bool { true }
1066 fn default_status_backdrop_blur_ignore_transparent() -> bool { true }
1067 
1068 fn default_status_module_hide_mode_preview() -> i64 { 4 }
1069 fn default_status_module_spacing() -> i64 {
1070     crate::policy::arrange::DEFAULT_STATUS_MODULE_SPACING as i64
1071 }
1072 fn default_window_backdrop_blur_ignore_transparent() -> bool { true }
1073 
1074 #[derive(Debug, Deserialize)]
1075 pub struct ModeRuleConfig {
1076     pub mode: String,
1077     pub app_id: String,
1078     pub title: Option<String>,
1079     pub single: Option<bool>,
1080     pub tag: Option<i64>,
1081     pub circular: Option<bool>,
1082     pub ssd: Option<bool>,
1083     pub over_sibling: Option<bool>,
1084     pub center: Option<bool>,
1085 }
1086 
1087 #[derive(Debug, Deserialize)]
1088 pub struct TagLayoutConfig {
1089     pub tag: i64,
1090     pub mode: String,
1091 }
1092 
1093 pub fn parse_hex_color(hex_str: &str) -> u32 {
1094     let hex = hex_str.trim_matches(|c| c == '"' || c == '\'' || c == ' ');
1095     let hex = hex.trim_start_matches('#');
1096     if hex.len() != 6 {
1097         return 0xFFFFFFFF; // fallback to white
1098     }
1099     if let Ok(val) = u32::from_str_radix(hex, 16) {
1100         val
1101     } else {
1102         0xFFFFFFFF
1103     }
1104 }
1105 
1106 /// Backdrop compression parameters for text of color `text_hex` that must
1107 /// hold a WCAG contrast `ratio` against every backdrop pixel: `(ceil, knee,
1108 /// invert)` in linear luminance, as scenefx's `wlr_scene_blur_set_compress`
1109 /// takes them. Light text caps the backdrop at the brightest luminance that
1110 /// still gives the ratio; dark text (`invert`) floors it at the darkest, and
1111 /// the ceiling is then measured in the inverted image (1 - floor). The knee,
1112 /// where compression starts, sits at half the ceiling, so a backdrop already
1113 /// dark enough for the text is left exactly as it is. `None` for a ratio of
1114 /// 1 or less, or an unparseable color.
1115 ///
1116 /// The bubble's own translucent fill and droplet lighting composite on top
1117 /// of the compressed backdrop and lift it, so the ratio reached is well
1118 /// under the one asked for (droplet style on pure white: 4.5 asked, 2.7
1119 /// reached; 10 asked, 4.7 reached).
1120 pub fn backdrop_compress_params(text_hex: &str, ratio: f64) -> Option<(f32, f32, bool)> {
1121     if !(ratio > 1.0) {
1122         return None;
1123     }
1124     let hex = text_hex.trim_matches(|c| c == '"' || c == '\'' || c == ' ').trim_start_matches('#');
1125     if hex.len() < 6 {
1126         return None;
1127     }
1128     let lin = |i: usize| -> Option<f64> {
1129         let c = u8::from_str_radix(hex.get(i..i + 2)?, 16).ok()? as f64 / 255.0;
1130         Some(if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) })
1131     };
1132     let lt = 0.2126 * lin(0)? + 0.7152 * lin(2)? + 0.0722 * lin(4)?;
1133     // Which way the text reads: against black or against white.
1134     let light = (lt + 0.05) / 0.05 >= 1.05 / (lt + 0.05);
1135     let ceil = if light {
1136         (lt + 0.05) / ratio - 0.05
1137     } else {
1138         1.0 - (ratio * (lt + 0.05) - 0.05)
1139     };
1140     // A ratio the text color cannot reach at all still compresses as hard
1141     // as it sensibly can rather than crushing the backdrop to black.
1142     let ceil = ceil.clamp(0.005, 1.0) as f32;
1143     Some((ceil, ceil * 0.5, !light))
1144 }
1145 
1146 pub fn parse_hex_color_rgba(hex_str: &str) -> [f32; 4] {
1147     let hex = hex_str.trim_matches(|c| c == '"' || c == '\'' || c == ' ');
1148     let hex = hex.trim_start_matches('#');
1149     if hex.len() == 8 {
1150         if let (Ok(r), Ok(g), Ok(b), Ok(a)) = (
1151             u8::from_str_radix(&hex[0..2], 16),
1152             u8::from_str_radix(&hex[2..4], 16),
1153             u8::from_str_radix(&hex[4..6], 16),
1154             u8::from_str_radix(&hex[6..8], 16),
1155         ) {
1156             let alpha = a as f32 / 255.0;
1157             return [
1158                 (r as f32 / 255.0) * alpha,
1159                 (g as f32 / 255.0) * alpha,
1160                 (b as f32 / 255.0) * alpha,
1161                 alpha,
1162             ];
1163         }
1164     } else if hex.len() == 6 {
1165         if let (Ok(r), Ok(g), Ok(b)) = (
1166             u8::from_str_radix(&hex[0..2], 16),
1167             u8::from_str_radix(&hex[2..4], 16),
1168             u8::from_str_radix(&hex[4..6], 16),
1169         ) {
1170             return [
1171                 r as f32 / 255.0,
1172                 g as f32 / 255.0,
1173                 b as f32 / 255.0,
1174                 1.0,
1175             ];
1176         }
1177     }
1178     [0.05, 0.05, 0.05, 0.05] // fallback default (5% white)
1179 }
1180 
1181 /// Camera reaction when the focused window goes away — see
1182 /// `WindowManager::focus_next_visible_window`. The fallback focus itself
1183 /// always transfers (keyboard input needs a live target); this only decides
1184 /// what the CAMERA does about it.
1185 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1186 pub enum OnAppExit {
1187     /// Pan to the fallback-focused window (the historic behavior).
1188     FocusPrevious,
1189     /// Enter overview instead of chasing any one window.
1190     Overview,
1191     /// The camera stays exactly where the user left it.
1192     Nothing,
1193 }
1194 
1195 pub fn parse_on_app_exit(s: &str) -> OnAppExit {
1196     match s.to_lowercase().as_str() {
1197         "focus_previous" => OnAppExit::FocusPrevious,
1198         "overview" => OnAppExit::Overview,
1199         _ => OnAppExit::Nothing,
1200     }
1201 }
1202 
1203 pub fn parse_tiling_mode(s: &str) -> TilingMode {
1204     match s.to_lowercase().as_str() {
1205         "fullscreen" => TilingMode::Fullscreen,
1206         "popup" => TilingMode::Popup,
1207         "sidepanel" | "side_panel" | "side-panel" | "pinned" | "overlay" => TilingMode::Overlay,
1208         "status" => TilingMode::Status,
1209         "utility" => TilingMode::Utility,
1210         // "maximized" is the retired name for grid-locked windows.
1211         "tiled" | "maximized" => TilingMode::Tiled,
1212         // Everything else — including the retired "cascade"/"grid" layout
1213         // modes still present in old configs — is Floating.
1214         _ => TilingMode::Floating,
1215     }
1216 }
1217 
1218 pub fn parse_modifiers(mod_str: &str) -> u32 {
1219     let mut mods = 0u32;
1220     if mod_str.contains("super") || mod_str.contains("mod4") {
1221         mods |= 0x40; // RIVER_SEAT_V1_MODIFIERS_MOD4
1222     }
1223     if mod_str.contains("shift") {
1224         mods |= 0x01; // RIVER_SEAT_V1_MODIFIERS_SHIFT
1225     }
1226     if mod_str.contains("ctrl") {
1227         mods |= 0x04; // RIVER_SEAT_V1_MODIFIERS_CTRL
1228     }
1229     if mod_str.contains("alt") || mod_str.contains("mod1") {
1230         mods |= 0x08; // RIVER_SEAT_V1_MODIFIERS_MOD1
1231     }
1232     mods
1233 }
1234 
1235 /// A touchscreen edge-swipe chord: `edge_left`, `edge_right`, `edge_top`
1236 /// or `edge_bottom` — the edge the finger starts from — optionally behind
1237 /// modifiers (`super+edge_top`). Returns the modifier mask and the edge.
1238 /// Kept apart from `cce_window_manager::bindings::parse_gesture`, whose
1239 /// gestures are the touchpad's and carry a finger count.
1240 pub fn parse_edge_gesture(chord: &str) -> Option<(u32, String)> {
1241     let lower = chord.trim().to_lowercase().replace('-', "_");
1242     let (mods, gesture) = match lower.rsplit_once('+') {
1243         Some((mods, gesture)) => (parse_modifiers(mods), gesture.trim()),
1244         None => (0, lower.as_str()),
1245     };
1246     let edge = gesture.strip_prefix("edge_")?;
1247     matches!(edge, "left" | "right" | "top" | "bottom").then(|| (mods, edge.to_string()))
1248 }
1249 
1250 pub fn parse_button(s: &str) -> u32 {
1251     match s.trim() {
1252         "left" => 0x110,   // BTN_LEFT
1253         "right" => 0x111,  // BTN_RIGHT
1254         "middle" => 0x112, // BTN_MIDDLE
1255         "side" => 0x113,   // BTN_SIDE
1256         "extra" => 0x114,  // BTN_EXTRA
1257         _ => s.trim().parse().unwrap_or(0),
1258     }
1259 }
1260 
1261 pub fn parse_action(s: &str) -> Action {
1262     let trimmed = s.trim();
1263     if trimmed.starts_with("spawn")
1264         && (trimmed.len() == 5 || trimmed.as_bytes()[5] == b' ' || trimmed.as_bytes()[5] == b'-')
1265     {
1266         Action::Spawn
1267     } else if trimmed == "toggle" {
1268         Action::Toggle
1269     } else if trimmed == "move" {
1270         Action::Move
1271     } else if trimmed == "resize" {
1272         Action::Resize
1273     } else {
1274         Action::None
1275     }
1276 }
1277 
1278 /// Warn when a spawn/toggle binding's executable can't be found (PATH lookup;
1279 /// absolute paths are checked directly).
1280 fn warn_if_command_missing(command: Option<&str>) {
1281     let Some(cmd_str) = command else { return };
1282     let cmd_exe = cmd_str.split_whitespace().next().unwrap_or("");
1283     if cmd_exe.is_empty() {
1284         return;
1285     }
1286     if let Ok(path_var) = std::env::var("PATH") {
1287         for path_dir in std::env::split_paths(&path_var) {
1288             if path_dir.join(cmd_exe).is_file() {
1289                 return;
1290             }
1291         }
1292         eprintln!("[WARNING] Configured keybinding command not found in PATH: {}", cmd_exe);
1293     }
1294 }
1295 
1296 pub fn parse_keysym(key_str: &str) -> u32 {
1297     let name = if key_str.starts_with("XKB_KEY_") {
1298         &key_str[8..]
1299     } else {
1300         key_str
1301     };
1302     xkbcommon::xkb::keysym_from_name(name, xkbcommon::xkb::KEYSYM_CASE_INSENSITIVE).into()
1303 }
1304 
1305 pub fn default_config_path() -> Option<String> {
1306     let xdg_config_home = std::env::var("XDG_CONFIG_HOME")
1307         .unwrap_or_else(|_| {
1308             let home = std::env::var("HOME").unwrap_or_default();
1309             format!("{}/.config", home)
1310         });
1311         
1312     let kdl_path = format!("{}/cce/config.kdl", xdg_config_home);
1313     if std::path::Path::new(&kdl_path).exists() {
1314         Some(kdl_path)
1315     } else {
1316         None
1317     }
1318 }
1319 
1320 /// `<state_home>/cce/state.json`; `None` when neither `$XDG_STATE_HOME` nor
1321 /// `$HOME` is set, rather than a path relative to wherever we were started.
1322 pub fn default_state_path() -> Option<String> {
1323     let path = cce_core::config::cce_state_dir().join("state.json");
1324     path.is_absolute().then(|| path.to_string_lossy().into_owned())
1325 }
1326 
1327 fn expand_env_vars(s: &str) -> String {
1328     let mut result = String::with_capacity(s.len());
1329     let chars: Vec<char> = s.chars().collect();
1330     let mut i = 0;
1331     while i < chars.len() {
1332         if chars[i] == '$' && i + 1 < chars.len() {
1333             if chars[i + 1] == '{' {
1334                 // ${VAR} form
1335                 if let Some(end) = chars[i + 2..].iter().position(|c| *c == '}') {
1336                     let var_name: String = chars[i + 2..i + 2 + end].iter().collect();
1337                     let val = std::env::var(&var_name).unwrap_or_default();
1338                     result.push_str(&val);
1339                     i = i + 2 + end + 1; // skip ${VAR}
1340                 } else {
1341                     result.push(chars[i]);
1342                     i += 1;
1343                 }
1344             } else if chars[i + 1].is_ascii_alphabetic() || chars[i + 1] == '_' {
1345                 // $VAR form — name is [A-Za-z_][A-Za-z0-9_]*
1346                 let start = i + 1;
1347                 let mut end = start;
1348                 while end < chars.len() && (chars[end].is_ascii_alphanumeric() || chars[end] == '_')
1349                 {
1350                     end += 1;
1351                 }
1352                 let var_name: String = chars[start..end].iter().collect();
1353                 let val = std::env::var(&var_name).unwrap_or_default();
1354                 result.push_str(&val);
1355                 i = end;
1356             } else {
1357                 // $ followed by non-identifier char (e.g. $$, $:, $@) — keep as-is
1358                 result.push(chars[i]);
1359                 i += 1;
1360             }
1361         } else {
1362             result.push(chars[i]);
1363             i += 1;
1364         }
1365     }
1366     result
1367 }
1368 
1369 /// A node's keyed values in BOTH KDL spellings, as `(key, entry)`: its
1370 /// properties (`root color="…" blur=0.5 corner_radius=24`) and its child
1371 /// nodes' first positional value (`root { color "…"; blur 0.5;
1372 /// corner_radius 24 }`). The child form is what cce-data-editor writes and
1373 /// what cce-ui's `kdl_to_json` reads; a block parsed from properties alone
1374 /// silently keeps its defaults under it, which is how the compositor ran on
1375 /// a root radius of 12 while every cce-ui app read the config's 24
1376 /// (2026-09-28). Properties first, then children, so a key given both ways
1377 /// takes the child's.
1378 fn node_keyed_values(node: &kdl::KdlNode) -> Vec<(String, kdl::KdlEntry)> {
1379     let mut out: Vec<(String, kdl::KdlEntry)> = node
1380         .entries()
1381         .iter()
1382         .filter_map(|e| e.name().map(|n| (n.value().to_string(), e.clone())))
1383         .collect();
1384     if let Some(children) = node.children() {
1385         for child in children.nodes() {
1386             if let Some(e) = child.entries().first() {
1387                 if e.name().is_none() {
1388                     out.push((child.name().value().to_string(), e.clone()));
1389                 }
1390             }
1391         }
1392     }
1393     out
1394 }
1395 
1396 fn get_child_arg_i64(node: &kdl::KdlNode, child_name: &str, default: i64) -> i64 {
1397     if let Some(children) = node.children() {
1398         for child in children.nodes() {
1399             if child.name().value() == child_name {
1400                 if let Some(entry) = child.entries().first() {
1401                     return entry.value().as_i64().unwrap_or(default);
1402                 }
1403             }
1404         }
1405     }
1406     default
1407 }
1408 
1409 fn get_child_arg_i64_opt(node: &kdl::KdlNode, child_name: &str) -> Option<i64> {
1410     if let Some(children) = node.children() {
1411         for child in children.nodes() {
1412             if child.name().value() == child_name {
1413                 if let Some(entry) = child.entries().first() {
1414                     return entry.value().as_i64();
1415                 }
1416             }
1417         }
1418     }
1419     None
1420 }
1421 
1422 fn get_child_arg_f64(node: &kdl::KdlNode, child_name: &str, default: f64) -> f64 {
1423     if let Some(children) = node.children() {
1424         for child in children.nodes() {
1425             if child.name().value() == child_name {
1426                 if let Some(entry) = child.entries().first() {
1427                     let mut val = entry.value().as_f64().unwrap_or(default);
1428                     if let Some(ty) = entry.ty() {
1429                         let ty_str = ty.value();
1430                         if ty_str.starts_with("f64:") {
1431                             let range_str = ty_str.trim_start_matches("f64:");
1432                             if let Some(dash_idx) = range_str.find('-') {
1433                                 let min_str = &range_str[..dash_idx].trim();
1434                                 let max_str = &range_str[dash_idx + 1..].trim();
1435                                 if let (Ok(min_f), Ok(max_f)) = (min_str.parse::<f64>(), max_str.parse::<f64>()) {
1436                                     val = val.clamp(min_f, max_f);
1437                                 }
1438                             }
1439                         }
1440                     }
1441                     return val;
1442                 }
1443             }
1444         }
1445     }
1446     default
1447 }
1448 
1449 fn get_child_arg_bool(node: &kdl::KdlNode, child_name: &str, default: bool) -> bool {
1450     if let Some(children) = node.children() {
1451         for child in children.nodes() {
1452             if child.name().value() == child_name {
1453                 if let Some(entry) = child.entries().first() {
1454                     return entry.value().as_bool().unwrap_or(default);
1455                 }
1456             }
1457         }
1458     }
1459     default
1460 }
1461 
1462 fn get_child_arg_string(node: &kdl::KdlNode, child_name: &str, default: &str) -> String {
1463     if let Some(children) = node.children() {
1464         for child in children.nodes() {
1465             if child.name().value() == child_name {
1466                 if let Some(entry) = child.entries().first() {
1467                     return entry.value().as_string().map(|s| s.to_string()).unwrap_or_else(|| default.to_string());
1468                 }
1469             }
1470         }
1471     }
1472     default.to_string()
1473 }
1474 
1475 fn get_child_arg_bool_opt(node: &kdl::KdlNode, child_name: &str) -> Option<bool> {
1476     if let Some(children) = node.children() {
1477         for child in children.nodes() {
1478             if child.name().value() == child_name {
1479                 if let Some(entry) = child.entries().first() {
1480                     return entry.value().as_bool();
1481                 }
1482             }
1483         }
1484     }
1485     None
1486 }
1487 
1488 fn get_child_arg_f64_opt(node: &kdl::KdlNode, child_name: &str) -> Option<f64> {
1489     if let Some(children) = node.children() {
1490         for child in children.nodes() {
1491             if child.name().value() == child_name {
1492                 if let Some(entry) = child.entries().first() {
1493                     let mut val = entry.value().as_f64()?;
1494                     if let Some(ty) = entry.ty() {
1495                         let ty_str = ty.value();
1496                         if ty_str.starts_with("f64:") {
1497                             let range_str = ty_str.trim_start_matches("f64:");
1498                             if let Some(dash_idx) = range_str.find('-') {
1499                                 let min_str = &range_str[..dash_idx].trim();
1500                                 let max_str = &range_str[dash_idx + 1..].trim();
1501                                 if let (Ok(min_f), Ok(max_f)) = (min_str.parse::<f64>(), max_str.parse::<f64>()) {
1502                                     val = val.clamp(min_f, max_f);
1503                                 }
1504                             }
1505                         }
1506                     }
1507                     return Some(val);
1508                 }
1509             }
1510         }
1511     }
1512     None
1513 }
1514 
1515 fn get_child_arg_vec2i_opt(node: &kdl::KdlNode, child_name: &str) -> Option<[i32; 2]> {
1516     if let Some(children) = node.children() {
1517         for child in children.nodes() {
1518             if child.name().value() == child_name {
1519                 let entries = child.entries();
1520                 if entries.len() >= 2 {
1521                     let has_tag = entries.first().and_then(|e| e.ty()).map_or(false, |t| t.value() == "vec2i");
1522                     if has_tag {
1523                         let x = entries[0].value().as_i64()? as i32;
1524                         let y = entries[1].value().as_i64()? as i32;
1525                         return Some([x, y]);
1526                     }
1527                 }
1528             }
1529         }
1530     }
1531     None
1532 }
1533 
1534 
1535 /// All positional string args of a child node, e.g. `rounded_apps "a" "b"`.
1536 /// `Some` when the child node is present (even with no args), `None` when absent.
1537 fn get_child_args_string_vec_opt(node: &kdl::KdlNode, child_name: &str) -> Option<Vec<String>> {
1538     if let Some(children) = node.children() {
1539         for child in children.nodes() {
1540             if child.name().value() == child_name {
1541                 return Some(
1542                     child
1543                         .entries()
1544                         .iter()
1545                         .filter(|e| e.name().is_none())
1546                         .filter_map(|e| e.value().as_string().map(|s| s.to_string()))
1547                         .collect(),
1548                 );
1549             }
1550         }
1551     }
1552     None
1553 }
1554 
1555 fn get_child_arg_string_opt(node: &kdl::KdlNode, child_name: &str) -> Option<String> {
1556     if let Some(children) = node.children() {
1557         for child in children.nodes() {
1558             if child.name().value() == child_name {
1559                 if let Some(entry) = child.entries().first() {
1560                     return entry.value().as_string().map(|s| s.to_string());
1561                 }
1562             }
1563         }
1564     }
1565     None
1566 }
1567 
1568 fn get_prop_string(node: &kdl::KdlNode, key: &str, default: &str) -> String {
1569     for entry in node.entries() {
1570         if let Some(id) = entry.name() {
1571             if id.value() == key {
1572                 return entry.value().as_string().map(|s| s.to_string()).unwrap_or_else(|| default.to_string());
1573             }
1574         }
1575     }
1576     default.to_string()
1577 }
1578 
1579 fn get_prop_string_opt(node: &kdl::KdlNode, key: &str) -> Option<String> {
1580     for entry in node.entries() {
1581         if let Some(id) = entry.name() {
1582             if id.value() == key {
1583                 return entry.value().as_string().map(|s| s.to_string());
1584             }
1585         }
1586     }
1587     None
1588 }
1589 
1590 fn get_prop_i64(node: &kdl::KdlNode, key: &str, default: i64) -> i64 {
1591     for entry in node.entries() {
1592         if let Some(id) = entry.name() {
1593             if id.value() == key {
1594                 return entry.value().as_i64().unwrap_or(default);
1595             }
1596         }
1597     }
1598     default
1599 }
1600 
1601 fn get_prop_bool(node: &kdl::KdlNode, key: &str, default: bool) -> bool {
1602     for entry in node.entries() {
1603         if let Some(id) = entry.name() {
1604             if id.value() == key {
1605                 return entry.value().as_bool().unwrap_or(default);
1606             }
1607         }
1608     }
1609     default
1610 }
1611 
1612 fn get_prop_bool_opt(node: &kdl::KdlNode, key: &str) -> Option<bool> {
1613     for entry in node.entries() {
1614         if let Some(id) = entry.name() {
1615             if id.value() == key {
1616                 return entry.value().as_bool();
1617             }
1618         }
1619     }
1620     None
1621 }
1622 
1623 fn get_prop_i64_opt(node: &kdl::KdlNode, key: &str) -> Option<i64> {
1624     for entry in node.entries() {
1625         if let Some(id) = entry.name() {
1626             if id.value() == key {
1627                 return entry.value().as_i64();
1628             }
1629         }
1630     }
1631     None
1632 }
1633 
1634 fn get_prop_f64_opt(node: &kdl::KdlNode, key: &str) -> Option<f64> {
1635     for entry in node.entries() {
1636         if let Some(id) = entry.name() {
1637             if id.value() == key {
1638                 let mut val = entry.value().as_f64()?;
1639                 if let Some(ty) = entry.ty() {
1640                     let ty_str = ty.value();
1641                     if ty_str.starts_with("f64:") {
1642                         let range_str = ty_str.trim_start_matches("f64:");
1643                         if let Some(dash_idx) = range_str.find('-') {
1644                             let min_str = &range_str[..dash_idx].trim();
1645                             let max_str = &range_str[dash_idx + 1..].trim();
1646                             if let (Ok(min_f), Ok(max_f)) = (min_str.parse::<f64>(), max_str.parse::<f64>()) {
1647                                 val = val.clamp(min_f, max_f);
1648                             }
1649                         }
1650                     }
1651                 }
1652                 return Some(val);
1653             }
1654         }
1655     }
1656     None
1657 }
1658 
1659 fn get_nested_prop_string(node: &kdl::KdlNode, child_name: &str, prop_name: &str, default: &str) -> String {
1660     if let Some(children) = node.children() {
1661         for child in children.nodes() {
1662             if child.name().value() == child_name {
1663                 for entry in child.entries() {
1664                     if let Some(id) = entry.name() {
1665                         if id.value() == prop_name {
1666                             return entry.value().as_string().map(|s| s.to_string()).unwrap_or_else(|| default.to_string());
1667                         }
1668                     }
1669                 }
1670             }
1671         }
1672     }
1673     default.to_string()
1674 }
1675 
1676 fn get_nested_prop_i64(node: &kdl::KdlNode, child_name: &str, prop_name: &str, default: i64) -> i64 {
1677     if let Some(children) = node.children() {
1678         for child in children.nodes() {
1679             if child.name().value() == child_name {
1680                 for entry in child.entries() {
1681                     if let Some(id) = entry.name() {
1682                         if id.value() == prop_name {
1683                             return entry.value().as_i64().unwrap_or(default);
1684                         }
1685                     }
1686                 }
1687             }
1688         }
1689     }
1690     default
1691 }
1692 
1693 fn get_nested_prop_bool(node: &kdl::KdlNode, child_name: &str, prop_name: &str, default: bool) -> bool {
1694     if let Some(children) = node.children() {
1695         for child in children.nodes() {
1696             if child.name().value() == child_name {
1697                 for entry in child.entries() {
1698                     if let Some(id) = entry.name() {
1699                         if id.value() == prop_name {
1700                             return entry.value().as_bool().unwrap_or(default);
1701                         }
1702                     }
1703                 }
1704             }
1705         }
1706     }
1707     default
1708 }
1709 
1710 fn get_nested_prop_f64(node: &kdl::KdlNode, child_name: &str, prop_name: &str, default: f64) -> f64 {
1711     if let Some(children) = node.children() {
1712         for child in children.nodes() {
1713             if child.name().value() == child_name {
1714                 for entry in child.entries() {
1715                     if let Some(id) = entry.name() {
1716                         if id.value() == prop_name {
1717                             let mut val = entry.value().as_f64().unwrap_or(default);
1718                             if let Some(ty) = entry.ty() {
1719                                 let ty_str = ty.value();
1720                                 if ty_str.starts_with("f64:") {
1721                                     let range_str = ty_str.trim_start_matches("f64:");
1722                                     if let Some(dash_idx) = range_str.find('-') {
1723                                         let min_str = &range_str[..dash_idx].trim();
1724                                         let max_str = &range_str[dash_idx + 1..].trim();
1725                                         if let (Ok(min_f), Ok(max_f)) = (min_str.parse::<f64>(), max_str.parse::<f64>()) {
1726                                             val = val.clamp(min_f, max_f);
1727                                         }
1728                                     }
1729                                 }
1730                             }
1731                             return val;
1732                         }
1733                     }
1734                 }
1735             }
1736         }
1737     }
1738     default
1739 }
1740 
1741 /// `"344x215"` (also `344,215` / `344 215`) → (w, h) in mm, both positive.
1742 fn parse_size_mm(s: &str) -> Option<(f64, f64)> {
1743     let mut it = s.split(|c: char| c == 'x' || c == 'X' || c == ',' || c.is_whitespace()).filter(|p| !p.is_empty());
1744     let w = it.next()?.trim().parse::<f64>().ok()?;
1745     let h = it.next()?.trim().parse::<f64>().ok()?;
1746     (w > 0.0 && h > 0.0 && it.next().is_none()).then_some((w, h))
1747 }
1748 
1749 fn parse_kdl_config(content: &str) -> Result<Config, String> {
1750     let doc: kdl::KdlDocument = content.parse().map_err(|e| format!("KDL parse error: {}", e))?;
1751     
1752     // 1. layout & style
1753     let mut layout = LayoutConfig::default();
1754     if let Some(node) = doc.nodes().iter().find(|n| n.name().value() == "layout") {
1755         layout.gap = get_child_arg_i64(node, "gap", default_gap());
1756         layout.gap_top = get_child_arg_i64(node, "gap_top", default_gap_top());
1757         layout.gap_left = get_child_arg_i64(node, "gap_left", default_gap_left());
1758         layout.gap_right = get_child_arg_i64(node, "gap_right", default_gap_right());
1759         layout.gap_bottom = get_child_arg_i64(node, "gap_bottom", default_gap_bottom());
1760         layout.cascade_offset = get_child_arg_i64(node, "cascade_offset", default_cascade_offset());
1761         layout.bar_height = get_child_arg_i64(node, "bar_height", default_bar_height());
1762         layout.grid_gap = get_child_arg_i64(node, "grid_gap", default_grid_gap());
1763     }
1764     
1765     if let Some(node) = doc.nodes().iter().find(|n| n.name().value() == "style") {
1766         layout.transition_duration = get_nested_prop_i64(node, "window", "transition_duration", default_transition_duration());
1767         layout.window_backdrop_blur_ignore_transparent = get_nested_prop_bool(node, "window", "backdrop_blur_ignore_transparent", default_window_backdrop_blur_ignore_transparent());
1768         
1769         layout.overlay_behavior = get_nested_prop_string(node, "overlay", "behavior", &default_overlay_behavior());
1770         layout.overlay_width = get_nested_prop_i64(node, "overlay", "width", default_overlay_width());
1771         layout.overlay_position = get_nested_prop_string(node, "overlay", "position", &default_overlay_position());
1772         layout.overlay_border_gap = get_nested_prop_i64(node, "overlay", "border_gap", default_overlay_border_gap());
1773         
1774         layout.status_normal_color = get_nested_prop_string(node, "status", "normal_color", &default_status_normal_color());
1775         layout.status_background_blur = get_nested_prop_f64(node, "status", "background_blur", default_status_background_blur());
1776         layout.status_backdrop_blur_ignore_transparent = get_nested_prop_bool(node, "status", "backdrop_blur_ignore_transparent", default_status_backdrop_blur_ignore_transparent());
1777         layout.status_module_hide_mode_preview = get_nested_prop_i64(node, "status", "module_hide_mode_preview", default_status_module_hide_mode_preview());
1778         layout.status_module_spacing = get_nested_prop_i64(node, "status", "module_spacing", default_status_module_spacing());
1779     }
1780 
1781     // The status bar's own config file wins over the shared status keys:
1782     // ~/.config/cce/cce-status-interface/config.kdl, `module { spacing height }`.
1783     // Re-read on every config (re)load, so `ccectl reload` picks up edits.
1784     {
1785         let app_cfg = cce_core::config::get_app_config_path("cce-status-interface");
1786         if let Ok(content) = std::fs::read_to_string(&app_cfg) {
1787             if let Ok(app_doc) = content.parse::<kdl::KdlDocument>() {
1788                 if let Some(module) = app_doc.nodes().iter().find(|n| n.name().value() == "module") {
1789                     // A module key in either KDL spelling: `spacing=(f64)12`
1790                     // prop on the module node, or a `spacing 12` child node.
1791                     let module_i64 = |key: &str| -> Option<i64> {
1792                         module
1793                             .entries()
1794                             .iter()
1795                             .find(|e| e.name().map(|id| id.value()) == Some(key))
1796                             .map(|e| e.value())
1797                             .or_else(|| {
1798                                 module.children().and_then(|c| {
1799                                     c.nodes()
1800                                         .iter()
1801                                         .find(|n| n.name().value() == key)
1802                                         .and_then(|n| n.entries().first().map(|e| e.value()))
1803                                 })
1804                             })
1805                             .and_then(|v| v.as_i64().or_else(|| v.as_f64().map(|f| f.round() as i64)))
1806                     };
1807                     if let Some(i) = module_i64("spacing") {
1808                         layout.status_module_spacing = i;
1809                     }
1810                     if let Some(i) = module_i64("height") {
1811                         layout.bar_height = i;
1812                     }
1813                     // The droplet spec string (presence enables the style;
1814                     // empty = all defaults). Same either-spelling lookup.
1815                     let module_str = |key: &str| -> Option<String> {
1816                         module
1817                             .entries()
1818                             .iter()
1819                             .find(|e| e.name().map(|id| id.value()) == Some(key))
1820                             .map(|e| e.value())
1821                             .or_else(|| {
1822                                 module.children().and_then(|c| {
1823                                     c.nodes()
1824                                         .iter()
1825                                         .find(|n| n.name().value() == key)
1826                                         .and_then(|n| n.entries().first().map(|e| e.value()))
1827                                 })
1828                             })
1829                             .and_then(|v| v.as_string().map(|s| s.to_string()))
1830                     };
1831                     layout.status_droplet = module_str("droplet");
1832                     // Backdrop compression: the contrast ratio the text must
1833                     // hold, against the text color the bar draws in (its
1834                     // own fallback is the shared status normal_color).
1835                     let module_f64 = |key: &str| -> Option<f64> {
1836                         module
1837                             .entries()
1838                             .iter()
1839                             .find(|e| e.name().map(|id| id.value()) == Some(key))
1840                             .map(|e| e.value())
1841                             .or_else(|| {
1842                                 module.children().and_then(|c| {
1843                                     c.nodes()
1844                                         .iter()
1845                                         .find(|n| n.name().value() == key)
1846                                         .and_then(|n| n.entries().first().map(|e| e.value()))
1847                                 })
1848                             })
1849                             .and_then(|v| v.as_f64().or_else(|| v.as_i64().map(|i| i as f64)))
1850                     };
1851                     let text_color = module_str("text_color")
1852                         .unwrap_or_else(|| layout.status_normal_color.clone());
1853                     layout.status_backdrop_compress = module_f64("backdrop_compress")
1854                         .and_then(|ratio| backdrop_compress_params(&text_color, ratio));
1855                 }
1856             }
1857         }
1858     }
1859 
1860     // 2. env
1861     let mut env = HashMap::new();
1862     if let Some(node) = doc.nodes().iter().find(|n| n.name().value() == "env") {
1863         if let Some(children) = node.children() {
1864             for child in children.nodes() {
1865                 if let Some(entry) = child.entries().first() {
1866                     if let Some(val) = entry.value().as_string() {
1867                         env.insert(child.name().value().to_string(), val.to_string());
1868                     }
1869                 }
1870             }
1871         }
1872     }
1873 
1874     // 3. lists
1875     let mut key_bindings = Vec::new();
1876     let mut pointer_bind = Vec::new();
1877     let mut gesture_bind = Vec::new();
1878     let mut mode_rule = Vec::new();
1879     let mut tag_layout = Vec::new();
1880     let mut startup = Vec::new();
1881     let mut device = Vec::new();
1882     
1883     for node in doc.nodes() {
1884         match node.name().value() {
1885             "key_bindings" => {
1886                 if let Some(children) = node.children() {
1887                     for child in children.nodes() {
1888                         if child.name().value() == "bind" {
1889                             let mods = get_prop_string(child, "mods", "");
1890                             let key = get_prop_string(child, "key", "");
1891                             let action = get_prop_string(child, "action", "");
1892                             let command = get_prop_string_opt(child, "command");
1893                             key_bindings.push(KeybindConfig { mods, key, action, command });
1894                         }
1895                     }
1896                 } else {
1897                     let mods = get_prop_string(node, "mods", "");
1898                     let key = get_prop_string(node, "key", "");
1899                     let action = get_prop_string(node, "action", "");
1900                     let command = get_prop_string_opt(node, "command");
1901                     key_bindings.push(KeybindConfig { mods, key, action, command });
1902                 }
1903             }
1904             "pointer_bind" => {
1905                 let mods = get_prop_string(node, "mods", "");
1906                 let button = get_prop_string(node, "button", "");
1907                 let action = get_prop_string(node, "action", "");
1908                 pointer_bind.push(PointerBindConfig { mods, button, action });
1909             }
1910             "gesture_bind" => {
1911                 let mods = get_prop_string_opt(node, "mods");
1912                 let gesture_type = get_prop_string(node, "type", "");
1913                 let fingers = get_prop_i64(node, "fingers", 0) as u32;
1914                 let direction = get_prop_string(node, "direction", "");
1915                 let action = get_prop_string(node, "action", "");
1916                 let command = get_prop_string_opt(node, "command");
1917                 gesture_bind.push(GestureBindConfig { mods, gesture_type, fingers, direction, action, command });
1918             }
1919             "mode_rule" => {
1920                 let mode = get_prop_string(node, "mode", "");
1921                 let app_id = get_prop_string(node, "app_id", "");
1922                 let title = get_prop_string_opt(node, "title");
1923                 let single = get_prop_bool_opt(node, "single");
1924                 let tag = get_prop_i64_opt(node, "tag");
1925                 let circular = get_prop_bool_opt(node, "circular");
1926                 let ssd = get_prop_bool_opt(node, "ssd");
1927                 let over_sibling = get_prop_bool_opt(node, "over_sibling");
1928                 let center = get_prop_bool_opt(node, "center");
1929                 mode_rule.push(ModeRuleConfig { mode, app_id, title, single, tag, circular, ssd, over_sibling, center });
1930             }
1931             "tag_layout" => {
1932                 let tag = get_prop_i64(node, "tag", 0);
1933                 let mode = get_prop_string(node, "mode", "");
1934                 tag_layout.push(TagLayoutConfig { tag, mode });
1935             }
1936             "startup" => {
1937                 let exec = get_prop_string(node, "exec", "");
1938                 let once = get_prop_bool(node, "once", false);
1939                 let restart = get_prop_bool(node, "restart", false);
1940                 startup.push(StartupConfig { exec, once, restart });
1941             }
1942             "device" => {
1943                 let name = get_prop_string(node, "name", "");
1944                 let scroll_factor = get_prop_f64_opt(node, "scroll_factor");
1945                 device.push(InputDeviceConfigRule { name, scroll_factor });
1946             }
1947             _ => {}
1948         }
1949     }
1950 
1951     if key_bindings.is_empty() {
1952         if let Some(input_node) = doc.nodes().iter().find(|n| n.name().value() == "input") {
1953             if let Some(children) = input_node.children() {
1954                 for child_node in children.nodes() {
1955                     if child_node.name().value() == "key_bindings" {
1956                         if let Some(bind_children) = child_node.children() {
1957                             for child in bind_children.nodes() {
1958                                 if child.name().value() == "bind" {
1959                                     let mods = get_prop_string(child, "mods", "");
1960                                     let key = get_prop_string(child, "key", "");
1961                                     let action = get_prop_string(child, "action", "");
1962                                     let command = get_prop_string_opt(child, "command");
1963                                     key_bindings.push(KeybindConfig { mods, key, action, command });
1964                                 }
1965                             }
1966                         } else {
1967                             let mods = get_prop_string(child_node, "mods", "");
1968                             let key = get_prop_string(child_node, "key", "");
1969                             let action = get_prop_string(child_node, "action", "");
1970                             let command = get_prop_string_opt(child_node, "command");
1971                             key_bindings.push(KeybindConfig { mods, key, action, command });
1972                         }
1973                     }
1974                 }
1975             }
1976         }
1977     }
1978 
1979     // 3b. idle timeouts
1980     let mut idle = crate::idle::IdleConfig::default();
1981     if let Some(node) = doc.nodes().iter().find(|n| n.name().value() == "idle") {
1982         idle.display_off_s = get_child_arg_i64(node, "display_off", 0);
1983         idle.sleep_s = get_child_arg_i64(node, "sleep", 0);
1984         idle.sleep_command = get_child_arg_string_opt(node, "sleep_command");
1985     }
1986 
1987     // 4. output
1988     let mut output = None;
1989     let mut display = HashMap::new();
1990     if let Some(node) = doc.nodes().iter().find(|n| n.name().value() == "output") {
1991         let scenefx_optimized_blur = get_child_arg_bool(node, "scenefx_optimized_blur", true);
1992         output = Some(OutputConfig { scenefx_optimized_blur });
1993 
1994         if let Some(children) = node.children() {
1995             for child in children.nodes() {
1996                 let name = child.name().value();
1997                 let mut parsed_scale = None;
1998                 if let Some(entry) = child.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("scale")) {
1999                     if let Some(num) = entry.value().as_f64() {
2000                         parsed_scale = Some(num);
2001                     }
2002                 }
2003                 // `size_mm="344x215"`: the panel's real size, overriding
2004                 // the EDID figure the backend read (TVs and projectors
2005                 // lie; some panels report nothing). Forwarded into the
2006                 // wl_output geometry every client sees, so cce-ui's metric
2007                 // measures against it.
2008                 let mut parsed_size_mm = None;
2009                 if let Some(entry) = child.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("size_mm")) {
2010                     parsed_size_mm = entry.value().as_string().and_then(parse_size_mm);
2011                 }
2012                 let mut parsed_interval = None;
2013                 if let Some(entry) = child.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("brightness_interval")) {
2014                     if let Some(num) = entry.value().as_i64() {
2015                         parsed_interval = Some(num);
2016                     }
2017                 }
2018                 let mut parsed_up = None;
2019                 if let Some(entry) = child.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("brightness_up")) {
2020                     if let Some(key_val) = entry.value().as_string() {
2021                         parsed_up = Some(key_val.to_string());
2022                     }
2023                 }
2024                 let mut parsed_down = None;
2025                 if let Some(entry) = child.entries().iter().find(|e| e.name().map(|n| n.value()) == Some("brightness_down")) {
2026                     if let Some(key_val) = entry.value().as_string() {
2027                         parsed_down = Some(key_val.to_string());
2028                     }
2029                 }
2030 
2031                 if let Some(display_children) = child.children() {
2032                     if parsed_scale.is_none() {
2033                         if let Some(scale_node) = display_children.nodes().iter().find(|n| n.name().value() == "scale") {
2034                             if let Some(entry) = scale_node.entries().first() {
2035                                 if let Some(num) = entry.value().as_f64() {
2036                                     parsed_scale = Some(num);
2037                                 }
2038                             }
2039                         }
2040                     }
2041                     if parsed_size_mm.is_none() {
2042                         if let Some(size_node) = display_children.nodes().iter().find(|n| n.name().value() == "size_mm") {
2043                             if let Some(entry) = size_node.entries().first() {
2044                                 parsed_size_mm = entry.value().as_string().and_then(parse_size_mm);
2045                             }
2046                         }
2047                     }
2048                     if parsed_interval.is_none() {
2049                         if let Some(interval_node) = display_children.nodes().iter().find(|n| n.name().value() == "brightness_interval") {
2050                             if let Some(entry) = interval_node.entries().first() {
2051                                 if let Some(num) = entry.value().as_i64() {
2052                                     parsed_interval = Some(num);
2053                                 }
2054                             }
2055                         }
2056                     }
2057                     if parsed_up.is_none() {
2058                         if let Some(up_node) = display_children.nodes().iter().find(|n| n.name().value() == "brightness_up") {
2059                             if let Some(entry) = up_node.entries().first() {
2060                                 if let Some(key_val) = entry.value().as_string() {
2061                                     parsed_up = Some(key_val.to_string());
2062                                 }
2063                             }
2064                         }
2065                     }
2066                     if parsed_down.is_none() {
2067                         if let Some(down_node) = display_children.nodes().iter().find(|n| n.name().value() == "brightness_down") {
2068                             if let Some(entry) = down_node.entries().first() {
2069                                 if let Some(key_val) = entry.value().as_string() {
2070                                     parsed_down = Some(key_val.to_string());
2071                                 }
2072                             }
2073                         }
2074                     }
2075                 }
2076 
2077                 if let Some(num) = parsed_scale {
2078                     display.insert(format!("scale_{}", name), num);
2079                 }
2080                 if let Some((w, h)) = parsed_size_mm {
2081                     display.insert(format!("mm_w_{}", name), w);
2082                     display.insert(format!("mm_h_{}", name), h);
2083                 }
2084                 let interval = parsed_interval.unwrap_or(10);
2085                 if parsed_interval.is_some() {
2086                     display.insert(format!("brightness_interval_{}", name), interval as f64);
2087                 }
2088                 if let Some(key_val) = parsed_up {
2089                     key_bindings.push(KeybindConfig {
2090                         mods: "".to_string(),
2091                         key: key_val,
2092                         action: "spawn".to_string(),
2093                         command: Some(format!("brightnessctl set {}%+", interval)),
2094                     });
2095                 }
2096                 if let Some(key_val) = parsed_down {
2097                     key_bindings.push(KeybindConfig {
2098                         mods: "".to_string(),
2099                         key: key_val,
2100                         action: "spawn".to_string(),
2101                         command: Some(format!("brightnessctl set {}%-", interval)),
2102                     });
2103                 }
2104             }
2105         }
2106     }
2107 
2108     // 6. input
2109     let mut input = None;
2110     if let Some(node) = doc.nodes().iter().find(|n| n.name().value() == "input") {
2111         let accel_speed = get_child_arg_f64_opt(node, "accel_speed");
2112         let accel_profile = get_child_arg_string_opt(node, "accel_profile");
2113         let scroll_factor = get_child_arg_f64_opt(node, "scroll_factor");
2114         let scroll_ease = get_child_arg_f64_opt(node, "scroll_ease");
2115         let kinetic_scroll = get_child_arg_bool_opt(node, "kinetic_scroll");
2116         let scroll_friction = get_child_arg_f64_opt(node, "scroll_friction");
2117         let repeat_rate = get_child_arg_i64_opt(node, "repeat_rate");
2118         let repeat_delay = get_child_arg_i64_opt(node, "repeat_delay");
2119 
2120         let mut touchpad = None;
2121         if let Some(children) = node.children() {
2122             // `trackpad` is the input.kdl spelling, `touchpad` the legacy one.
2123             if let Some(tp_node) = children
2124                 .nodes()
2125                 .iter()
2126                 .find(|n| n.name().value() == "touchpad" || n.name().value() == "trackpad")
2127             {
2128                 let tap_to_click = get_child_arg_bool_opt(tp_node, "tap_to_click");
2129                 let natural_scroll = get_child_arg_bool_opt(tp_node, "natural_scroll");
2130                 let dwt = get_child_arg_bool_opt(tp_node, "dwt");
2131                 let dwtp = get_child_arg_bool_opt(tp_node, "dwtp");
2132 
2133                 let mut gestures = None;
2134                 if let Some(tp_children) = tp_node.children() {
2135                     if let Some(gestures_node) = tp_children.nodes().iter().find(|n| n.name().value() == "gestures") {
2136                         let swipe = get_child_arg_bool_opt(gestures_node, "swipe");
2137                         let pinch = get_child_arg_bool_opt(gestures_node, "pinch");
2138                         gestures = Some(GesturesConfig { swipe, pinch });
2139                     }
2140                 }
2141 
2142                 touchpad = Some(TouchpadConfig {
2143                     tap_to_click,
2144                     natural_scroll,
2145                     dwt,
2146                     dwtp,
2147                     gestures,
2148                     accel_speed: get_child_arg_f64_opt(tp_node, "accel_speed"),
2149                     accel_profile: get_child_arg_string_opt(tp_node, "accel_profile"),
2150                     scroll_factor: get_child_arg_f64_opt(tp_node, "scroll_factor"),
2151                 });
2152             }
2153         }
2154 
2155         let mut trackpoint = None;
2156         if let Some(children) = node.children() {
2157             if let Some(tp_node) = children.nodes().iter().find(|n| n.name().value() == "trackpoint") {
2158                 let accel_speed = get_child_arg_f64_opt(tp_node, "accel_speed");
2159                 let accel_profile = get_child_arg_string_opt(tp_node, "accel_profile");
2160                 trackpoint = Some(TrackpointConfig {
2161                     accel_speed,
2162                     accel_profile,
2163                     scroll_factor: get_child_arg_f64_opt(tp_node, "scroll_factor"),
2164                     scroll_method: get_child_arg_string_opt(tp_node, "scroll_method"),
2165                 });
2166             }
2167         }
2168 
2169         let mut mouse = None;
2170         if let Some(children) = node.children() {
2171             if let Some(m_node) = children.nodes().iter().find(|n| n.name().value() == "mouse") {
2172                 mouse = Some(MouseConfig {
2173                     accel_speed: get_child_arg_f64_opt(m_node, "accel_speed"),
2174                     accel_profile: get_child_arg_string_opt(m_node, "accel_profile"),
2175                     scroll_factor: get_child_arg_f64_opt(m_node, "scroll_factor"),
2176                     scroll_method: get_child_arg_string_opt(m_node, "scroll_method"),
2177                 });
2178             }
2179         }
2180 
2181         input = Some(InputConfig {
2182             accel_speed,
2183             accel_profile,
2184             scroll_factor,
2185             scroll_ease,
2186             kinetic_scroll,
2187             scroll_friction,
2188             repeat_rate,
2189             repeat_delay,
2190             mouse,
2191             touchpad,
2192             trackpoint,
2193         });
2194     }
2195 
2196     // 7. transparency
2197     let mut transparency = None;
2198     if let Some(node) = doc.nodes().iter().find(|n| n.name().value() == "transparency") {
2199         let opacity = get_child_arg_f64(node, "opacity", 0.9);
2200         transparency = Some(TransparencyConfig { opacity: Some(opacity) });
2201     }
2202 
2203     // 8. surface
2204     let mut surface = SurfaceConfig::default();
2205     let mut found_nested = false;
2206     if let Some(style_node) = doc.nodes().iter().find(|n| n.name().value() == "style") {
2207         if let Some(style_children) = style_node.children() {
2208             if let Some(surface_node) = style_children.nodes().iter().find(|n| n.name().value() == "surface") {
2209                 if let Some(surface_children) = surface_node.children() {
2210                     if let Some(desktop_node) = surface_children.nodes().iter().find(|n| n.name().value() == "desktop") {
2211                         found_nested = true;
2212                         for entry in desktop_node.entries() {
2213                             if let Some(id) = entry.name() {
2214                                 match id.value() {
2215                                     "gap_color" => {
2216                                         if let Some(val) = entry.value().as_string() {
2217                                             surface.desktop_gap_color = val.to_string();
2218                                         }
2219                                     }
2220                                     "cell_color" => {
2221                                         if let Some(val) = entry.value().as_string() {
2222                                             surface.desktop_cell_color = val.to_string();
2223                                         }
2224                                     }
2225                                     "gap_width" => {
2226                                         if let Some(val) = entry.value().as_i64() {
2227                                             surface.desktop_gap_width = val;
2228                                         }
2229                                     }
2230                                     "line_relief" => {
2231                                         if let Some(val) = entry.value().as_i64() {
2232                                             surface.desktop_line_relief = val;
2233                                         } else if let Some(s) = entry.value().as_string() {
2234                                             // A (relief) value: the fallback
2235                                             // honors its width — the client
2236                                             // installs the full material,
2237                                             // but the scenefx chamfer has no
2238                                             // custom profile to install.
2239                                             if let Some(spec) = cce_core::relief_spec::ReliefSpec::parse(s) {
2240                                                 surface.desktop_line_relief = spec.width.round() as i64;
2241                                             }
2242                                         }
2243                                     }
2244                                     "cell_fade_inset" => {
2245                                         if let Some(val) = entry.value().as_i64() {
2246                                             surface.desktop_cell_fade_inset = val;
2247                                         }
2248                                     }
2249                                     "cell_labels" => {
2250                                         if let Some(val) = entry.value().as_bool() {
2251                                             surface.desktop_cell_labels = val;
2252                                         }
2253                                     }
2254                                     "grid_fade_mode" => {
2255                                         if let Some(val) = entry.value().as_string() {
2256                                             surface.desktop_grid_fade_mode = val.to_string();
2257                                         }
2258                                     }
2259                                     "grid_cell_size" | "desktop_grid_scale" => {
2260                                         if let Some(val) = entry.value().as_i64() {
2261                                             surface.desktop_grid_scale = val;
2262                                         }
2263                                     }
2264                                     "grid_cell_width" => {
2265                                         if let Some(val) = entry.value().as_i64() {
2266                                             surface.grid_cell_width = Some(val);
2267                                         }
2268                                     }
2269                                     "grid_cell_height" => {
2270                                         if let Some(val) = entry.value().as_i64() {
2271                                             surface.grid_cell_height = Some(val);
2272                                         }
2273                                     }
2274                                     "snap" => {
2275                                         if let Some(val) = entry.value().as_bool() {
2276                                             surface.desktop_snap = val;
2277                                         }
2278                                     }
2279                                     "overview_ramp" => {
2280                                         if let Some(val) = entry.value().as_string() {
2281                                             surface.desktop_overview_ramp = val.to_string();
2282                                         }
2283                                     }
2284                                     "overview_ms" => {
2285                                         if let Some(val) = entry.value().as_i64() {
2286                                             surface.desktop_overview_ms = val;
2287                                         }
2288                                     }
2289                                     "snap_threshold" => {
2290                                         if let Some(val) = entry.value().as_i64() {
2291                                             surface.desktop_snap_threshold = val;
2292                                         }
2293                                     }
2294                                     "edge_pan" => {
2295                                         if let Some(val) = entry.value().as_bool() {
2296                                             surface.desktop_edge_pan = val;
2297                                         }
2298                                     }
2299                                     "edge_pan_band" => {
2300                                         if let Some(val) = entry.value().as_i64() {
2301                                             surface.desktop_edge_pan_band = val;
2302                                         }
2303                                     }
2304                                     "edge_pan_speed" => {
2305                                         if let Some(val) = entry.value().as_i64() {
2306                                             surface.desktop_edge_pan_speed = val;
2307                                         }
2308                                     }
2309                                     _ => {}
2310                                 }
2311                             }
2312                         }
2313                     }
2314                     // RFC Phase 7a (cce-ui): `plate { root ... }` is the one
2315                     // spelling of the root-plate style; the compositor reads
2316                     // the silhouette values from this block. The legacy
2317                     // `root plate` read-alias was removed 2026-09-06 after
2318                     // every live config had migrated.
2319                     let root_plate_node = surface_children
2320                         .nodes()
2321                         .iter()
2322                         .find(|n| n.name().value() == "plate")
2323                         .and_then(|n| n.children())
2324                         .and_then(|c| c.nodes().iter().find(|n| n.name().value() == "root"));
2325                     if let Some(root_node) = root_plate_node {
2326                         found_nested = true;
2327                         // Both spellings — properties on the `root` line and
2328                         // child nodes inside `root { … }` — see
2329                         // `node_keyed_values`. This block is the one the
2330                         // window silhouette comes from, and cce-ui reads the
2331                         // same keys either way; reading one spelling here put
2332                         // the compositor's clip and the apps' root plates on
2333                         // different radii.
2334                         for (key, entry) in node_keyed_values(root_node) {
2335                             match key.as_str() {
2336                                 "color" => {
2337                                     if let Some(val) = entry.value().as_string() {
2338                                         surface.root_plate_color = val.to_string();
2339                                     }
2340                                 }
2341                                 "blur" => {
2342                                     if let Some(mut val) = entry.value().as_f64() {
2343                                         if let Some(ty) = entry.ty() {
2344                                             let ty_str = ty.value();
2345                                             if ty_str.starts_with("f64:") {
2346                                                 let range_str = ty_str.trim_start_matches("f64:");
2347                                                 if let Some(dash_idx) = range_str.find('-') {
2348                                                     let min_str = &range_str[..dash_idx].trim();
2349                                                     let max_str = &range_str[dash_idx + 1..].trim();
2350                                                     if let (Ok(min_f), Ok(max_f)) = (min_str.parse::<f64>(), max_str.parse::<f64>()) {
2351                                                         val = val.clamp(min_f, max_f);
2352                                                     }
2353                                                 }
2354                                             }
2355                                         }
2356                                         surface.root_plate_blur = val;
2357                                     }
2358                                 }
2359                                 "corner_radius" => {
2360                                     if let Some(val) = entry.value().as_i64() {
2361                                         surface.root_plate_corner_radius = val;
2362                                     }
2363                                 }
2364                                 _ => {}
2365                             }
2366                         }
2367                     }
2368                     // `surface { fade in_ms=140 out_ms=120 }` — the DE-wide
2369                     // open/close dissolve, read here so both halves of it
2370                     // (the compositor's scene-node ramp and the deadline a
2371                     // closing client waits on) come from one place.
2372                     if let Some(fade_node) = surface_children.nodes().iter().find(|n| n.name().value() == "fade") {
2373                         found_nested = true;
2374                         for entry in fade_node.entries() {
2375                             if let Some(id) = entry.name() {
2376                                 match id.value() {
2377                                     "in_ms" => {
2378                                         if let Some(val) = entry.value().as_i64() {
2379                                             surface.fade_in_ms = val;
2380                                         }
2381                                     }
2382                                     "out_ms" => {
2383                                         if let Some(val) = entry.value().as_i64() {
2384                                             surface.fade_out_ms = val;
2385                                         }
2386                                     }
2387                                     _ => {}
2388                                 }
2389                             }
2390                         }
2391                     }
2392                     if let Some(border_node) = surface_children.nodes().iter().find(|n| n.name().value() == "border") {
2393                         found_nested = true;
2394                         for entry in border_node.entries() {
2395                             if let Some(id) = entry.name() {
2396                                 match id.value() {
2397                                     "width" => {
2398                                         if let Some(val) = entry.value().as_i64() {
2399                                             surface.border_width = val;
2400                                         }
2401                                     }
2402                                     "color" => {
2403                                         if let Some(val) = entry.value().as_string() {
2404                                             surface.border_color = val.to_string();
2405                                         }
2406                                     }
2407                                     "color_focused" => {
2408                                         if let Some(val) = entry.value().as_string() {
2409                                             surface.border_color_focused = Some(val.to_string());
2410                                         }
2411                                     }
2412                                     "color_hover" => {
2413                                         if let Some(val) = entry.value().as_string() {
2414                                             surface.border_color_hover = Some(val.to_string());
2415                                         }
2416                                     }
2417                                     // No `corner_radius` here: the border
2418                                     // ring, its discs and the frame take the
2419                                     // ONE window radius, `plate.root.
2420                                     // corner_radius` (see `Layout::
2421                                     // root_plate_corner_radius`). The key was
2422                                     // read into dead config until 2026-09-28
2423                                     // and is ignored now, like any unknown one.
2424                                     "segment_gap" => {
2425                                         if let Some(val) = entry.value().as_i64() {
2426                                             surface.border_segment_gap = val;
2427                                         }
2428                                     }
2429                                     "taper" => {
2430                                         if let Some(val) = entry.value().as_f64() {
2431                                             surface.border_taper = val;
2432                                         } else if let Some(val) = entry.value().as_i64() {
2433                                             surface.border_taper = val as f64;
2434                                         }
2435                                     }
2436                                     "handle_width" => {
2437                                         if let Some(val) = entry.value().as_f64() {
2438                                             surface.border_handle_width = val;
2439                                         } else if let Some(val) = entry.value().as_i64() {
2440                                             surface.border_handle_width = val as f64;
2441                                         }
2442                                     }
2443                                     "overlap_opacity" => {
2444                                         if let Some(val) = entry.value().as_f64() {
2445                                             surface.border_overlap_opacity = val;
2446                                         } else if let Some(val) = entry.value().as_i64() {
2447                                             surface.border_overlap_opacity = val as f64;
2448                                         }
2449                                     }
2450                                     "swell_curve" => {
2451                                         if let Some(val) = entry.value().as_f64() {
2452                                             surface.border_swell_curve = val;
2453                                         } else if let Some(val) = entry.value().as_i64() {
2454                                             surface.border_swell_curve = val as f64;
2455                                         }
2456                                     }
2457                                     "bulge" => {
2458                                         if let Some(val) = entry.value().as_f64() {
2459                                             surface.border_corner_bulge = val;
2460                                         } else if let Some(val) = entry.value().as_i64() {
2461                                             surface.border_corner_bulge = val as f64;
2462                                         }
2463                                     }
2464                                     "corner_length" => {
2465                                         if let Some(val) = entry.value().as_i64() {
2466                                             surface.border_corner_length = val;
2467                                         }
2468                                     }
2469                                     _ => {}
2470                                 }
2471                             }
2472                         }
2473                     }
2474                     if let Some(bevel_node) = surface_children.nodes().iter().find(|n| n.name().value() == "bevel") {
2475                         found_nested = true;
2476                         for entry in bevel_node.entries() {
2477                             if let Some(id) = entry.name() {
2478                                 match id.value() {
2479                                     "enabled" => {
2480                                         if let Some(val) = entry.value().as_bool() {
2481                                             surface.bevel_enabled = val;
2482                                         }
2483                                     }
2484                                     "thickness" => {
2485                                         if let Some(val) = entry.value().as_f64() {
2486                                             surface.bevel_thickness = val;
2487                                         } else if let Some(val) = entry.value().as_i64() {
2488                                             surface.bevel_thickness = val as f64;
2489                                         }
2490                                     }
2491                                     "light" => {
2492                                         if let Some(val) = entry.value().as_string() {
2493                                             surface.bevel_light = val.to_string();
2494                                         }
2495                                     }
2496                                     "light_intensity" => {
2497                                         if let Some(val) = entry.value().as_f64() {
2498                                             surface.bevel_light_intensity = val;
2499                                         }
2500                                     }
2501                                     "shade_intensity" => {
2502                                         if let Some(val) = entry.value().as_f64() {
2503                                             surface.bevel_shade_intensity = val;
2504                                         }
2505                                     }
2506                                     "shoulder" => {
2507                                         if let Some(val) = entry.value().as_f64() {
2508                                             surface.bevel_shoulder = val;
2509                                         }
2510                                     }
2511                                     "color" => {
2512                                         if let Some(val) = entry.value().as_string() {
2513                                             surface.bevel_color = val.to_string();
2514                                         }
2515                                     }
2516                                     "focus_sharpness" => {
2517                                         if let Some(val) = entry.value().as_f64() {
2518                                             surface.bevel_focus_sharpness = val;
2519                                         } else if let Some(val) = entry.value().as_i64() {
2520                                             surface.bevel_focus_sharpness = val as f64;
2521                                         }
2522                                     }
2523                                     "focus_color" => {
2524                                         if let Some(val) = entry.value().as_string() {
2525                                             surface.bevel_focus_color = val.to_string();
2526                                         }
2527                                     }
2528                                     _ => {}
2529                                 }
2530                             }
2531                         }
2532                     }
2533                     if let Some(shadow_node) = surface_children.nodes().iter().find(|n| n.name().value() == "shadow") {
2534                         found_nested = true;
2535                         for entry in shadow_node.entries() {
2536                             if let Some(id) = entry.name() {
2537                                 match id.value() {
2538                                     "enabled" => {
2539                                         if let Some(val) = entry.value().as_bool() {
2540                                             surface.shadow_enabled = val;
2541                                         }
2542                                     }
2543                                     // Named `blur` to match the status/root plate blur keys.
2544                                     "blur" => {
2545                                         if let Some(val) = entry.value().as_f64() {
2546                                             surface.shadow_sigma = val;
2547                                         } else if let Some(val) = entry.value().as_i64() {
2548                                             surface.shadow_sigma = val as f64;
2549                                         }
2550                                     }
2551                                     "color" => {
2552                                         if let Some(val) = entry.value().as_string() {
2553                                             surface.shadow_color = val.to_string();
2554                                         }
2555                                     }
2556                                     "offset_x" => {
2557                                         if let Some(val) = entry.value().as_i64() {
2558                                             surface.shadow_offset_x = val;
2559                                         }
2560                                     }
2561                                     "offset_y" => {
2562                                         if let Some(val) = entry.value().as_i64() {
2563                                             surface.shadow_offset_y = val;
2564                                         }
2565                                     }
2566                                     "tiled" => {
2567                                         if let Some(val) = entry.value().as_bool() {
2568                                             surface.shadow_tiled = val;
2569                                         }
2570                                     }
2571                                     _ => {}
2572                                 }
2573                             }
2574                         }
2575                     }
2576                     if let Some(cloud_node) = surface_children.nodes().iter().find(|n| n.name().value() == "cloud") {
2577                         found_nested = true;
2578                         if let Some(pos) = get_child_arg_vec2i_opt(cloud_node, "position_default") {
2579                             surface.cloud_position_default = Some(pos);
2580                         }
2581                     }
2582                 }
2583             }
2584         }
2585     }
2586     if !found_nested {
2587         if let Some(node) = doc.nodes().iter().find(|n| n.name().value() == "surface") {
2588             surface.desktop_gap_color = get_child_arg_string(node, "desktop_gap_color", &default_desktop_gap_color());
2589             surface.desktop_cell_color = get_child_arg_string(node, "desktop_cell_color", &default_desktop_cell_color());
2590             surface.desktop_grid_scale = get_child_arg_i64(node, "grid_cell_size", get_child_arg_i64(node, "desktop_grid_scale", default_desktop_grid_scale()));
2591             surface.grid_cell_width = match get_child_arg_i64(node, "grid_cell_width", i64::MIN) {
2592                 i64::MIN => None,
2593                 v => Some(v),
2594             };
2595             surface.grid_cell_height = match get_child_arg_i64(node, "grid_cell_height", i64::MIN) {
2596                 i64::MIN => None,
2597                 v => Some(v),
2598             };
2599             surface.desktop_gap_width = get_child_arg_i64(node, "desktop_gap_width", default_desktop_gap_width());
2600             surface.desktop_cell_fade_inset = get_child_arg_i64(node, "desktop_cell_fade_inset", default_desktop_cell_fade_inset());
2601             surface.desktop_grid_fade_mode = get_child_arg_string(node, "grid_fade_mode", &default_desktop_grid_fade_mode());
2602             surface.desktop_snap = get_child_arg_bool(node, "desktop_snap", default_desktop_snap());
2603             surface.desktop_overview_ramp = get_child_arg_string(node, "desktop_overview_ramp", "");
2604             surface.desktop_overview_ms = get_child_arg_i64(node, "desktop_overview_ms", default_desktop_overview_ms());
2605             surface.desktop_snap_threshold = get_child_arg_i64(node, "desktop_snap_threshold", default_desktop_snap_threshold());
2606             surface.desktop_edge_pan = get_child_arg_bool(node, "desktop_edge_pan", default_desktop_edge_pan());
2607             surface.desktop_edge_pan_band = get_child_arg_i64(node, "desktop_edge_pan_band", default_desktop_edge_pan_band());
2608             surface.desktop_edge_pan_speed = get_child_arg_i64(node, "desktop_edge_pan_speed", default_desktop_edge_pan_speed());
2609             surface.root_plate_color = get_child_arg_string(node, "root_plate_color", &default_root_plate_color());
2610             surface.root_plate_blur = get_child_arg_f64(node, "root_plate_blur", default_root_plate_blur());
2611             surface.root_plate_corner_radius = get_child_arg_i64(node, "root_plate_corner_radius", default_root_plate_corner_radius());
2612             surface.border_width = get_child_arg_i64(node, "border_width", default_border_width());
2613             surface.border_color = get_child_arg_string(node, "border_color", &default_border_color());
2614             surface.border_color_focused = get_child_arg_string_opt(node, "border_color_focused");
2615             surface.border_color_hover = get_child_arg_string_opt(node, "border_color_hover");
2616             surface.border_segment_gap = get_child_arg_i64(node, "border_segment_gap", default_border_segment_gap());
2617             surface.border_corner_length = get_child_arg_i64(node, "border_corner_length", 0);
2618             surface.cloud_position_default = get_child_arg_vec2i_opt(node, "cloud_position_default");
2619         }
2620     }
2621 
2622     // window manager
2623     let mut window_manager = None;
2624     if let Some(node) = doc.nodes().iter().find(|n| n.name().value() == "window manager" || n.name().value() == "window_manager") {
2625         let close_window = get_child_arg_string_opt(node, "close_window");
2626         let toggle_fullscreen = get_child_arg_string_opt(node, "toggle_fullscreen");
2627         let toggle_overview = get_child_arg_string_opt(node, "toggle_overview");
2628         let window_switcher = get_child_arg_string_opt(node, "window_switcher");
2629         let window_switcher_prev = get_child_arg_string_opt(node, "window_switcher_prev");
2630         let center_on_spawn = get_child_arg_bool_opt(node, "center_on_spawn");
2631         let on_app_exit = get_child_arg_string_opt(node, "on_app_exit");
2632         let corner_shape = get_child_arg_f64_opt(node, "corner_shape");
2633         let rounded_apps = get_child_args_string_vec_opt(node, "rounded_apps");
2634         let bevel_apps = get_child_args_string_vec_opt(node, "bevel_apps");
2635         let xwayland_hidpi = get_child_arg_bool_opt(node, "xwayland_hidpi");
2636         let xwayland_hidpi_except = get_child_args_string_vec_opt(node, "xwayland_hidpi_except");
2637         let touchpad_view_apps = get_child_args_string_vec_opt(node, "touchpad_view_apps");
2638         let touchpad_view_swipe = get_child_arg_string_opt(node, "touchpad_view_swipe");
2639         let touchpad_view_sensitivity = get_child_arg_f64_opt(node, "touchpad_view_sensitivity");
2640         let swipe_peek = get_child_arg_f64_opt(node, "swipe_peek");
2641         let swipe_repeat_peek = get_child_arg_f64_opt(node, "swipe_repeat_peek");
2642         let swipe_focus_cone = get_child_arg_f64_opt(node, "swipe_focus_cone");
2643         let swipe_threshold = get_child_arg_f64_opt(node, "swipe_threshold");
2644         let swipe_repeat_threshold = get_child_arg_f64_opt(node, "swipe_repeat_threshold");
2645         let touchpad_view_invert = get_child_arg_bool_opt(node, "touchpad_view_invert");
2646         let touchpad_hscroll_shift_apps = get_child_args_string_vec_opt(node, "touchpad_hscroll_shift_apps");
2647         let osk_on_touch = get_child_arg_bool_opt(node, "osk_on_touch");
2648         window_manager = Some(WindowManagerConfig { close_window, toggle_fullscreen, toggle_overview, window_switcher, window_switcher_prev, center_on_spawn, on_app_exit, corner_shape, rounded_apps, bevel_apps, xwayland_hidpi, xwayland_hidpi_except, touchpad_view_apps, touchpad_view_swipe, touchpad_view_sensitivity, swipe_peek, swipe_repeat_peek, swipe_focus_cone, swipe_threshold, swipe_repeat_threshold, touchpad_view_invert, touchpad_hscroll_shift_apps, osk_on_touch });
2649     }
2650 
2651     Ok(Config {
2652         layout,
2653         env,
2654         key_bindings,
2655         pointer_bind,
2656         mode_rule,
2657         tag_layout,
2658         startup,
2659         output,
2660         display,
2661         device,
2662         input,
2663         gesture_bind,
2664         transparency,
2665         surface,
2666         window_manager,
2667         idle,
2668     })
2669 }
2670 
2671 pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager) -> Result<(), String> {
2672     let content = match fs::read_to_string(path) {
2673         Ok(c) => c,
2674         Err(e) => return Err(format!("cannot open {}: {}", path, e)),
2675     };
2676 
2677     let mut config: Config = parse_kdl_config(&content)?;
2678 
2679     let path_buf = std::path::Path::new(path);
2680     let input_path = path_buf.parent().unwrap_or_else(|| std::path::Path::new(".")).join("input.kdl");
2681     let mut wm_domain_entries: Vec<cce_core::input::BindingEntry> = Vec::new();
2682     if input_path.exists() {
2683         if let Ok(input_content) = fs::read_to_string(&input_path) {
2684             // New domain-scoped format: a `cce-window-manager { ... }` block
2685             // of `<action_name> "<chord>"` bindings. Other domains belong to
2686             // clients/widgets and are ignored here.
2687             match cce_core::input::InputConfig::parse(&input_content) {
2688                 Ok(ic) => {
2689                     wm_domain_entries = ic.domain(cce_core::input::WINDOW_MANAGER_DOMAIN).to_vec();
2690                 }
2691                 Err(e) => eprintln!("[WARNING] {}: {}", input_path.display(), e),
2692             }
2693             // Legacy input.kdl contents: root-level key_bindings nodes and
2694             // the input section.
2695             if let Ok(input_config) = parse_kdl_config(&input_content) {
2696                 config.key_bindings.extend(input_config.key_bindings);
2697                 if input_config.input.is_some() {
2698                     config.input = input_config.input;
2699                 }
2700             }
2701         }
2702     }
2703 
2704     state.output_scale = 1.0f32;
2705     state.xwayland_hidpi = config
2706         .window_manager
2707         .as_ref()
2708         .and_then(|wm| wm.xwayland_hidpi)
2709         .unwrap_or(true);
2710     state.xwayland_hidpi_except = config
2711         .window_manager
2712         .as_ref()
2713         .and_then(|wm| wm.xwayland_hidpi_except.clone())
2714         .unwrap_or_default();
2715     {
2716         let tv = config.window_manager.as_ref();
2717         state.touchpad_view_apps = tv.and_then(|w| w.touchpad_view_apps.clone()).unwrap_or_default();
2718         state.touchpad_view_swipe_tumble = tv
2719             .and_then(|w| w.touchpad_view_swipe.as_deref())
2720             .map_or(false, |s| s.eq_ignore_ascii_case("tumble"));
2721         state.touchpad_view_sensitivity = tv.and_then(|w| w.touchpad_view_sensitivity).unwrap_or(1.0);
2722         state.swipe_peek_px = tv
2723             .and_then(|w| w.swipe_peek)
2724             .filter(|v| v.is_finite() && *v >= 0.0)
2725             .unwrap_or(60.0);
2726         state.swipe_repeat_peek_px = tv
2727             .and_then(|w| w.swipe_repeat_peek)
2728             .filter(|v| v.is_finite() && *v >= 0.0)
2729             .unwrap_or(state.swipe_peek_px * 0.5);
2730         state.swipe_focus_cone_deg = tv
2731             .and_then(|w| w.swipe_focus_cone)
2732             .filter(|v| v.is_finite())
2733             .map_or(45.0, |v| v.clamp(1.0, 89.0));
2734         state.swipe_threshold = tv
2735             .and_then(|w| w.swipe_threshold)
2736             .filter(|v| v.is_finite() && *v > 0.0)
2737             .unwrap_or(70.0);
2738         state.swipe_repeat_threshold = tv
2739             .and_then(|w| w.swipe_repeat_threshold)
2740             .filter(|v| v.is_finite() && *v > 0.0)
2741             .unwrap_or(state.swipe_threshold * 4.0);
2742         state.touchpad_view_invert = tv.and_then(|w| w.touchpad_view_invert).unwrap_or(false);
2743         state.touchpad_hscroll_shift_apps = tv.and_then(|w| w.touchpad_hscroll_shift_apps.clone()).unwrap_or_default();
2744         state.osk_on_touch = tv.and_then(|w| w.osk_on_touch).unwrap_or(true);
2745     }
2746     state.display = config.display.clone();
2747     state.on_app_exit = config
2748         .window_manager
2749         .as_ref()
2750         .and_then(|wm| wm.on_app_exit.as_deref())
2751         .map(parse_on_app_exit)
2752         .unwrap_or(OnAppExit::FocusPrevious);
2753     state.center_on_spawn = config
2754         .window_manager
2755         .as_ref()
2756         .and_then(|wm| wm.center_on_spawn)
2757         .unwrap_or(true);
2758     state.rounded_apps = config
2759         .window_manager
2760         .as_ref()
2761         .and_then(|wm| wm.rounded_apps.clone())
2762         .unwrap_or_default();
2763     // Unset means "same apps as rounded_apps" — which deliberately excludes
2764     // the implicit cce-* set, since those draw their own bevels.
2765     state.bevel_apps = config
2766         .window_manager
2767         .as_ref()
2768         .and_then(|wm| wm.bevel_apps.clone())
2769         .unwrap_or_else(|| state.rounded_apps.clone());
2770 
2771     // Feed scenefx's rounded-corner shaders the DE-wide corner-shape exponent
2772     // (clamped like cce-ui's corner_shape()). Plain C state, safe pre-renderer
2773     // and on live reload.
2774     let corner_shape = config
2775         .window_manager
2776         .as_ref()
2777         .and_then(|wm| wm.corner_shape)
2778         .unwrap_or(2.0)
2779         .clamp(2.0, 16.0);
2780     unsafe {
2781         crate::ffi::fx_renderer_set_corner_shape(corner_shape as f32);
2782     }
2783     let span_factor = if corner_shape > 2.001 {
2784         (corner_shape - 1.0) * 2f64.powf(1.0 / corner_shape) / std::f64::consts::SQRT_2
2785     } else {
2786         1.0
2787     };
2788     CORNER_SPAN_FACTOR.store(span_factor.to_bits(), std::sync::atomic::Ordering::Relaxed);
2789 
2790     state.layout.gap = config.layout.gap as i32;
2791     state.layout.gap_top = config.layout.gap_top as i32;
2792     state.layout.gap_left = config.layout.gap_left as i32;
2793     state.layout.gap_right = config.layout.gap_right as i32;
2794     state.layout.gap_bottom = config.layout.gap_bottom as i32;
2795     state.layout.cascade_offset = config.layout.cascade_offset as i32;
2796     state.layout.bar_height = config.layout.bar_height as i32;
2797     state.layout.border_width = config.surface.border_width as i32;
2798     state.layout.fullscreen_border_width = 0;
2799     state.layout.cascade_border_width = 0;
2800     state.layout.grid_border_width = 0;
2801     state.layout.floating_border_width = 0;
2802 
2803     state.layout.border_color = parse_hex_color_rgba(&config.surface.border_color);
2804     state.layout.border_color_focused = config
2805         .surface
2806         .border_color_focused
2807         .as_deref()
2808         .map(parse_hex_color_rgba)
2809         .unwrap_or(state.layout.border_color);
2810     state.layout.border_color_hover = config
2811         .surface
2812         .border_color_hover
2813         .as_deref()
2814         .map(parse_hex_color_rgba)
2815         .unwrap_or_else(|| lighten_premultiplied(state.layout.border_color_focused, HOVER_LIGHTEN));
2816     state.layout.border_segment_gap = config.surface.border_segment_gap.max(0) as i32;
2817     // Clamped at 1: past that the corners would be THICKER than the middle,
2818     // which is the moulding inside out.
2819     state.layout.border_taper = config.surface.border_taper.clamp(0.05, 1.0) as f32;
2820     state.layout.border_handle_width = config.surface.border_handle_width.max(4.0) as f32;
2821     state.layout.border_overlap_opacity = config.surface.border_overlap_opacity.clamp(0.0, 1.0) as f32;
2822     // Capped at 2s: the close fade is a deadline a client blocks on before it
2823     // exits, so a mistyped 20000 would hang every quit for 20 seconds.
2824     state.layout.fade_in_ms = config.surface.fade_in_ms.clamp(0, 2000) as u32;
2825     state.layout.fade_out_ms = config.surface.fade_out_ms.clamp(0, 2000) as u32;
2826     state.layout.border_swell_curve = config.surface.border_swell_curve.clamp(0.1, 6.0) as f32;
2827     state.layout.border_corner_bulge = config.surface.border_corner_bulge.max(0.0) as f32;
2828     state.layout.border_corner_length = config.surface.border_corner_length.max(0) as i32;
2829 
2830     state.layout.desktop_gap_color = config.surface.desktop_gap_color.clone();
2831 
2832     let background_color_val = parse_hex_color(&config.surface.desktop_gap_color);
2833     state.layout.background_r = ((background_color_val >> 16) & 0xFF) * 0x01010101;
2834     state.layout.background_g = ((background_color_val >> 8) & 0xFF) * 0x01010101;
2835     state.layout.background_b = (background_color_val & 0xFF) * 0x01010101;
2836     state.layout.background_a = 0xFFFFFFFF;
2837 
2838     state.layout.desktop_cell_color = parse_hex_color_rgba(&config.surface.desktop_cell_color);
2839     state.layout.desktop_cell_width =
2840         config.surface.grid_cell_width.unwrap_or(config.surface.desktop_grid_scale) as f64;
2841     state.layout.desktop_cell_height =
2842         config.surface.grid_cell_height.unwrap_or(config.surface.desktop_grid_scale) as f64;
2843     state.layout.desktop_snap = config.surface.desktop_snap;
2844     state.layout.overview_anim = if config.surface.desktop_overview_ramp.is_empty() {
2845         None
2846     } else {
2847         match crate::policy::ramp::SpeedRamp::from_spec(&config.surface.desktop_overview_ramp) {
2848             Some(ramp) => Some((ramp, (config.surface.desktop_overview_ms.max(16)) as f64)),
2849             None => {
2850                 log::warn!("overview_ramp {:?} is invalid or all-zero; falling back to the exponential camera animation", config.surface.desktop_overview_ramp);
2851                 None
2852             }
2853         }
2854     };
2855     state.layout.desktop_snap_threshold = config.surface.desktop_snap_threshold.max(0) as f64;
2856     state.layout.desktop_edge_pan = config.surface.desktop_edge_pan;
2857     state.layout.desktop_edge_pan_band = config.surface.desktop_edge_pan_band.max(1) as f64;
2858     state.layout.desktop_edge_pan_speed = config.surface.desktop_edge_pan_speed.max(0) as f64;
2859     state.layout.desktop_gap_width = config.surface.desktop_gap_width as i32;
2860     state.layout.desktop_line_relief = if config.surface.desktop_line_relief < 0 {
2861         None
2862     } else {
2863         Some(config.surface.desktop_line_relief as f64)
2864     };
2865     state.layout.desktop_cell_fade_inset = config.surface.desktop_cell_fade_inset;
2866     state.layout.desktop_cell_labels = config.surface.desktop_cell_labels;
2867     state.layout.desktop_grid_fade_mode = config.surface.desktop_grid_fade_mode.clone();
2868 
2869     state.layout.border_font_size = 11;
2870     state.layout.transition_duration = config.layout.transition_duration as i32;
2871     state.layout.grid_gap = config.layout.grid_gap as i32;
2872     state.layout.border_blur = false;
2873     state.layout.window_blur = config.surface.root_plate_blur > 0.001;
2874     state.layout.root_plate_corner_radius = config.surface.root_plate_corner_radius as i32;
2875     state.layout.overlay_behavior = config.layout.overlay_behavior;
2876     state.layout.overlay_width = config.layout.overlay_width as i32;
2877     state.layout.overlay_position = config.layout.overlay_position;
2878     state.layout.overlay_border_gap = config.layout.overlay_border_gap as i32;
2879     state.layout.status_normal_color = config.layout.status_normal_color.clone();
2880     state.layout.status_background_blur = config.layout.status_background_blur as f32;
2881     state.layout.transparency_opacity = config.transparency.as_ref().and_then(|t| t.opacity).unwrap_or(0.9) as f32;
2882     let root_plate_rgba = parse_hex_color_rgba(&config.surface.root_plate_color);
2883     state.layout.window_opacity = root_plate_rgba[3] < 0.999;
2884     state.layout.scenefx_optimized_blur = config.output.as_ref().map(|o| o.scenefx_optimized_blur).unwrap_or(true);
2885     // Idle timeouts live on the server, not the window manager; a reload
2886     // re-arms them from now with the new figures.
2887     if !state.server.is_null() {
2888         unsafe { (*state.server).idle.configure(&config.idle); }
2889     }
2890     state.layout.status_backdrop_blur_ignore_transparent = config.layout.status_backdrop_blur_ignore_transparent;
2891     state.layout.window_backdrop_blur_ignore_transparent = config.layout.window_backdrop_blur_ignore_transparent;
2892     state.layout.status_module_hide_mode_preview = config.layout.status_module_hide_mode_preview;
2893     state.layout.status_module_spacing = config.layout.status_module_spacing;
2894     state.layout.status_droplet = config.layout.status_droplet.clone();
2895     state.layout.status_backdrop_compress = config.layout.status_backdrop_compress;
2896     state.layout.cloud_position_default = config.surface.cloud_position_default;
2897     state.layout.shadow_enabled = config.surface.shadow_enabled;
2898     state.layout.shadow_sigma = config.surface.shadow_sigma.max(0.0) as f32;
2899     state.layout.shadow_color = parse_hex_color_rgba(&config.surface.shadow_color);
2900     state.layout.shadow_offset_x = config.surface.shadow_offset_x as i32;
2901     state.layout.shadow_offset_y = config.surface.shadow_offset_y as i32;
2902     state.layout.shadow_tiled = config.surface.shadow_tiled;
2903     state.layout.bevel_enabled = config.surface.bevel_enabled;
2904     state.layout.bevel_thickness = config.surface.bevel_thickness.max(0.0) as f32;
2905     let (bevel_lx, bevel_ly) = parse_light_direction(&config.surface.bevel_light);
2906     state.layout.bevel_light_x = bevel_lx;
2907     state.layout.bevel_light_y = bevel_ly;
2908     state.layout.bevel_light_intensity = config.surface.bevel_light_intensity.clamp(0.0, 1.0) as f32;
2909     state.layout.bevel_shade_intensity = config.surface.bevel_shade_intensity.clamp(0.0, 1.0) as f32;
2910     state.layout.bevel_shoulder = config.surface.bevel_shoulder.clamp(0.0, 1.0) as f32;
2911     state.layout.bevel_color = parse_hex_color_rgba(&config.surface.bevel_color);
2912     let fc = parse_hex_color_rgba(&config.surface.bevel_focus_color);
2913     state.layout.bevel_focus_color = [fc[0], fc[1], fc[2]];
2914     // Clamped low at 1: below that the glint would spread WIDER than the
2915     // rim's own slope, which is what `thickness` is for.
2916     state.layout.bevel_focus_sharpness =
2917         config.surface.bevel_focus_sharpness.clamp(1.0, 64.0) as f32;
2918 
2919     for (key, val) in &config.env {
2920         let expanded = expand_env_vars(val);
2921         std::env::set_var(key, &expanded);
2922     }
2923 
2924     state.input_rules = config.device.clone();
2925     state.input_config = config.input.clone().unwrap_or_default();
2926     unsafe {
2927         // Config first (its per-class scroll factors are defaults), then the
2928         // name-based device rules so they stay the most specific override.
2929         state.apply_input_config();
2930         state.apply_input_rules();
2931     }
2932 
2933     state.keybinds.clear();
2934     let mut table = cce_window_manager::bindings::BindingTable::new();
2935     // Gesture entries from the same domain (`focus_left "swipe3_left"`);
2936     // they go ahead of config.kdl's `gesture_bind` nodes below.
2937     let mut input_gesture_binds: Vec<GestureBind> = Vec::new();
2938 
2939     // Primary source: the `cce-window-manager` domain of input.kdl.
2940     for entry in &wm_domain_entries {
2941         let Some(action) = Action::from_name(&entry.name) else {
2942             eprintln!("[WARNING] input.kdl: unknown window-manager action {:?}", entry.name);
2943             continue;
2944         };
2945         let command = match action {
2946             Action::Spawn | Action::Toggle => {
2947                 warn_if_command_missing(entry.command.as_deref());
2948                 entry.command.clone()
2949             }
2950             // Media-key actions have stock commands (policy-side
2951             // `media_command`); a `command=` property overrides.
2952             Action::VolumeUp | Action::VolumeDown | Action::VolumeMute | Action::MicMute
2953             | Action::BrightnessUp | Action::BrightnessDown => {
2954                 warn_if_command_missing(entry.command.as_deref());
2955                 entry.command.clone()
2956             }
2957             _ => None,
2958         };
2959         // A touchscreen edge swipe: `edge_left` fires on a finger swiped in
2960         // from the left edge (`touch::Claim::Edge`).
2961         if let Some((mods, edge)) = parse_edge_gesture(&entry.chord) {
2962             if input_gesture_binds.iter().any(|b| b.mods == mods && b.gesture_type == "edge" && b.direction == edge) {
2963                 eprintln!("[WARNING] input.kdl: {:?} is bound more than once", entry.chord);
2964             }
2965             input_gesture_binds.push(GestureBind {
2966                 mods,
2967                 gesture_type: "edge".to_string(),
2968                 fingers: 1,
2969                 direction: edge,
2970                 action,
2971                 command: command.clone(),
2972             });
2973             continue;
2974         }
2975         // A touchpad gesture rides in the chord slot: `swipe3_left`,
2976         // `super+pinch_out`. The fingerless spelling binds three AND four
2977         // fingers, as `toggle_overview "swipe_down"` always has.
2978         if let Some(g) = cce_window_manager::bindings::parse_gesture(&entry.chord) {
2979             let fingers: Vec<u32> = g.fingers.map(|n| vec![n]).unwrap_or_else(|| vec![3, 4]);
2980             for fingers in fingers {
2981                 let dup = input_gesture_binds.iter().any(|b| {
2982                     b.mods == g.mods && b.gesture_type == g.kind.as_str() && b.fingers == fingers && b.direction == g.direction
2983                 });
2984                 if dup {
2985                     eprintln!("[WARNING] input.kdl: {:?} is bound more than once", entry.chord);
2986                 }
2987                 input_gesture_binds.push(GestureBind {
2988                     mods: g.mods,
2989                     gesture_type: g.kind.as_str().to_string(),
2990                     fingers,
2991                     direction: g.direction.clone(),
2992                     action,
2993                     command: command.clone(),
2994                 });
2995             }
2996             continue;
2997         }
2998         let Some(chord) = cce_window_manager::bindings::parse_chord(&entry.chord) else {
2999             eprintln!("[WARNING] input.kdl: invalid chord {:?} for {}", entry.chord, entry.name);
3000             continue;
3001         };
3002         let keysym = parse_keysym(&chord.key);
3003         if keysym == 0 {
3004             eprintln!("[WARNING] input.kdl: unknown key {:?} in chord {:?}", chord.key, entry.chord);
3005             continue;
3006         }
3007         if table.add(Keybind { mods: chord.mods, keysym, action, command }) {
3008             eprintln!("[WARNING] input.kdl: {:?} is bound more than once", entry.chord);
3009         }
3010     }
3011 
3012     // Legacy sources: config.kdl `key_bindings` nodes (including the
3013     // synthesized brightness binds) and the `window_manager` section.
3014     // input.kdl wins on chord conflicts via add_default.
3015     let mut seen = std::collections::HashSet::new();
3016     for kb in &config.key_bindings {
3017         let (mods_str, key_str) = if kb.mods.is_empty() {
3018             if let Some(last_plus) = kb.key.rfind('+') {
3019                 (kb.key[..last_plus].to_string(), kb.key[last_plus+1..].to_string())
3020             } else {
3021                 ("".to_string(), kb.key.clone())
3022             }
3023         } else {
3024             (kb.mods.clone(), kb.key.clone())
3025         };
3026         let mods = parse_modifiers(&mods_str);
3027         let keysym = parse_keysym(&key_str);
3028 
3029         if !seen.insert((mods, keysym)) {
3030             eprintln!("[WARNING] Keybinding conflict: multiple actions mapped to mods={:?}, key={:?}", mods_str, key_str);
3031         }
3032 
3033         let action = parse_action(&kb.action);
3034         let command = if action == Action::Spawn || action == Action::Toggle {
3035             warn_if_command_missing(kb.command.as_deref());
3036             kb.command.clone()
3037         } else {
3038             None
3039         };
3040         table.add_default(Keybind { mods, keysym, action, command });
3041     }
3042 
3043     if let Some(ref wm_config) = config.window_manager {
3044         let wm_section_binds = [
3045             (&wm_config.close_window, Action::Close),
3046             (&wm_config.toggle_fullscreen, Action::Fullscreen),
3047             (&wm_config.window_switcher, Action::WindowSwitcher),
3048             (&wm_config.window_switcher_prev, Action::WindowSwitcherPrev),
3049         ];
3050         for (chord_str, action) in wm_section_binds {
3051             let Some(chord_str) = chord_str else { continue };
3052             if let Some(chord) = cce_window_manager::bindings::parse_chord(chord_str) {
3053                 let keysym = parse_keysym(&chord.key);
3054                 table.add_default(Keybind { mods: chord.mods, keysym, action, command: None });
3055             }
3056         }
3057     }
3058 
3059     // Stock defaults from the policy crate; never shadow configured chords.
3060     for d in cce_window_manager::bindings::DEFAULT_BINDINGS {
3061         let keysym = parse_keysym(d.key);
3062         table.add_default(Keybind { mods: d.mods, keysym, action: d.action, command: None });
3063     }
3064 
3065     state.keybinds = table.into_bindings();
3066 
3067     state.pointer_binds.clear();
3068     for pb in &config.pointer_bind {
3069         let mods = parse_modifiers(&pb.mods);
3070         let button = parse_button(&pb.button);
3071         let action = parse_action(&pb.action);
3072         state.pointer_binds.push(PointerBind {
3073             mods,
3074             button,
3075             action,
3076         });
3077     }
3078 
3079     // Gesture table, first match wins in the cursor's swipe/pinch handlers:
3080     // input.kdl entries, then config.kdl `gesture_bind` nodes, then the
3081     // legacy `window_manager { toggle_overview "swipe_down" }`.
3082     state.gesture_binds.clear();
3083     state.gesture_binds.extend(input_gesture_binds);
3084     for gb in &config.gesture_bind {
3085         let mods = gb.mods.as_ref().map(|m| parse_modifiers(m)).unwrap_or(0);
3086         let action = parse_action(&gb.action);
3087         let command = if action == Action::Spawn || action == Action::Toggle {
3088             gb.command.clone()
3089         } else {
3090             None
3091         };
3092         state.gesture_binds.push(GestureBind {
3093             mods,
3094             gesture_type: gb.gesture_type.clone(),
3095             fingers: gb.fingers,
3096             direction: gb.direction.clone(),
3097             action,
3098             command,
3099         });
3100     }
3101 
3102     if let Some(ref wm_config) = config.window_manager {
3103         if let Some(ref toggle_ov_str) = wm_config.toggle_overview {
3104             let normalized = toggle_ov_str.to_lowercase().replace('-', "_");
3105             let gesture_type = if normalized.starts_with("swipe") {
3106                 Some("swipe")
3107             } else if normalized.starts_with("pinch") {
3108                 Some("pinch")
3109             } else {
3110                 None
3111             };
3112             if let Some(g_type) = gesture_type {
3113                 let direction = normalized.trim_start_matches(g_type).trim_start_matches('_').to_string();
3114                 for fingers in [3, 4] {
3115                     state.gesture_binds.push(GestureBind {
3116                         mods: 0,
3117                         gesture_type: g_type.to_string(),
3118                         fingers,
3119                         direction: direction.clone(),
3120                         action: Action::Overview,
3121                         command: None,
3122                     });
3123                 }
3124             }
3125         }
3126     }
3127 
3128     state.mode_rules.clear();
3129     for rule in config.mode_rule {
3130         state.mode_rules.push(ModeRule {
3131             mode: parse_tiling_mode(&rule.mode),
3132             app_id_pattern: rule.app_id,
3133             title_pattern: rule.title,
3134             single_instance: rule.single.unwrap_or(false),
3135             tag: rule.tag.unwrap_or(-1) as i32,
3136             circular: rule.circular.unwrap_or(false),
3137             ssd: rule.ssd,
3138             over_sibling: rule.over_sibling.unwrap_or(false),
3139             center: rule.center.unwrap_or(false),
3140         });
3141     }
3142 
3143     for _tag_layout in config.tag_layout {
3144         // Tag layouts are ignored in the pannable coordinate system.
3145     }
3146 
3147     state.startup.clear();
3148     for st in config.startup {
3149         state.startup.push(st);
3150     }
3151 
3152     log::info!(
3153         "Parsed config: {} keybinds, {} pointer binds, {} gesture binds, {} startup programs",
3154         state.keybinds.len(),
3155         state.pointer_binds.len(),
3156         state.gesture_binds.len(),
3157         state.startup.len()
3158     );
3159 
3160     unsafe {
3161         if !state.server.is_null() {
3162             let outputs_head = &mut (*state.server).om.outputs as *mut crate::ffi::wl_list as *mut crate::server::WlList;
3163             let mut curr = (*outputs_head).next;
3164             while curr != outputs_head {
3165                 let next = (*curr).next;
3166                 let output = &mut *crate::container_of!(curr, crate::output::Output, link);
3167                 output.update_background_color();
3168                 curr = next;
3169             }
3170         }
3171     }
3172 
3173     Ok(())
3174 }
3175 
3176 #[cfg(test)]
3177 mod tests {
3178     use super::*;
3179 
3180     #[test]
3181     fn test_my_config() {
3182         if let Some(path) = default_config_path() {
3183             let mut server = crate::server::Server::default();
3184             parse_config(&path, &mut server.wm).unwrap();
3185             assert!(!server.wm.layout.desktop_gap_color.is_empty());
3186             assert!(server.wm.layout.desktop_gap_width >= 0);
3187             assert!(server.wm.layout.root_plate_corner_radius >= 0);
3188             println!("TEST_WM_STARTUP: {:?}", server.wm.startup);
3189             println!("TEST_WM_PATH: {:?}", std::env::var("PATH"));
3190         }
3191     }
3192 
3193     /// RFC Phase 7a: `plate { root ... }` is the one root-plate spelling; a
3194     /// legacy `root plate` node is ignored (its read-alias was removed
3195     /// 2026-09-06), so the defaults stand when only it is present.
3196     #[test]
3197     fn test_plate_root_canonical_spelling() {
3198         let canonical = r##"
3199 style {
3200     surface {
3201         plate {
3202             root color="#11223344" blur=0.5 corner_radius=21
3203         }
3204         backplate color="#ffffffff" blur=0.9 corner_radius=7
3205     }
3206 }
3207 "##;
3208         let config = parse_kdl_config(canonical).unwrap();
3209         assert_eq!(config.surface.root_plate_corner_radius, 21, "canonical read; legacy ignored");
3210         assert_eq!(config.surface.root_plate_color, "#11223344");
3211 
3212         // The child-node spelling of the same block — what cce-data-editor
3213         // writes and what a hand-kept config tends to grow into — reads the
3214         // same. Until 2026-09-28 it did not, and the compositor sat on its
3215         // default radius under root plates the apps drew at the config's.
3216         let children = r##"
3217 style {
3218     surface {
3219         plate {
3220             root {
3221                 blur (f64)0.1
3222                 color (rgba)"#5e657acf"
3223                 corner_radius (i64)24
3224             }
3225         }
3226     }
3227 }
3228 "##;
3229         let config = parse_kdl_config(children).unwrap();
3230         assert_eq!(config.surface.root_plate_corner_radius, 24, "child-node spelling");
3231         assert_eq!(config.surface.root_plate_color, "#5e657acf");
3232         assert!((config.surface.root_plate_blur - 0.1).abs() < 1e-9);
3233 
3234         // The legacy spelling alone is not read: the defaults stand.
3235         let legacy = r##"
3236 style {
3237     surface {
3238         backplate color="#55667788" corner_radius=9
3239     }
3240 }
3241 "##;
3242         let config = parse_kdl_config(legacy).unwrap();
3243         assert_eq!(config.surface.root_plate_corner_radius, default_root_plate_corner_radius(), "legacy spelling is not read");
3244         assert_eq!(config.surface.root_plate_color, default_root_plate_color());
3245     }
3246 
3247     #[test]
3248     fn test_parse_rgba() {
3249         let color = parse_hex_color_rgba("#a5cfc2");
3250         assert_eq!(color, [165.0/255.0, 207.0/255.0, 194.0/255.0, 1.0]);
3251     }
3252 
3253     #[test]
3254     fn test_kdl_display_scale_parsing() {
3255         let content = r#"
3256             output {
3257                 eDP-1 {
3258                     scale (f64)2.0
3259                     brightness_up (keybind)"XF86MonBrightnessUp"
3260                     brightness_down (keybind)"XF86MonBrightnessDown"
3261                     brightness_interval (i64)10
3262                 }
3263                 DP-1 {
3264                     scale (f64)1.5
3265                 }
3266             }
3267         "#;
3268         let config = parse_kdl_config(content).unwrap();
3269         assert_eq!(config.display.get("scale_eDP-1"), Some(&2.0));
3270         assert_eq!(config.display.get("scale_DP-1"), Some(&1.5));
3271         assert_eq!(config.display.get("mm_w_eDP-1"), None);
3272         assert_eq!(config.display.get("brightness_interval_eDP-1"), Some(&10.0));
3273 
3274         let up_bind = config.key_bindings.iter().find(|kb| kb.key == "XF86MonBrightnessUp").unwrap();
3275         assert_eq!(up_bind.action, "spawn");
3276         assert_eq!(up_bind.command, Some("brightnessctl set 10%+".to_string()));
3277 
3278         let down_bind = config.key_bindings.iter().find(|kb| kb.key == "XF86MonBrightnessDown").unwrap();
3279         assert_eq!(down_bind.action, "spawn");
3280         assert_eq!(down_bind.command, Some("brightnessctl set 10%-".to_string()));
3281     }
3282 
3283     #[test]
3284     fn test_kdl_display_size_mm_parsing() {
3285         let content = r#"
3286             output {
3287                 eDP-1 scale=(f64)2.0 size_mm="344x215"
3288                 DP-1 {
3289                     scale (f64)1.5
3290                     size_mm "597 336"
3291                 }
3292                 HDMI-A-1 size_mm="bogus"
3293             }
3294         "#;
3295         let config = parse_kdl_config(content).unwrap();
3296         assert_eq!(config.display.get("mm_w_eDP-1"), Some(&344.0));
3297         assert_eq!(config.display.get("mm_h_eDP-1"), Some(&215.0));
3298         assert_eq!(config.display.get("mm_w_DP-1"), Some(&597.0));
3299         assert_eq!(config.display.get("mm_h_DP-1"), Some(&336.0));
3300         assert_eq!(config.display.get("mm_w_HDMI-A-1"), None);
3301         assert_eq!(parse_size_mm("344,215"), Some((344.0, 215.0)));
3302         assert_eq!(parse_size_mm("344x0"), None);
3303         assert_eq!(parse_size_mm("1x2x3"), None);
3304     }
3305 
3306     #[test]
3307     fn test_kdl_window_manager_parsing() {
3308         let content = r#"
3309             "window_manager" {
3310                 close_window (keybind)"super+q"
3311                 toggle_fullscreen (keybind)"super+f"
3312                 toggle_overview ("menu:swipe_up,swipe_down,swipe_left,swipe_right,pinch_in,pinch_out")"swipe_up"
3313                 swipe_peek (f64)40.0
3314                 swipe_repeat_peek (f64)15.0
3315                 swipe_focus_cone (f64)30.0
3316                 swipe_threshold (f64)80.0
3317                 swipe_repeat_threshold (f64)200.0
3318             }
3319         "#;
3320         let config = parse_kdl_config(content).unwrap();
3321         assert!(config.window_manager.is_some());
3322         let wm = config.window_manager.unwrap();
3323         assert_eq!(wm.close_window, Some("super+q".to_string()));
3324         assert_eq!(wm.toggle_fullscreen, Some("super+f".to_string()));
3325         assert_eq!(wm.toggle_overview, Some("swipe_up".to_string()));
3326         assert_eq!(wm.swipe_peek, Some(40.0));
3327         assert_eq!(wm.swipe_repeat_peek, Some(15.0));
3328         assert_eq!(wm.swipe_focus_cone, Some(30.0));
3329         assert_eq!(wm.swipe_threshold, Some(80.0));
3330         assert_eq!(wm.swipe_repeat_threshold, Some(200.0));
3331         // Absent means "unset", which the apply step reads as the centring default.
3332         assert_eq!(wm.center_on_spawn, None);
3333     }
3334 
3335     #[test]
3336     fn test_kdl_window_manager_center_on_spawn() {
3337         let off = parse_kdl_config(
3338             r#"
3339             window_manager {
3340                 center_on_spawn (bool)false
3341             }
3342         "#,
3343         )
3344         .unwrap();
3345         assert_eq!(off.window_manager.unwrap().center_on_spawn, Some(false));
3346 
3347         let on = parse_kdl_config(
3348             r#"
3349             window_manager {
3350                 center_on_spawn (bool)true
3351             }
3352         "#,
3353         )
3354         .unwrap();
3355         assert_eq!(on.window_manager.unwrap().center_on_spawn, Some(true));
3356 
3357         // No window_manager block at all: nothing to read, and the apply step defaults on.
3358         assert!(parse_kdl_config("layout {\n gap 4\n}").unwrap().window_manager.is_none());
3359     }
3360 
3361     #[test]
3362     fn test_kdl_window_manager_rounded_apps() {
3363         let listed = parse_kdl_config(
3364             r#"
3365             window_manager {
3366                 rounded_apps "claude-desktop" "org.keepassxc.KeePassXC"
3367             }
3368         "#,
3369         )
3370         .unwrap();
3371         assert_eq!(
3372             listed.window_manager.unwrap().rounded_apps,
3373             Some(vec!["claude-desktop".to_string(), "org.keepassxc.KeePassXC".to_string()])
3374         );
3375 
3376         // Absent means "unset": the apply step reads it as an empty allowlist.
3377         let absent = parse_kdl_config(
3378             r#"
3379             window_manager {
3380                 center_on_spawn (bool)true
3381             }
3382         "#,
3383         )
3384         .unwrap();
3385         assert_eq!(absent.window_manager.unwrap().rounded_apps, None);
3386     }
3387 
3388     #[test]
3389     fn test_kdl_window_manager_corner_shape() {
3390         let set = parse_kdl_config(
3391             r#"
3392             window_manager {
3393                 corner_shape (f64)4.5
3394             }
3395         "#,
3396         )
3397         .unwrap();
3398         assert_eq!(set.window_manager.unwrap().corner_shape, Some(4.5));
3399 
3400         // Absent means "unset"; the apply step then feeds scenefx the circular default.
3401         let unset = parse_kdl_config("window_manager {\n center_on_spawn (bool)true\n}").unwrap();
3402         assert_eq!(unset.window_manager.unwrap().corner_shape, None);
3403     }
3404 
3405     #[test]
3406     fn test_kdl_input_device_classes_parsing() {
3407         let content = r#"
3408             input {
3409                 accel_profile "flat"
3410                 accel_speed (f64)1.0
3411                 scroll_factor (f64)1.0
3412                 scroll_ease (f64)9.5
3413                 kinetic_scroll (bool)false
3414                 scroll_friction (f64)4.0
3415                 mouse {
3416                     accel_speed (f64)0.5
3417                     scroll_factor (f64)2.0
3418                     scroll_method "button"
3419                 }
3420                 trackpad {
3421                     tap_to_click (bool)true
3422                     natural_scroll (bool)true
3423                     scroll_factor (f64)1.5
3424                     accel_speed (f64)0.9
3425                 }
3426                 trackpoint {
3427                     accel_speed (f64)0.4
3428                     accel_profile "adaptive"
3429                     scroll_factor (f64)3.0
3430                     scroll_method ("menu:none,button,two_finger,edge")"none"
3431                 }
3432             }
3433         "#;
3434         let config = parse_kdl_config(content).unwrap();
3435         let input = config.input.unwrap();
3436         assert_eq!(input.accel_profile, Some("flat".to_string()));
3437         assert_eq!(input.accel_speed, Some(1.0));
3438         assert_eq!(input.scroll_factor, Some(1.0));
3439         assert_eq!(input.scroll_ease, Some(9.5));
3440         assert_eq!(input.kinetic_scroll, Some(false));
3441         assert_eq!(input.scroll_friction, Some(4.0));
3442         let mouse = input.mouse.unwrap();
3443         assert_eq!(mouse.accel_speed, Some(0.5));
3444         assert_eq!(mouse.scroll_factor, Some(2.0));
3445         assert_eq!(mouse.scroll_method, Some("button".to_string()));
3446         // `trackpad` parses into the touchpad block (input.kdl spelling).
3447         let tp = input.touchpad.unwrap();
3448         assert_eq!(tp.tap_to_click, Some(true));
3449         assert_eq!(tp.natural_scroll, Some(true));
3450         assert_eq!(tp.scroll_factor, Some(1.5));
3451         assert_eq!(tp.accel_speed, Some(0.9));
3452         let tpoint = input.trackpoint.unwrap();
3453         assert_eq!(tpoint.accel_speed, Some(0.4));
3454         assert_eq!(tpoint.accel_profile, Some("adaptive".to_string()));
3455         assert_eq!(tpoint.scroll_factor, Some(3.0));
3456         // The annotated spelling the settings UI writes parses the same.
3457         assert_eq!(tpoint.scroll_method, Some("none".to_string()));
3458     }
3459 
3460     #[test]
3461     fn test_kdl_input_key_repeat() {
3462         let input = parse_kdl_config("input {\n repeat_rate 30\n repeat_delay 200\n}\n")
3463             .unwrap()
3464             .input
3465             .unwrap();
3466         assert_eq!(input.repeat_rate, Some(30));
3467         assert_eq!(input.repeat_delay, Some(200));
3468         assert_eq!(input.repeat_info(), (30, 200));
3469 
3470         // Unset keys fall back to the defaults; negatives clamp to 0.
3471         let input = parse_kdl_config("input {\n repeat_delay -5\n}\n").unwrap().input.unwrap();
3472         assert_eq!(
3473             input.repeat_info(),
3474             (crate::keyboard::DEFAULT_REPEAT_RATE, 0)
3475         );
3476         assert_eq!(
3477             InputConfig::default().repeat_info(),
3478             (crate::keyboard::DEFAULT_REPEAT_RATE, crate::keyboard::DEFAULT_REPEAT_DELAY)
3479         );
3480     }
3481 
3482     #[test]
3483     fn test_parse_edge_gesture() {
3484         assert_eq!(parse_edge_gesture("edge_left"), Some((0, "left".to_string())));
3485         assert_eq!(parse_edge_gesture("Edge-Bottom"), Some((0, "bottom".to_string())));
3486         assert_eq!(parse_edge_gesture("super+edge_top"), Some((0x40, "top".to_string())));
3487         assert_eq!(parse_edge_gesture("edge_middle"), None);
3488         assert_eq!(parse_edge_gesture("swipe3_left"), None);
3489         assert_eq!(parse_edge_gesture("super+t"), None);
3490     }
3491 
3492     #[test]
3493     fn test_kdl_osk_on_touch() {
3494         let off = parse_kdl_config("window_manager {\n osk_on_touch (bool)false\n}").unwrap();
3495         assert_eq!(off.window_manager.unwrap().osk_on_touch, Some(false));
3496         let unset = parse_kdl_config("window_manager {\n}").unwrap();
3497         assert_eq!(unset.window_manager.unwrap().osk_on_touch, None);
3498     }
3499 
3500     #[test]
3501     fn test_kdl_touchpad_hscroll_shift_apps() {
3502         let content = r#"
3503             window_manager {
3504                 touchpad_view_apps "Houdini FX"
3505                 touchpad_hscroll_shift_apps "Houdini FX" "hython*"
3506             }
3507         "#;
3508         let config = parse_kdl_config(content).unwrap();
3509         let wm = config.window_manager.unwrap();
3510         assert_eq!(wm.touchpad_view_apps, Some(vec!["Houdini FX".to_string()]));
3511         assert_eq!(wm.touchpad_hscroll_shift_apps, Some(vec!["Houdini FX".to_string(), "hython*".to_string()]));
3512         // Absent, the list is empty and the emulation is off.
3513         let config = parse_kdl_config("window_manager { }").unwrap();
3514         assert_eq!(config.window_manager.unwrap().touchpad_hscroll_shift_apps, None);
3515     }
3516 
3517     #[test]
3518     fn test_kdl_surface_border_parsing() {
3519         let content = r##"
3520             style {
3521                 surface {
3522                     border width=2 color="#ff8800" color_focused="#00ff88" color_hover="#88ffcc" segment_gap=6 corner_length=24
3523                 }
3524             }
3525         "##;
3526         let config = parse_kdl_config(content).unwrap();
3527         assert_eq!(config.surface.border_width, 2);
3528         assert_eq!(config.surface.border_color, "#ff8800");
3529         assert_eq!(config.surface.border_color_focused, Some("#00ff88".to_string()));
3530         assert_eq!(config.surface.border_color_hover, Some("#88ffcc".to_string()));
3531         assert_eq!(config.surface.border_segment_gap, 6);
3532         assert_eq!(config.surface.border_corner_length, 24);
3533 
3534         // Defaults keep borders off; the focused color falls back to `color`
3535         // and the hover color to a lightened focused color.
3536         let config = parse_kdl_config("").unwrap();
3537         assert_eq!(config.surface.border_width, 0);
3538         assert_eq!(config.surface.border_color_focused, None);
3539         assert_eq!(config.surface.border_color_hover, None);
3540         assert_eq!(config.surface.border_segment_gap, 4);
3541         assert_eq!(config.surface.border_corner_length, 0);
3542     }
3543 
3544     #[test]
3545     fn backdrop_compress_caps_light_text_and_floors_dark_text() {
3546         // White text at 4.5:1 caps the backdrop at (1.05 / 4.5) - 0.05.
3547         let (ceil, knee, invert) = backdrop_compress_params("#ffffff", 4.5).unwrap();
3548         assert!(!invert);
3549         assert!((ceil - 0.1833).abs() < 1e-3, "{ceil}");
3550         assert!((knee - ceil * 0.5).abs() < 1e-6);
3551         // Black text floors it at 4.5 * 0.05 - 0.05 = 0.175, a ceiling of
3552         // 0.825 in the inverted image.
3553         let (ceil, _, invert) = backdrop_compress_params("#000000", 4.5).unwrap();
3554         assert!(invert);
3555         assert!((ceil - 0.825).abs() < 1e-3, "{ceil}");
3556         // Off, or nothing to compress for.
3557         assert_eq!(backdrop_compress_params("#ffffff", 1.0), None);
3558         assert_eq!(backdrop_compress_params("#ffffff", 0.0), None);
3559         assert_eq!(backdrop_compress_params("nonsense", 4.5), None);
3560         // A ratio the color cannot reach clamps rather than going negative.
3561         let (ceil, _, _) = backdrop_compress_params("#808080", 21.0).unwrap();
3562         assert!(ceil > 0.0);
3563     }
3564 }