1use std::collections::HashMap;
2use std::env;
3use std::path::Path;
4use std::sync::OnceLock;
5
6use halley_core::cluster_layout::ClusterWorkspaceLayoutKind;
7use halley_core::decay::FocusRingDecayPolicy;
8use halley_core::field::Vec2;
9use halley_core::viewport::{FocusRing, Viewport};
10
11use crate::keybinds::{CompositorBinding, Keybinds, LaunchBinding, PointerBinding};
12
13use super::paths::{absolutize_path, default_config_path, global_config_path};
14use super::{
15 AnimationsConfig, BearingsConfig, ClickCollapsedOutsideFocusMode, ClickCollapsedPanMode,
16 CloseRestorePanMode, ClusterBloomDirection, ClusterDefaultLayout, CursorConfig,
17 DecorationsConfig, FocusRingConfig, FontConfig, InputConfig, NodeBackgroundColorMode,
18 NodeBorderColorMode, NodeDisplayPolicy, OverlayStyleConfig, PanToNewMode, ScreenshotConfig,
19 ShapeStyle, ViewportOutputConfig, WindowCloseAnimationStyle, WindowRule,
20};
21
22#[derive(Clone, Debug)]
23pub struct RuntimeTuning {
24 pub viewport_center: Vec2,
25 pub viewport_size: Vec2,
26
27 pub focus_ring_rx: f32,
28 pub focus_ring_ry: f32,
29 pub focus_ring_offset_x: f32,
30 pub focus_ring_offset_y: f32,
31
32 pub primary_hot_inner_frac: f32,
33 pub primary_to_node_ms: u64,
34 pub node_show_labels: NodeDisplayPolicy,
35 pub node_show_app_icons: NodeDisplayPolicy,
36 pub node_shape: ShapeStyle,
37 pub node_label_shape: ShapeStyle,
38 pub node_icon_size: f32,
39 pub node_background_color: NodeBackgroundColorMode,
40 pub node_border_color_hover: NodeBorderColorMode,
41 pub node_border_color_inactive: NodeBorderColorMode,
42 pub decorations: DecorationsConfig,
43 pub click_collapsed_outside_focus: ClickCollapsedOutsideFocusMode,
44 pub click_collapsed_pan: ClickCollapsedPanMode,
45 pub bearings: BearingsConfig,
46
47 pub cluster_distance_px: f32,
48 pub cluster_dwell_ms: u64,
49 pub cluster_show_icons: bool,
50 pub cluster_bloom_direction: ClusterBloomDirection,
51 pub cluster_default_layout: ClusterDefaultLayout,
52 pub tile_gaps_inner_px: f32,
53 pub tile_gaps_outer_px: f32,
54 pub tile_new_on_top: bool,
55 pub tile_queue_show_icons: bool,
56 pub tile_max_stack: usize,
57 pub stacking_max_visible: usize,
58 pub trail_history_length: usize,
59 pub trail_wrap: bool,
60
61 pub active_outside_ring_delay_ms: u64,
62 pub inactive_outside_ring_delay_ms: u64,
63 pub docked_offscreen_delay_ms: u64,
64
65 pub non_overlap_gap_px: f32,
66 pub field_active_windows_allowed: usize,
67 pub pan_to_new: PanToNewMode,
68 pub close_restore_focus: bool,
69 pub close_restore_pan: CloseRestorePanMode,
70 pub zoom_enabled: bool,
71 pub zoom_step: f32,
72 pub zoom_min: f32,
73 pub zoom_max: f32,
74 pub zoom_smooth: bool,
75 pub zoom_smooth_rate: f32,
76 pub non_overlap_active_gap_scale: f32,
77 pub non_overlap_bump_newer: bool,
78 pub non_overlap_bump_damping: f32,
79 pub drag_smoothing_boost: f32,
80 pub center_window_to_mouse: bool,
81 pub restore_last_active_on_pan_return: bool,
82 pub physics_enabled: bool,
83 pub window_rules: Vec<WindowRule>,
84
85 pub keybinds: Keybinds,
86 pub compositor_bindings: Vec<CompositorBinding>,
87 pub launch_bindings: Vec<LaunchBinding>,
88 pub pointer_bindings: Vec<PointerBinding>,
89
90 pub tty_viewports: Vec<ViewportOutputConfig>,
91 pub autostart_once: Vec<String>,
92 pub autostart_on_reload: Vec<String>,
93 pub input: InputConfig,
94 pub cursor: CursorConfig,
95 pub font: FontConfig,
96 pub animations: AnimationsConfig,
97 pub overlay_style: OverlayStyleConfig,
98 pub screenshot: ScreenshotConfig,
99 pub env: HashMap<String, String>,
100}
101impl RuntimeTuning {
102 pub fn default_home_config_path() -> String {
103 default_config_path().to_string_lossy().to_string()
104 }
105
106 pub fn global_config_path() -> String {
107 global_config_path().to_string_lossy().to_string()
108 }
109
110 pub fn internal_config_template() -> String {
111 Self::render_fresh_config(&[])
112 }
113
114 pub fn builtin_defaults() -> Self {
115 static BUILTIN_DEFAULTS: OnceLock<RuntimeTuning> = OnceLock::new();
116
117 BUILTIN_DEFAULTS
118 .get_or_init(|| {
119 let template = RuntimeTuning::internal_config_template();
120 RuntimeTuning::from_rune_str_with_seed(&template, RuntimeTuning::default())
121 .unwrap_or_default()
122 })
123 .clone()
124 }
125
126 pub fn render_fresh_config(tty_viewports: &[ViewportOutputConfig]) -> String {
127 let viewport_block = render_viewport_section(tty_viewports);
128 let mut rendered = String::with_capacity(
129 INTERNAL_CONFIG_PREFIX.len() + viewport_block.len() + INTERNAL_CONFIG_SUFFIX.len(),
130 );
131 rendered.push_str(INTERNAL_CONFIG_PREFIX);
132 rendered.push_str(viewport_block.as_str());
133 rendered.push_str(INTERNAL_CONFIG_SUFFIX);
134 rendered
135 }
136
137 pub fn window_primary_border_size_px(&self) -> i32 {
138 self.decorations.border.size_px.max(0)
139 }
140
141 pub fn window_border_radius_px(&self) -> i32 {
142 self.decorations.border.radius_px.max(0)
143 }
144
145 pub fn window_secondary_border_enabled(&self) -> bool {
146 self.decorations.secondary_border.enabled && self.decorations.secondary_border.size_px > 0
147 }
148
149 pub fn window_secondary_border_size_px(&self) -> i32 {
150 if self.window_secondary_border_enabled() {
151 self.decorations.secondary_border.size_px.max(0)
152 } else {
153 0
154 }
155 }
156
157 pub fn window_secondary_border_gap_px(&self) -> i32 {
158 if self.window_secondary_border_enabled() {
159 self.decorations.secondary_border.gap_px.max(0)
160 } else {
161 0
162 }
163 }
164
165 pub fn total_window_border_footprint_px(&self) -> i32 {
166 self.window_primary_border_size_px()
167 + self.window_secondary_border_gap_px()
168 + self.window_secondary_border_size_px()
169 }
170
171 pub fn cluster_layout_kind(&self) -> ClusterWorkspaceLayoutKind {
172 self.cluster_default_layout.to_workspace_layout_kind()
173 }
174
175 pub fn active_cluster_visible_limit(&self) -> usize {
176 match self.cluster_layout_kind() {
177 ClusterWorkspaceLayoutKind::Tiling => self.tile_max_stack,
178 ClusterWorkspaceLayoutKind::Stacking => self.stacking_max_visible,
179 }
180 }
181
182 pub fn animations_enabled(&self) -> bool {
183 self.animations.enabled
184 }
185
186 pub fn smooth_resize_enabled(&self) -> bool {
187 self.animations_enabled() && self.animations.smooth_resize.enabled
188 }
189
190 pub fn smooth_resize_duration_ms(&self) -> u64 {
191 self.animations.smooth_resize.duration_ms.max(1)
192 }
193
194 pub fn window_close_animation_enabled(&self) -> bool {
195 self.animations_enabled() && self.animations.window_close.enabled
196 }
197
198 pub fn window_close_duration_ms(&self) -> u64 {
199 self.animations.window_close.duration_ms.max(1)
200 }
201
202 pub fn window_close_style(&self) -> WindowCloseAnimationStyle {
203 self.animations.window_close.style
204 }
205
206 pub fn window_open_animation_enabled(&self) -> bool {
207 self.animations_enabled() && self.animations.window_open.enabled
208 }
209
210 pub fn window_open_duration_ms(&self) -> u64 {
211 self.animations.window_open.duration_ms.max(1)
212 }
213
214 pub fn tile_animation_enabled(&self) -> bool {
215 self.animations_enabled() && self.animations.tile.enabled
216 }
217
218 pub fn tile_animation_duration_ms(&self) -> u64 {
219 self.animations.tile.duration_ms.max(1)
220 }
221
222 pub fn stack_animation_enabled(&self) -> bool {
223 self.animations_enabled() && self.animations.stack.enabled
224 }
225
226 pub fn stack_animation_duration_ms(&self) -> u64 {
227 self.animations.stack.duration_ms.max(1)
228 }
229
230 pub fn config_path() -> String {
231 match env::var("HALLEY_WL_CONFIG") {
232 Ok(path) => absolutize_path(&path).to_string_lossy().to_string(),
233 Err(_) => {
234 let home = default_config_path();
235 if Path::new(&home).exists() {
236 home.to_string_lossy().to_string()
237 } else {
238 let global = global_config_path();
239 if Path::new(&global).exists() {
240 global.to_string_lossy().to_string()
241 } else {
242 home.to_string_lossy().to_string()
243 }
244 }
245 }
246 }
247 }
248
249 pub fn load() -> Self {
250 Self::load_from_path(&Self::config_path())
251 }
252
253 pub fn load_from_path(path: &str) -> Self {
254 let mut out = Self::try_load_from_path(path).unwrap_or_else(Self::builtin_defaults);
255 out.clamp_values();
256 out
257 }
258
259 pub fn try_load_from_path(path: &str) -> Option<Self> {
260 let mut out = Self::from_rune_file(path)?;
261 out.clamp_values();
262 Some(out)
263 }
264
265 pub fn apply_process_env(&self) {
266 for (key, value) in &self.env {
267 let key = key.trim();
268 if key.is_empty() {
269 continue;
270 }
271 let value = value.trim();
272 if value.is_empty() {
273 continue;
274 }
275 unsafe { env::set_var(key, value) };
276 }
277
278 let theme = self.cursor.theme.trim();
279 if !theme.is_empty() {
280 unsafe { env::set_var("XCURSOR_THEME", theme) };
281 }
282 unsafe { env::set_var("XCURSOR_SIZE", self.cursor.size.to_string()) };
283 }
284
285 pub fn viewport(&self) -> Viewport {
286 Viewport::new(self.viewport_center, self.viewport_size)
287 }
288
289 pub fn focus_ring(&self) -> FocusRing {
290 FocusRingConfig {
291 rx: self.focus_ring_rx,
292 ry: self.focus_ring_ry,
293 offset_x: self.focus_ring_offset_x,
294 offset_y: self.focus_ring_offset_y,
295 }
296 .to_focus_ring()
297 }
298
299 pub fn focus_ring_for_output(&self, output_name: &str) -> FocusRing {
300 self.tty_viewports
301 .iter()
302 .find(|viewport| viewport.connector == output_name)
303 .and_then(|viewport| viewport.focus_ring)
304 .unwrap_or(FocusRingConfig {
305 rx: self.focus_ring_rx,
306 ry: self.focus_ring_ry,
307 offset_x: self.focus_ring_offset_x,
308 offset_y: self.focus_ring_offset_y,
309 })
310 .to_focus_ring()
311 }
312
313 pub fn focus_ring_decay_policy(&self) -> FocusRingDecayPolicy {
314 let mut p = FocusRingDecayPolicy::new();
315 p.inside_to_node_ms = self.primary_to_node_ms;
316 p
317 }
318
319 pub fn keybinds_resolved_summary(&self) -> String {
320 format!(
321 "mod={} compositor_actions={} custom_launches={} pointer_actions={}",
322 self.keybinds.modifier_name(),
323 self.compositor_bindings.len(),
324 self.launch_bindings.len(),
325 self.pointer_bindings.len(),
326 )
327 }
328
329 pub fn zoom_resolved_summary(&self) -> String {
330 format!(
331 "enabled={} step={:.3} min={:.3} max={:.3} smooth={} smooth_rate={:.3}",
332 self.zoom_enabled,
333 self.zoom_step,
334 self.zoom_min,
335 self.zoom_max,
336 self.zoom_smooth,
337 self.zoom_smooth_rate,
338 )
339 }
340}
341
342const INTERNAL_CONFIG_PREFIX: &str = r##"@author "Dustin Pilgrim"
343@description "Spatial Wayland compositor built around infinite workspace navigation"
344
345# Halley is a spatial compositor.
346# Instead of fixed workspaces, each monitor has a navigable field where
347# windows live in space. You move through that space with panning, zooming,
348# clusters, and focus-aware behavior.
349
350# Optional environment variables for apps launched by Halley.
351# Uncomment these if you want to prefer Wayland for Qt apps and use qt6ct.
352#env:
353# QT_QPA_PLATFORM "wayland"
354# QT_QPA_PLATFORMTHEME "qt6ct"
355#end
356
357# Autostart lets Halley launch bars, notifiers, and background helpers.
358# `once` runs only on compositor startup. `on-reload` runs after a config reload.
359autostart:
360 # Common examples you may want later:
361 #once "waybar"
362
363 #once "mako"
364 #once "gessod"
365 #once "stasis"
366
367 # Example:
368 #on-reload "thunderbird"
369end
370
371# Cursor settings apply to the compositor itself and child apps started by Halley.
372# `hide-when-typing` is useful when you mostly drive the field with the keyboard.
373cursor:
374 theme "Adwaita"
375 size 24
376 hide-when-typing true
377 hide-after-ms 2000
378end
379
380# Keyboard repeat and pointer-driven focus behavior.
381# `focus-mode "click"` preserves the existing click-to-focus behavior.
382input:
383 repeat-rate 30
384 repeat-delay 500
385 focus-mode "click"
386end
387
388# Default font used for compositor UI like labels and overlays.
389font:
390 family "monospace"
391 size 11
392end
393
394# Where screenshots taken through Halley are saved.
395# Use an absolute path or an env-expanded path like `$env.HOME/...`.
396screenshot:
397 directory "$env.HOME/Pictures/Screenshots/"
398end
399
400"##;
401
402const INTERNAL_CONFIG_SUFFIX: &str = r##"
403# The field is Halley's spatial world for a monitor.
404# Windows live on this field instead of being arranged into fixed desktops.
405field:
406 # Gap in pixels between windows and layout elements.
407 gap 20.0
408 # Maximum number of non-node windows allowed on the Field before decay takes over.
409 # Set to 0 to disable decay entirely.
410 active-windows-allowed 5
411 # How aggressively the camera pans to newly opened windows.
412 pan-to-new "if-needed"
413 close-restore-focus true
414 close-restore-pan "if-offscreen"
415
416 zoom:
417 enabled true
418 step 1.10
419 min 0.35
420 max 1.35
421 smooth true
422 smooth-rate 12.5
423 end
424end
425
426# A node is Halley's collapsed representation of a window.
427# When a window is no longer active enough to stay expanded,
428# it can decay into a compact node that still exists on the field.
429node:
430 # Keep nodes recognizable without making the field too noisy.
431 show-labels "hover"
432 # `always`, `hover`, or `off` for real app icons. Halley falls back to
433 # the app-id initial when an icon is unavailable or intentionally hidden.
434 show-app-icons "always"
435
436 node-shape "square"
437 node-label-shape "square"
438
439 # Size is a fraction of the node diameter.
440 icon-size 0.72
441
442 # Auto tints the node fill from its border colour.
443 background-colour "auto"
444
445 # Border colour source for hovered/inactive nodes.
446 # Allowed values: "use-window-active", "use-window-inactive",
447 # "use-window-secondary-active", "use-window-secondary-inactive".
448 border-colour-hover "use-window-active"
449 border-colour-inactive "use-window-inactive"
450
451 click-collapsed-outside-focus "activate"
452 click-collapsed-pan "if-offscreen"
453end
454
455# Decay controls how windows transition between active, inactive,
456# and collapsed states.
457# Lower values make Halley condense inactive work more quickly.
458decay:
459 active-delay 240
460 inactive-delay 120
461end
462
463# Trail is Halley's navigation history.
464# Think back/forward through previously focused places or windows.
465trail:
466 history-length 25
467 wrap true
468end
469
470# Bearings are directional indicators for offscreen things.
471# They can show both labels and distance to help you re-orient quickly.
472bearings:
473 show-distance true
474 show-icons true
475 fade-distance 1200
476end
477
478# Clusters are Halley's workspace-like grouping system.
479# Unlike traditional workspaces, clusters live in the field.
480clusters:
481 cluster-dwell-ms 2000
482 distance-px 280.0
483 bloom-direction "clockwise"
484 show-icons true
485 default-layout "stacking"
486end
487
488# Settings for tiled layout inside a cluster.
489tile:
490 new-on-top false
491 gaps-inner 20
492 gaps-outer 20
493 max-stack 4
494 queue-show-icons true
495end
496
497# Settings for stacking layout inside a cluster.
498stacking:
499 max-visible 5
500end
501
502# Halley can use gentle physics-style motion instead of purely rigid snapping.
503physics:
504 enabled true
505 damping 0.45
506end
507
508# Animation controls for window and layout transitions.
509animations:
510 enabled true
511
512 smooth-resize:
513 enabled true
514 duration-ms 90 # lower = tighter, higher = softer
515 end
516
517 window-open:
518 enabled true
519 duration-ms 620
520 end
521
522 window-close:
523 enabled true
524 duration-ms 270
525 style "shrink"
526 end
527
528 tile:
529 enabled true
530 duration-ms 240
531 end
532
533 stack:
534 enabled true
535 duration-ms 220
536 end
537end
538
539# Compositor-owned window borders managed by Halley.
540decorations:
541 border:
542 size 3
543 radius 0
544 colour-focused "#d65d26"
545 colour-unfocused "#333333"
546 end
547
548 secondary-border:
549 enabled false
550 size 1
551 gap 2
552 colour-focused "#fabd2f"
553 colour-unfocused "#1f1f1f"
554 end
555
556 resize-using-border true
557end
558
559# Styling for compositor-drawn overlays like labels and helper UI.
560overlays:
561 background-colour "auto"
562 text-colour "auto"
563 shape "square"
564 borders "true"
565 border-source "primary"
566end
567
568# Main input bindings.
569# Some bindings are context-sensitive. The same key may do different things
570# in the field versus inside a tile or stacking layout.
571keybinds:
572 mod "super"
573
574 # Basic compositor controls.
575 "$var.mod+shift+r" "reload"
576 "$var.mod+n" "toggle-state"
577 "$var.mod+q" "close-focused"
578
579 # Zoom controls for the field camera.
580 "$var.mod+mousewheelup" "zoom-in"
581 "$var.mod+mousewheeldown" "zoom-out"
582 "$var.mod+middlemouse" "zoom-reset"
583
584 "$var.mod+shift+e" "quit"
585
586 # Move the selected/latest node in the field.
587 "$var.mod+left" "node-move left"
588 "$var.mod+right" "node-move right"
589 "$var.mod+up" "node-move up"
590 "$var.mod+down" "node-move down"
591
592 # Switch active monitor focus.
593 "$var.mod+shift+left" "monitor-focus left"
594 "$var.mod+shift+right" "monitor-focus right"
595 "$var.mod+shift+up" "monitor-focus up"
596 "$var.mod+shift+down" "monitor-focus down"
597
598 # Cluster controls.
599 "$var.mod+shift+c" "cluster-mode"
600 "$var.mod+l" "cluster-layout cycle"
601
602 # Bearings controls.
603 "$var.mod+z" "bearings-show"
604 "$var.mod+shift+z" "bearings-toggle"
605
606 # Trail navigation.
607 "$var.mod+," "trail-prev"
608 "$var.mod+." "trail-next"
609
610 # Applications.
611 # `open-terminal` picks the first supported Wayland terminal in PATH.
612 "$var.mod+return" "open-terminal"
613 "$var.mod+d" "fuzzel"
614
615 # Mouse actions.
616 "$var.mod+leftmouse" "move-window"
617 "$var.mod+rightmouse" "resize-window"
618 "$var.mod+shift+leftmouse" "field-jump"
619
620 # Tile layout controls.
621 "$var.mod+left" "tile-focus left"
622 "$var.mod+right" "tile-focus right"
623 "$var.mod+up" "tile-focus up"
624 "$var.mod+down" "tile-focus down"
625
626 "$var.mod+ctrl+left" "tile-swap left"
627 "$var.mod+ctrl+right" "tile-swap right"
628 "$var.mod+ctrl+up" "tile-swap up"
629 "$var.mod+ctrl+down" "tile-swap down"
630
631 # Stacking layout controls.
632 "$var.mod+left" "stack-cycle forward"
633 "$var.mod+right" "stack-cycle backward"
634
635 # Screenshot UI
636 "$var.mod+shift+s" "halleyctl capture menu"
637
638 # Media keys.
639 "XF86AudioRaiseVolume" "wpctl set-volume -l 1 @default_audio_sink@ 5%+"
640 "XF86AudioLowerVolume" "wpctl set-volume @default_audio_sink@ 5%-"
641 "XF86AudioMute" "wpctl set-mute @default_audio_sink@ toggle"
642end
643
644# Rules let you special-case certain windows/apps.
645# This example keeps common Firefox file dialogs centered and floating.
646rules:
647 rule:
648 app-id "firefox"
649 title [r"File Upload.*", r"Open File.*", r"Save File.*", r"Choose.*"]
650 overlap-policy "all"
651 spawn-placement "center"
652 cluster-participation "float"
653 end
654end
655"##;
656
657fn render_viewport_section(tty_viewports: &[ViewportOutputConfig]) -> String {
658 if tty_viewports.is_empty() {
659 return [
660 "# A viewport represents one monitor/output.",
661 "# On first tty launch Halley writes the detected outputs here for you.",
662 "# If you want to manage monitors manually later, edit this section.",
663 "viewport:",
664 "end",
665 "",
666 ]
667 .join("\n");
668 }
669
670 let defaults = RuntimeTuning::builtin_defaults();
671 let default_focus_ring = FocusRingConfig {
672 rx: defaults.focus_ring_rx,
673 ry: defaults.focus_ring_ry,
674 offset_x: defaults.focus_ring_offset_x,
675 offset_y: defaults.focus_ring_offset_y,
676 };
677
678 let mut lines = vec![
679 "# A viewport represents one monitor/output.".to_string(),
680 "# On first tty launch Halley writes the detected outputs here for you.".to_string(),
681 "# If you want to manage monitors manually later, edit this section.".to_string(),
682 "viewport:".to_string(),
683 ];
684
685 for viewport in tty_viewports {
686 let focus_ring = viewport.focus_ring.unwrap_or(default_focus_ring);
687 lines.push(format!(" {}:", viewport.connector));
688 lines.push(format!(" enabled {}", viewport.enabled));
689 lines.push(String::new());
690 lines.push(format!(" offset-x {}", viewport.offset_x));
691 lines.push(format!(" offset-y {}", viewport.offset_y));
692 lines.push(String::new());
693 lines.push(format!(" width {}", viewport.width));
694 lines.push(format!(" height {}", viewport.height));
695 lines.push(String::new());
696 lines.push(format!(
697 " rate {:.3}",
698 viewport.refresh_rate.unwrap_or(60.0)
699 ));
700 lines.push(format!(" transform {}", viewport.transform_degrees));
701 lines.push(format!(" vrr \"{}\"", viewport.vrr.as_str()));
702 lines.push(" # The focus ring is Halley's active zone.".to_string());
703 lines.push(" # Windows inside it stay more fully active.".to_string());
704 lines
705 .push(" # Windows outside it may decay into nodes depending on config.".to_string());
706 lines.push(" focus-ring:".to_string());
707 lines.push(format!(" primary-rx {:.1}", focus_ring.rx));
708 lines.push(format!(" primary-ry {:.1}", focus_ring.ry));
709 lines.push(format!(" offset-x {:.0}", focus_ring.offset_x));
710 lines.push(format!(" offset-y {:.0}", focus_ring.offset_y));
711 lines.push(" end".to_string());
712 lines.push(" end".to_string());
713 }
714
715 lines.extend([
716 " # Example second monitor configuration.".to_string(),
717 " # Uncomment and edit if needed.".to_string(),
718 " #DP-2:".to_string(),
719 " # enabled true".to_string(),
720 " #".to_string(),
721 " # offset-x 0".to_string(),
722 " # offset-y 0".to_string(),
723 " #".to_string(),
724 " # width 1920".to_string(),
725 " # height 1200".to_string(),
726 " #".to_string(),
727 " # rate 75.0".to_string(),
728 " # transform 0".to_string(),
729 " # vrr \"off\"".to_string(),
730 " #".to_string(),
731 " # focus-ring:".to_string(),
732 format!(" # primary-rx {:.1}", default_focus_ring.rx),
733 format!(" # primary-ry {:.1}", default_focus_ring.ry),
734 format!(" # offset-x {:.0}", default_focus_ring.offset_x),
735 format!(" # offset-y {:.0}", default_focus_ring.offset_y),
736 " # end".to_string(),
737 " #end".to_string(),
738 ]);
739
740 if let Some(last) = lines.last()
741 && !last.is_empty()
742 {
743 lines.push(String::new());
744 }
745
746 lines.push("end".to_string());
747 lines.push(String::new());
748 lines.join("\n")
749}
750
751#[cfg(test)]
752mod tests {
753 use super::*;
754
755 #[test]
756 fn total_window_border_footprint_includes_secondary_border_when_enabled() {
757 let mut tuning = RuntimeTuning::default();
758 assert_eq!(tuning.total_window_border_footprint_px(), 3);
759
760 tuning.decorations.secondary_border.enabled = true;
761 tuning.decorations.secondary_border.size_px = 2;
762 tuning.decorations.secondary_border.gap_px = 4;
763 assert_eq!(tuning.total_window_border_footprint_px(), 9);
764 }
765
766 #[test]
767 fn builtin_defaults_follow_internal_template() {
768 let tuning = RuntimeTuning::builtin_defaults();
769
770 assert_eq!(tuning.node_shape, ShapeStyle::Square);
771 assert_eq!(tuning.node_label_shape, ShapeStyle::Square);
772 assert_eq!(tuning.cursor.hide_after_ms, 2000);
773 assert_eq!(tuning.cluster_dwell_ms, 2000);
774 assert_eq!(tuning.field_active_windows_allowed, 5);
775 assert_eq!(tuning.input.repeat_rate, 30);
776 assert_eq!(tuning.input.repeat_delay, 500);
777 }
778
779 #[test]
780 fn render_fresh_config_includes_detected_viewports() {
781 let rendered = RuntimeTuning::render_fresh_config(&[ViewportOutputConfig {
782 connector: "DP-1".to_string(),
783 enabled: true,
784 offset_x: 0,
785 offset_y: 0,
786 width: 2560,
787 height: 1440,
788 refresh_rate: Some(180.0),
789 transform_degrees: 0,
790 vrr: crate::ViewportVrrMode::Off,
791 focus_ring: None,
792 }]);
793
794 assert!(rendered.contains("viewport:\n DP-1:"));
795 assert!(rendered.contains(" rate 180.000"));
796 assert!(rendered.contains("# Example second monitor configuration."));
797 assert!(rendered.contains(" focus-ring:"));
798 assert!(rendered.contains("# Cursor settings apply to the compositor itself"));
799 assert!(
800 rendered.contains(
801 "input:\n repeat-rate 30\n repeat-delay 500\n focus-mode \"click\"\nend"
802 )
803 );
804 }
805
806 #[test]
807 fn render_fresh_config_without_outputs_keeps_documented_viewport_block() {
808 let rendered = RuntimeTuning::render_fresh_config(&[]);
809
810 assert!(
811 rendered.contains(
812 "# Autostart lets Halley launch bars, notifiers, and background helpers."
813 )
814 );
815 assert!(rendered.contains("viewport:\nend\n"));
816 }
817}