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