Skip to main content

halley_config/layout/
runtime.rs

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