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 }