Skip to main content

halley_config/layout/
runtime.rs

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 maximize_animation_enabled(&self) -> bool {
195        self.animations_enabled() && self.animations.maximize.enabled
196    }
197
198    pub fn maximize_animation_duration_ms(&self) -> u64 {
199        self.animations.maximize.duration_ms.max(1)
200    }
201
202    pub fn window_close_animation_enabled(&self) -> bool {
203        self.animations_enabled() && self.animations.window_close.enabled
204    }
205
206    pub fn window_close_duration_ms(&self) -> u64 {
207        self.animations.window_close.duration_ms.max(1)
208    }
209
210    pub fn window_close_style(&self) -> WindowCloseAnimationStyle {
211        self.animations.window_close.style
212    }
213
214    pub fn window_open_animation_enabled(&self) -> bool {
215        self.animations_enabled() && self.animations.window_open.enabled
216    }
217
218    pub fn window_open_duration_ms(&self) -> u64 {
219        self.animations.window_open.duration_ms.max(1)
220    }
221
222    pub fn tile_animation_enabled(&self) -> bool {
223        self.animations_enabled() && self.animations.tile.enabled
224    }
225
226    pub fn tile_animation_duration_ms(&self) -> u64 {
227        self.animations.tile.duration_ms.max(1)
228    }
229
230    pub fn stack_animation_enabled(&self) -> bool {
231        self.animations_enabled() && self.animations.stack.enabled
232    }
233
234    pub fn stack_animation_duration_ms(&self) -> u64 {
235        self.animations.stack.duration_ms.max(1)
236    }
237
238    pub fn config_path() -> String {
239        match env::var("HALLEY_WL_CONFIG") {
240            Ok(path) => absolutize_path(&path).to_string_lossy().to_string(),
241            Err(_) => {
242                let home = default_config_path();
243                if Path::new(&home).exists() {
244                    home.to_string_lossy().to_string()
245                } else {
246                    let global = global_config_path();
247                    if Path::new(&global).exists() {
248                        global.to_string_lossy().to_string()
249                    } else {
250                        home.to_string_lossy().to_string()
251                    }
252                }
253            }
254        }
255    }
256
257    pub fn load() -> Self {
258        Self::load_from_path(&Self::config_path())
259    }
260
261    pub fn load_from_path(path: &str) -> Self {
262        let mut out = Self::try_load_from_path(path).unwrap_or_else(Self::builtin_defaults);
263        out.clamp_values();
264        out
265    }
266
267    pub fn try_load_from_path(path: &str) -> Option<Self> {
268        let mut out = Self::from_rune_file(path)?;
269        out.clamp_values();
270        Some(out)
271    }
272
273    pub fn apply_process_env(&self) {
274        for (key, value) in &self.env {
275            let key = key.trim();
276            if key.is_empty() {
277                continue;
278            }
279            let value = value.trim();
280            if value.is_empty() {
281                continue;
282            }
283            unsafe { env::set_var(key, value) };
284        }
285
286        let theme = self.cursor.theme.trim();
287        if !theme.is_empty() {
288            unsafe { env::set_var("XCURSOR_THEME", theme) };
289        }
290        unsafe { env::set_var("XCURSOR_SIZE", self.cursor.size.to_string()) };
291    }
292
293    pub fn viewport(&self) -> Viewport {
294        Viewport::new(self.viewport_center, self.viewport_size)
295    }
296
297    pub fn focus_ring(&self) -> FocusRing {
298        FocusRingConfig {
299            rx: self.focus_ring_rx,
300            ry: self.focus_ring_ry,
301            offset_x: self.focus_ring_offset_x,
302            offset_y: self.focus_ring_offset_y,
303        }
304        .to_focus_ring()
305    }
306
307    pub fn focus_ring_for_output(&self, output_name: &str) -> FocusRing {
308        self.tty_viewports
309            .iter()
310            .find(|viewport| viewport.connector == output_name)
311            .and_then(|viewport| viewport.focus_ring)
312            .unwrap_or(FocusRingConfig {
313                rx: self.focus_ring_rx,
314                ry: self.focus_ring_ry,
315                offset_x: self.focus_ring_offset_x,
316                offset_y: self.focus_ring_offset_y,
317            })
318            .to_focus_ring()
319    }
320
321    pub fn focus_ring_decay_policy(&self) -> FocusRingDecayPolicy {
322        let mut p = FocusRingDecayPolicy::new();
323        p.inside_to_node_ms = self.primary_to_node_ms;
324        p
325    }
326
327    pub fn keybinds_resolved_summary(&self) -> String {
328        format!(
329            "mod={} compositor_actions={} custom_launches={} pointer_actions={}",
330            self.keybinds.modifier_name(),
331            self.compositor_bindings.len(),
332            self.launch_bindings.len(),
333            self.pointer_bindings.len(),
334        )
335    }
336
337    pub fn zoom_resolved_summary(&self) -> String {
338        format!(
339            "enabled={} step={:.3} min={:.3} max={:.3} smooth={} smooth_rate={:.3}",
340            self.zoom_enabled,
341            self.zoom_step,
342            self.zoom_min,
343            self.zoom_max,
344            self.zoom_smooth,
345            self.zoom_smooth_rate,
346        )
347    }
348}
349
350const INTERNAL_CONFIG_PREFIX: &str = r##"@author "Dustin Pilgrim"
351@description "Spatial Wayland compositor built around infinite workspace navigation"
352
353# Halley is a spatial compositor.
354# Instead of fixed workspaces, each monitor has a navigable field where
355# windows live in space. You move through that space with panning, zooming,
356# clusters, and focus-aware behavior.
357
358# Split configs can be included with `gather`. A gathered file without `as`
359# is merged into this config; explicit values here override gathered defaults.
360#gather "colors.rune"
361
362# Optional environment variables for apps launched by Halley.
363# Uncomment these if you want to prefer Wayland for Qt apps and use qt6ct.
364#env:
365#  QT_QPA_PLATFORM "wayland"
366#  QT_QPA_PLATFORMTHEME "qt6ct"
367#end
368
369# Autostart lets Halley launch bars, notifiers, and background helpers.
370# `once` runs only on compositor startup. `on-reload` runs after a config reload.
371autostart:
372  # Common examples you may want later:
373  #once "waybar"
374
375  #once "mako"
376  #once "gessod"
377  #once "stasis"
378
379  # Example:
380  #on-reload "thunderbird"
381end
382
383# Cursor settings apply to the compositor itself and child apps started by Halley.
384# `hide-when-typing` is useful when you mostly drive the field with the keyboard.
385cursor:
386  theme "Adwaita"
387  size 24
388  hide-when-typing true
389  hide-after-ms 2000
390end
391
392# Keyboard repeat and pointer-driven focus behavior.
393# `focus-mode "click"` preserves the existing click-to-focus behavior.
394input:
395  repeat-rate 30
396  repeat-delay 500
397  focus-mode "click"
398  keyboard:
399    layout "us"
400    variant ""
401    options ""
402  end
403end
404
405# Default font used for compositor UI like labels and overlays.
406font:
407  family "monospace"
408  size 11
409end
410
411# Where screenshots taken through Halley are saved.
412# Use an absolute path or an env-expanded path like `$env.HOME/...`.
413screenshot:
414  directory "$env.HOME/Pictures/Screenshots/"
415end
416
417"##;
418
419const INTERNAL_CONFIG_SUFFIX: &str = r##"
420# The field is Halley's spatial world for a monitor.
421# Windows live on this field instead of being arranged into fixed desktops.
422field:
423  # Gap in pixels between windows and layout elements.
424  gap 20.0
425  # Maximum number of non-node windows allowed on the Field before decay takes over.
426  # Set to 0 to disable decay entirely.
427  active-windows-allowed 5
428  # How aggressively the camera pans to newly opened windows.
429  pan-to-new "if-needed"
430  close-restore-focus true
431  close-restore-pan "if-offscreen"
432
433  zoom:
434    enabled true
435    step 1.10
436    min 0.35
437    max 1.35
438    smooth true
439    smooth-rate 12.5
440  end
441end
442
443# A node is Halley's collapsed representation of a window.
444# When a window is no longer active enough to stay expanded,
445# it can decay into a compact node that still exists on the field.
446node:
447  # Keep nodes recognizable without making the field too noisy.
448  show-labels "hover"
449  # `always`, `hover`, or `off` for real app icons. Halley falls back to
450  # the app-id initial when an icon is unavailable or intentionally hidden.
451  show-app-icons "always"
452
453  node-shape "square"
454  node-label-shape "square"
455
456  # Size is a fraction of the node diameter.
457  icon-size 0.72
458
459  # Auto tints the node fill from its border colour.
460  background-colour "auto"
461
462  # Border colour source for hovered/inactive nodes.
463  # Allowed values: "use-window-active", "use-window-inactive",
464  # "use-window-secondary-active", "use-window-secondary-inactive".
465  border-colour-hover "use-window-active"
466  border-colour-inactive "use-window-inactive"
467
468  click-collapsed-outside-focus "activate"
469  click-collapsed-pan "if-offscreen"
470end
471
472# Decay controls how windows transition between active, inactive,
473# and collapsed states.
474# Lower values make Halley condense inactive work more quickly.
475decay:
476  active-delay 240
477  inactive-delay 120
478end
479
480# Trail is Halley's navigation history.
481# Think back/forward through previously focused places or windows.
482trail:
483  history-length 25
484  wrap true
485end
486
487# Bearings are directional indicators for offscreen things.
488# They can show both labels and distance to help you re-orient quickly.
489bearings:
490  show-distance true
491  show-icons true
492  fade-distance 1200
493end
494
495# Clusters are Halley's workspace-like grouping system.
496# Unlike traditional workspaces, clusters live in the field.
497clusters:
498  cluster-dwell-ms 2000
499  distance-px 280.0
500  bloom-direction "clockwise"
501  show-icons true
502  default-layout "stacking"
503end
504
505# Settings for tiled layout inside a cluster.
506tile:
507  new-on-top false
508  gaps-inner 20
509  gaps-outer 20
510  max-stack 4
511  queue-show-icons true
512end
513
514# Settings for stacking layout inside a cluster.
515stacking:
516  max-visible 5
517end
518
519# Halley can use gentle physics-style motion instead of purely rigid snapping.
520physics:
521  enabled true
522  damping 0.45
523end
524
525# Animation controls for window and layout transitions.
526animations:
527  enabled true
528
529  smooth-resize:
530    enabled true
531    duration-ms 90  # lower = tighter, higher = softer
532  end
533
534  maximize:
535    enabled true
536    duration-ms 240
537  end
538
539  window-open:
540    enabled true
541    duration-ms 620
542  end
543
544  window-close:
545    enabled true
546    duration-ms 270
547    style "shrink"
548  end
549
550  tile:
551    enabled true
552    duration-ms 240
553  end
554
555  stack:
556    enabled true
557    duration-ms 220
558  end
559end
560
561# Compositor-owned window borders managed by Halley.
562decorations:
563  border:
564    size 3
565    radius 0
566    colour-focused "#d65d26"
567    colour-unfocused "#333333"
568  end
569
570  secondary-border:
571    enabled false
572    size 1
573    gap 2
574    colour-focused "#fabd2f"
575    colour-unfocused "#1f1f1f"
576  end
577
578  shadows:
579    window:
580      enabled true
581      blur-radius 8
582      spread 0
583      offset-x 0
584      offset-y 5
585      colour "#05030530"
586    end
587
588    node:
589      enabled true
590      blur-radius 14
591      spread 0
592      offset-x 0
593      offset-y 3
594      colour "#05030524"
595    end
596
597    overlay:
598      enabled true
599      blur-radius 24
600      spread 1
601      offset-x 0
602      offset-y 7
603      colour "#05030538"
604    end
605  end
606
607  resize-using-border true
608end
609
610# Styling for compositor-drawn overlays like labels and helper UI.
611overlays:
612  background-colour "auto"
613  text-colour "auto"
614  shape "square"
615  borders "true"
616  border-source "primary"
617end
618
619# Main input bindings.
620# Some bindings are context-sensitive. The same key may do different things
621# in the field versus inside a tile or stacking layout.
622keybinds:
623  mod "super"
624
625  # Basic compositor controls.
626  "$var.mod+shift+r" "reload"
627  "$var.mod+n" "toggle-state"
628  "$var.mod+m" "maximize-focused"
629  "$var.mod+q" "close-focused"
630
631  # Zoom controls for the field camera.
632  "$var.mod+mousewheelup" "zoom-in"
633  "$var.mod+mousewheeldown" "zoom-out"
634  "$var.mod+middlemouse" "zoom-reset"
635
636  "$var.mod+shift+e" "quit"
637
638  # Move the selected/latest node in the field.
639  "$var.mod+left" "node-move left"
640  "$var.mod+right" "node-move right"
641  "$var.mod+up" "node-move up"
642  "$var.mod+down" "node-move down"
643
644  # Switch active monitor focus.
645  "$var.mod+shift+left" "monitor-focus left"
646  "$var.mod+shift+right" "monitor-focus right"
647  "$var.mod+shift+up" "monitor-focus up"
648  "$var.mod+shift+down" "monitor-focus down"
649
650  # Cluster controls.
651  "$var.mod+shift+c" "cluster-mode"
652  "$var.mod+l" "cluster-layout cycle"
653  "$var.mod+1" "cluster slot 1"
654  "$var.mod+2" "cluster slot 2"
655  "$var.mod+3" "cluster slot 3"
656  "$var.mod+4" "cluster slot 4"
657  "$var.mod+5" "cluster slot 5"
658  "$var.mod+6" "cluster slot 6"
659  "$var.mod+7" "cluster slot 7"
660  "$var.mod+8" "cluster slot 8"
661  "$var.mod+9" "cluster slot 9"
662  "$var.mod+0" "cluster slot 10"
663
664  # Bearings controls.
665  "$var.mod+z" "bearings-show"
666  "$var.mod+shift+z" "bearings-toggle"
667
668  # Trail navigation.
669  "$var.mod+," "trail-prev"
670  "$var.mod+." "trail-next"
671
672  # Focus cycling.
673  "alt+tab" "cycle-focus"
674  "alt+shift+tab" "cycle-focus-backward"
675
676  # Applications.
677  # `open-terminal` picks the first supported Wayland terminal in PATH.
678  "$var.mod+return" "open-terminal"
679  "$var.mod+d" "fuzzel"
680
681  # Mouse actions.
682  "$var.mod+leftmouse" "move-window"
683  "$var.mod+rightmouse" "resize-window"
684  "$var.mod+shift+leftmouse" "field-jump"
685
686  # Tile layout controls.
687  "$var.mod+left" "tile-focus left"
688  "$var.mod+right" "tile-focus right"
689  "$var.mod+up" "tile-focus up"
690  "$var.mod+down" "tile-focus down"
691
692  "$var.mod+ctrl+left" "tile-swap left"
693  "$var.mod+ctrl+right" "tile-swap right"
694  "$var.mod+ctrl+up" "tile-swap up"
695  "$var.mod+ctrl+down" "tile-swap down"
696
697  # Stacking layout controls.
698  "$var.mod+left" "stack-cycle forward"
699  "$var.mod+right" "stack-cycle backward"
700
701  # Screenshot UI
702  "$var.mod+shift+s" "halleyctl capture menu"
703
704  # Media keys.
705  "XF86AudioRaiseVolume" "wpctl set-volume -l 1 @default_audio_sink@ 5%+"
706  "XF86AudioLowerVolume" "wpctl set-volume @default_audio_sink@ 5%-"
707  "XF86AudioMute" "wpctl set-mute @default_audio_sink@ toggle"
708end
709
710# Rules let you special-case certain windows/apps.
711# This example keeps common Firefox file dialogs centered and floating.
712rules:
713  rule:
714    app-id "firefox"
715    title [r"File Upload.*", r"Open File.*", r"Save File.*", r"Choose.*"]
716    overlap-policy "all"
717    spawn-placement "center"
718    cluster-participation "float"
719  end
720end
721"##;
722
723fn render_viewport_section(tty_viewports: &[ViewportOutputConfig]) -> String {
724    if tty_viewports.is_empty() {
725        return [
726            "# A viewport represents one monitor/output.",
727            "# On first tty launch Halley writes the detected outputs here for you.",
728            "# If you want to manage monitors manually later, edit this section.",
729            "viewport:",
730            "end",
731            "",
732        ]
733        .join("\n");
734    }
735
736    let defaults = RuntimeTuning::builtin_defaults();
737    let default_focus_ring = FocusRingConfig {
738        rx: defaults.focus_ring_rx,
739        ry: defaults.focus_ring_ry,
740        offset_x: defaults.focus_ring_offset_x,
741        offset_y: defaults.focus_ring_offset_y,
742    };
743
744    let mut lines = vec![
745        "# A viewport represents one monitor/output.".to_string(),
746        "# On first tty launch Halley writes the detected outputs here for you.".to_string(),
747        "# If you want to manage monitors manually later, edit this section.".to_string(),
748        "viewport:".to_string(),
749    ];
750
751    for viewport in tty_viewports {
752        let focus_ring = viewport.focus_ring.unwrap_or(default_focus_ring);
753        lines.push(format!("  {}:", viewport.connector));
754        lines.push(format!("    enabled {}", viewport.enabled));
755        lines.push(String::new());
756        lines.push(format!("    offset-x {}", viewport.offset_x));
757        lines.push(format!("    offset-y {}", viewport.offset_y));
758        lines.push(String::new());
759        lines.push(format!("    width {}", viewport.width));
760        lines.push(format!("    height {}", viewport.height));
761        lines.push(String::new());
762        lines.push(format!(
763            "    rate {:.3}",
764            viewport.refresh_rate.unwrap_or(60.0)
765        ));
766        lines.push(format!("    transform {}", viewport.transform_degrees));
767        lines.push(format!("    vrr \"{}\"", viewport.vrr.as_str()));
768        lines.push("    # The focus ring is Halley's active zone.".to_string());
769        lines.push("    # Windows inside it stay more fully active.".to_string());
770        lines
771            .push("    # Windows outside it may decay into nodes depending on config.".to_string());
772        lines.push("    focus-ring:".to_string());
773        lines.push(format!("      primary-rx {:.1}", focus_ring.rx));
774        lines.push(format!("      primary-ry {:.1}", focus_ring.ry));
775        lines.push(format!("      offset-x {:.0}", focus_ring.offset_x));
776        lines.push(format!("      offset-y {:.0}", focus_ring.offset_y));
777        lines.push("    end".to_string());
778        lines.push("  end".to_string());
779    }
780
781    lines.extend([
782        "  # Example second monitor configuration.".to_string(),
783        "  # Uncomment and edit if needed.".to_string(),
784        "  #DP-2:".to_string(),
785        "  #  enabled true".to_string(),
786        "  #".to_string(),
787        "  #  offset-x 0".to_string(),
788        "  #  offset-y 0".to_string(),
789        "  #".to_string(),
790        "  #  width 1920".to_string(),
791        "  #  height 1200".to_string(),
792        "  #".to_string(),
793        "  #  rate 75.0".to_string(),
794        "  #  transform 0".to_string(),
795        "  #  vrr \"off\"".to_string(),
796        "  #".to_string(),
797        "  #  focus-ring:".to_string(),
798        format!("  #    primary-rx {:.1}", default_focus_ring.rx),
799        format!("  #    primary-ry {:.1}", default_focus_ring.ry),
800        format!("  #    offset-x {:.0}", default_focus_ring.offset_x),
801        format!("  #    offset-y {:.0}", default_focus_ring.offset_y),
802        "  #  end".to_string(),
803        "  #end".to_string(),
804    ]);
805
806    if let Some(last) = lines.last()
807        && !last.is_empty()
808    {
809        lines.push(String::new());
810    }
811
812    lines.push("end".to_string());
813    lines.push(String::new());
814    lines.join("\n")
815}
816
817#[cfg(test)]
818mod tests {
819    use super::*;
820
821    #[test]
822    fn total_window_border_footprint_includes_secondary_border_when_enabled() {
823        let mut tuning = RuntimeTuning::default();
824        assert_eq!(tuning.total_window_border_footprint_px(), 3);
825
826        tuning.decorations.secondary_border.enabled = true;
827        tuning.decorations.secondary_border.size_px = 2;
828        tuning.decorations.secondary_border.gap_px = 4;
829        assert_eq!(tuning.total_window_border_footprint_px(), 9);
830    }
831
832    #[test]
833    fn builtin_defaults_follow_internal_template() {
834        let tuning = RuntimeTuning::builtin_defaults();
835
836        assert_eq!(tuning.node_shape, ShapeStyle::Square);
837        assert_eq!(tuning.node_label_shape, ShapeStyle::Square);
838        assert_eq!(tuning.cursor.hide_after_ms, 2000);
839        assert_eq!(tuning.cluster_dwell_ms, 2000);
840        assert_eq!(tuning.field_active_windows_allowed, 5);
841        assert_eq!(tuning.input.repeat_rate, 30);
842        assert_eq!(tuning.input.repeat_delay, 500);
843        assert_eq!(
844            tuning.input.keyboard,
845            crate::layout::KeyboardConfig::default()
846        );
847        assert_eq!(tuning.animations.maximize.duration_ms, 240);
848    }
849
850    #[test]
851    fn render_fresh_config_includes_detected_viewports() {
852        let rendered = RuntimeTuning::render_fresh_config(&[ViewportOutputConfig {
853            connector: "DP-1".to_string(),
854            enabled: true,
855            offset_x: 0,
856            offset_y: 0,
857            width: 2560,
858            height: 1440,
859            refresh_rate: Some(180.0),
860            transform_degrees: 0,
861            vrr: crate::ViewportVrrMode::Off,
862            focus_ring: None,
863        }]);
864
865        assert!(rendered.contains("viewport:\n  DP-1:"));
866        assert!(rendered.contains("    rate 180.000"));
867        assert!(rendered.contains("# Example second monitor configuration."));
868        assert!(rendered.contains("    focus-ring:"));
869        assert!(rendered.contains("# Cursor settings apply to the compositor itself"));
870        assert!(rendered.contains("#gather \"colors.rune\""));
871        assert!(rendered.contains("  maximize:\n    enabled true\n    duration-ms 240"));
872        assert!(rendered.contains("  shadows:\n    window:"));
873        assert!(rendered.contains("      colour \"#05030530\""));
874        assert!(rendered.contains("\"$var.mod+1\" \"cluster slot 1\""));
875        assert!(rendered.contains("\"alt+tab\" \"cycle-focus\""));
876        assert!(
877            rendered.contains(
878                "input:\n  repeat-rate 30\n  repeat-delay 500\n  focus-mode \"click\"\n  keyboard:\n    layout \"us\"\n    variant \"\"\n    options \"\"\n  end\nend"
879            )
880        );
881    }
882
883    #[test]
884    fn render_fresh_config_without_outputs_keeps_documented_viewport_block() {
885        let rendered = RuntimeTuning::render_fresh_config(&[]);
886
887        assert!(
888            rendered.contains(
889                "# Autostart lets Halley launch bars, notifiers, and background helpers."
890            )
891        );
892        assert!(rendered.contains("viewport:\nend\n"));
893    }
894}