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}