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