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, 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}