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}