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

src/server/config.rs (143.3K)

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