diff --git a/docs/common-workflows/layout-ratios.md b/docs/common-workflows/layout-ratios.md index d5e3f310..fe0dc149 100644 --- a/docs/common-workflows/layout-ratios.md +++ b/docs/common-workflows/layout-ratios.md @@ -237,6 +237,78 @@ not specified in the matching rule default to their standard defaults (not the b This increases the visible scrolling columns as more windows are added. +## Layout Defaults + +You can define global per-layout default `layout_options` and `layout_options_rules` using +the top-level `layout_defaults` setting. This avoids repeating the same configuration across +every workspace that uses the same layout. + +### Configuration + +```json +{ + "layout_defaults": { + "VerticalStack": { + "layout_options": { "column_ratios": [0.7] }, + "layout_options_rules": { + "2": { "column_ratios": [0.7] }, + "3": { "column_ratios": [0.55] }, + "5": { "column_ratios": [0.4] } + } + }, + "Columns": { + "layout_options": { "column_ratios": [0.3, 0.4] }, + "layout_options_rules": { + "4": { "column_ratios": [0.2, 0.3, 0.3] } + } + }, + "HorizontalStack": { + "layout_options": { "row_ratios": [0.6] } + } + }, + "monitors": [ + { + "workspaces": [ + { + "name": "main", + "layout": "VerticalStack" + } + ] + } + ] +} +``` + +In this example, every workspace using `VerticalStack`, `Columns`, or `HorizontalStack` +automatically gets the global `layout_options` and `layout_options_rules` without needing +to specify them per-workspace. Note that `VerticalStack` only has 2 columns (main + stack), +so only a single `column_ratios` value is meaningful, while `Columns` distributes windows +across multiple columns where additional ratios control each column's width. + +### Resolution Cascade + +Global defaults act as a fallback. If a workspace defines **either** `layout_options` or +`layout_options_rules`, it **completely replaces** all global `layout_defaults` for that +layout. Global defaults are only used when the workspace has **neither** setting. + +Within the effective source (workspace or global): +1. Try threshold match from the rules (highest matching threshold wins) +2. If a rule matches → use it (full replacement of base options) +3. Otherwise → use the base `layout_options` + +### Override Examples + +| Workspace Config | Global Config | Effective Behavior | +|------------------|---------------|--------------------| +| No `layout_options`, no rules | `layout_defaults` has both | Uses global base + global rules | +| Has `layout_options` only | `layout_defaults` has both | Workspace base only (all globals ignored) | +| Has `layout_options_rules` only | `layout_defaults` has both | Workspace rules only (all globals ignored) | +| Has both | `layout_defaults` has both | All workspace (all globals ignored) | + +This "complete replacement" semantic means you never get a mix of workspace and global +settings for the same layout. If you override anything at the workspace level, you take +full control of that layout's options for that workspace. + ## Progressive Ratio Behavior Ratios are applied progressively as windows are added. For example, with `row_ratios: [0.3, 0.5]` in a VerticalStack: diff --git a/komorebi-layouts/src/default_layout.rs b/komorebi-layouts/src/default_layout.rs index fd42773c..b53a32da 100644 --- a/komorebi-layouts/src/default_layout.rs +++ b/komorebi-layouts/src/default_layout.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use clap::ValueEnum; use serde::Deserialize; use serde::Serialize; @@ -54,7 +56,7 @@ pub fn validate_ratios(ratios: &[f32]) -> [Option; MAX_RATIOS] { } #[derive( - Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq, Display, EnumString, ValueEnum, + Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq, Hash, Display, EnumString, ValueEnum, )] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] /// A predefined komorebi layout @@ -250,6 +252,21 @@ pub struct GridLayoutOptions { pub rows: usize, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Per-layout default options entry for the `layout_defaults` global setting. +/// Contains both base layout options and threshold-based layout options rules. +pub struct LayoutDefaultEntry { + /// Default layout options for this layout + #[serde(skip_serializing_if = "Option::is_none")] + pub layout_options: Option, + /// Threshold-based layout options rules in the format of threshold => options. + /// When container count >= threshold, the highest matching threshold's options + /// fully replace the base `layout_options`. + #[serde(skip_serializing_if = "Option::is_none")] + pub layout_options_rules: Option>, +} + impl DefaultLayout { pub fn leftmost_index(&self, len: usize) -> usize { match self { diff --git a/komorebi-layouts/src/default_layout_tests.rs b/komorebi-layouts/src/default_layout_tests.rs index b4f22582..a94619a7 100644 --- a/komorebi-layouts/src/default_layout_tests.rs +++ b/komorebi-layouts/src/default_layout_tests.rs @@ -433,3 +433,522 @@ mod layout_options_rules_tests { assert!(opts.grid.is_none()); } } + +mod layout_default_entry_tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn test_default_layout_as_hashmap_key() { + let mut map: HashMap = HashMap::new(); + map.insert(DefaultLayout::BSP, "bsp"); + map.insert(DefaultLayout::VerticalStack, "vstack"); + map.insert(DefaultLayout::Columns, "cols"); + + assert_eq!(map.len(), 3); + assert_eq!(map[&DefaultLayout::BSP], "bsp"); + assert_eq!(map[&DefaultLayout::VerticalStack], "vstack"); + assert_eq!(map[&DefaultLayout::Columns], "cols"); + } + + #[test] + fn test_default_layout_hash_consistency() { + // Same variant inserted twice should overwrite + let mut map: HashMap = HashMap::new(); + map.insert(DefaultLayout::Grid, 1); + map.insert(DefaultLayout::Grid, 2); + assert_eq!(map.len(), 1); + assert_eq!(map[&DefaultLayout::Grid], 2); + } + + #[test] + fn test_layout_default_entry_deserialize_full() { + let json = r#"{ + "layout_options": {"column_ratios": [0.7]}, + "layout_options_rules": { + "2": {"column_ratios": [0.7]}, + "3": {"column_ratios": [0.55]}, + "5": {"column_ratios": [0.3, 0.3, 0.3]} + } + }"#; + let entry: LayoutDefaultEntry = serde_json::from_str(json).unwrap(); + + let base = entry.layout_options.unwrap(); + assert_eq!(base.column_ratios.unwrap()[0], Some(0.7)); + + let rules = entry.layout_options_rules.unwrap(); + assert_eq!(rules.len(), 3); + assert_eq!(rules[&2].column_ratios.unwrap()[0], Some(0.7)); + assert_eq!(rules[&3].column_ratios.unwrap()[0], Some(0.55)); + let r5 = rules[&5].column_ratios.unwrap(); + assert_eq!(r5[0], Some(0.3)); + assert_eq!(r5[1], Some(0.3)); + assert_eq!(r5[2], Some(0.3)); + } + + #[test] + fn test_layout_default_entry_deserialize_only_base() { + let json = r#"{ + "layout_options": {"column_ratios": [0.6]} + }"#; + let entry: LayoutDefaultEntry = serde_json::from_str(json).unwrap(); + + assert!(entry.layout_options.is_some()); + assert_eq!( + entry.layout_options.unwrap().column_ratios.unwrap()[0], + Some(0.6) + ); + assert!(entry.layout_options_rules.is_none()); + } + + #[test] + fn test_layout_default_entry_deserialize_only_rules() { + let json = r#"{ + "layout_options_rules": { + "3": {"column_ratios": [0.4]} + } + }"#; + let entry: LayoutDefaultEntry = serde_json::from_str(json).unwrap(); + + assert!(entry.layout_options.is_none()); + let rules = entry.layout_options_rules.unwrap(); + assert_eq!(rules.len(), 1); + assert_eq!(rules[&3].column_ratios.unwrap()[0], Some(0.4)); + } + + #[test] + fn test_layout_default_entry_deserialize_empty() { + let json = r#"{}"#; + let entry: LayoutDefaultEntry = serde_json::from_str(json).unwrap(); + assert!(entry.layout_options.is_none()); + assert!(entry.layout_options_rules.is_none()); + } + + #[test] + fn test_layout_default_entry_roundtrip() { + let json = r#"{ + "layout_options": {"column_ratios": [0.7]}, + "layout_options_rules": { + "2": {"column_ratios": [0.6]}, + "5": {"column_ratios": [0.3, 0.3, 0.3]} + } + }"#; + let original: LayoutDefaultEntry = serde_json::from_str(json).unwrap(); + let serialized = serde_json::to_string(&original).unwrap(); + let deserialized: LayoutDefaultEntry = serde_json::from_str(&serialized).unwrap(); + + assert_eq!( + original.layout_options.unwrap().column_ratios, + deserialized.layout_options.unwrap().column_ratios + ); + let orig_rules = original.layout_options_rules.unwrap(); + let deser_rules = deserialized.layout_options_rules.unwrap(); + assert_eq!(orig_rules.len(), deser_rules.len()); + for (key, orig_opts) in &orig_rules { + let deser_opts = &deser_rules[key]; + assert_eq!(orig_opts.column_ratios, deser_opts.column_ratios); + } + } + + #[test] + fn test_layout_defaults_full_config_deserialize() { + // Simulate the top-level layout_defaults field + let json = r#"{ + "VerticalStack": { + "layout_options": {"column_ratios": [0.7]}, + "layout_options_rules": { + "2": {"column_ratios": [0.7]}, + "3": {"column_ratios": [0.55]} + } + }, + "HorizontalStack": { + "layout_options": {"column_ratios": [0.6]} + }, + "Columns": { + "layout_options_rules": { + "4": {"column_ratios": [0.3, 0.3, 0.3]} + } + } + }"#; + let defaults: HashMap = + serde_json::from_str(json).unwrap(); + + assert_eq!(defaults.len(), 3); + + // VerticalStack: has both base and rules + let vs = &defaults[&DefaultLayout::VerticalStack]; + assert!(vs.layout_options.is_some()); + assert_eq!(vs.layout_options_rules.as_ref().unwrap().len(), 2); + + // HorizontalStack: has only base + let hs = &defaults[&DefaultLayout::HorizontalStack]; + assert!(hs.layout_options.is_some()); + assert!(hs.layout_options_rules.is_none()); + + // Columns: has only rules + let cols = &defaults[&DefaultLayout::Columns]; + assert!(cols.layout_options.is_none()); + assert_eq!(cols.layout_options_rules.as_ref().unwrap().len(), 1); + } + + #[test] + fn test_layout_default_entry_with_scrolling_and_grid() { + let json = r#"{ + "layout_options": { + "column_ratios": [0.5], + "scrolling": {"columns": 3}, + "grid": {"rows": 2} + }, + "layout_options_rules": { + "4": { + "scrolling": {"columns": 5, "center_focused_column": true} + } + } + }"#; + let entry: LayoutDefaultEntry = serde_json::from_str(json).unwrap(); + + let base = entry.layout_options.unwrap(); + assert_eq!(base.scrolling.unwrap().columns, 3); + assert_eq!(base.grid.unwrap().rows, 2); + + let rules = entry.layout_options_rules.unwrap(); + let r4 = &rules[&4]; + assert_eq!(r4.scrolling.unwrap().columns, 5); + assert_eq!(r4.scrolling.unwrap().center_focused_column, Some(true)); + // Rule doesn't inherit base fields - full replacement + assert!(r4.column_ratios.is_none()); + assert!(r4.grid.is_none()); + } + + #[test] + fn test_layout_default_entry_skip_serializing_none() { + // When both fields are None, they should not appear in output + let entry = LayoutDefaultEntry { + layout_options: None, + layout_options_rules: None, + }; + let json = serde_json::to_string(&entry).unwrap(); + assert!(!json.contains("layout_options")); + assert!(!json.contains("layout_options_rules")); + assert_eq!(json, "{}"); + } +} + +/// Tests for the complete-replacement cascade logic. +/// +/// This mirrors the resolution algorithm in workspace.rs::update(): +/// - If the workspace defines EITHER layout_options OR layout_options_rules, +/// it completely replaces the global layout_defaults for this layout. +/// - Global defaults are only used when the workspace has NEITHER setting. +/// - Within the effective source (workspace or global): +/// 1. Try threshold match from rules (highest matching threshold wins) +/// 2. If a rule matches -> use it (full replacement of base) +/// 3. Else -> use the base layout_options +/// +/// Since the actual cascade is in workspace.rs (which has heavy WM dependencies), +/// we test the pure algorithm here using the same data structures. +mod cascade_resolution_tests { + use super::*; + + /// Simulates the cascade resolution logic from workspace.rs::update(). + /// This is a pure function equivalent of the inline code in update(). + fn resolve_effective_options( + container_count: usize, + workspace_base: Option, + workspace_rules: &[(usize, LayoutOptions)], // sorted by threshold ascending + global_base: Option, + global_rules: &[(usize, LayoutOptions)], // sorted by threshold ascending + ) -> Option { + let has_workspace_overrides = workspace_base.is_some() || !workspace_rules.is_empty(); + + let (effective_base, effective_rules): (Option, &[(usize, LayoutOptions)]) = + if has_workspace_overrides { + (workspace_base, workspace_rules) + } else { + (global_base, global_rules) + }; + + // Try threshold match from effective rules + let mut matched = None; + for (threshold, opts) in effective_rules { + if container_count >= *threshold { + matched = Some(*opts); + } + } + + // If a rule matched, use it (full replacement); otherwise use effective base + if matched.is_some() { + matched + } else { + effective_base + } + } + + fn opts_with_ratio(ratio: f32) -> LayoutOptions { + layout_options_with_column_ratios(&[ratio]) + } + + // --- No overrides --- + + #[test] + fn test_no_workspace_no_global_returns_none() { + let result = resolve_effective_options(3, None, &[], None, &[]); + assert!(result.is_none()); + } + + // --- Base-only scenarios --- + + #[test] + fn test_workspace_base_only() { + let ws_base = opts_with_ratio(0.7); + let result = resolve_effective_options(3, Some(ws_base), &[], None, &[]); + assert_eq!(result.unwrap().column_ratios, ws_base.column_ratios); + } + + #[test] + fn test_global_base_only() { + let global_base = opts_with_ratio(0.6); + let result = resolve_effective_options(3, None, &[], Some(global_base), &[]); + assert_eq!(result.unwrap().column_ratios, global_base.column_ratios); + } + + #[test] + fn test_workspace_base_overrides_all_globals() { + // Workspace has base → globals (both base and rules) are ignored entirely + let ws_base = opts_with_ratio(0.7); + let global_base = opts_with_ratio(0.6); + let global_rules = vec![(2, opts_with_ratio(0.5))]; + let result = + resolve_effective_options(3, Some(ws_base), &[], Some(global_base), &global_rules); + // Workspace base wins; global rules are NOT used even though they would match + assert_eq!(result.unwrap().column_ratios, ws_base.column_ratios); + } + + // --- Rules-only scenarios --- + + #[test] + fn test_global_rules_match() { + let global_rules = vec![(2, opts_with_ratio(0.6)), (4, opts_with_ratio(0.5))]; + // 3 containers: matches threshold 2, not 4 + let result = resolve_effective_options(3, None, &[], None, &global_rules); + assert_eq!(result.unwrap().column_ratios.unwrap()[0], Some(0.6)); + } + + #[test] + fn test_global_rules_highest_matching_threshold_wins() { + let global_rules = vec![(2, opts_with_ratio(0.6)), (4, opts_with_ratio(0.5))]; + // 5 containers: matches both thresholds 2 and 4; highest (4) wins + let result = resolve_effective_options(5, None, &[], None, &global_rules); + assert_eq!(result.unwrap().column_ratios.unwrap()[0], Some(0.5)); + } + + #[test] + fn test_global_rules_no_match_falls_through_to_none() { + let global_rules = vec![(5, opts_with_ratio(0.5))]; + // 3 containers: doesn't match threshold 5 + let result = resolve_effective_options(3, None, &[], None, &global_rules); + assert!(result.is_none()); + } + + #[test] + fn test_global_rules_no_match_falls_through_to_global_base() { + let global_base = opts_with_ratio(0.6); + let global_rules = vec![(5, opts_with_ratio(0.5))]; + // 3 containers: doesn't match threshold 5, falls back to global base + let result = resolve_effective_options(3, None, &[], Some(global_base), &global_rules); + assert_eq!(result.unwrap().column_ratios, global_base.column_ratios); + } + + #[test] + fn test_workspace_rules_override_global_rules() { + let ws_rules = vec![(2, opts_with_ratio(0.8))]; + let global_rules = vec![(2, opts_with_ratio(0.6))]; + // Workspace has rules → global rules are ignored entirely + let result = resolve_effective_options(3, None, &ws_rules, None, &global_rules); + assert_eq!(result.unwrap().column_ratios.unwrap()[0], Some(0.8)); + } + + // --- Complete replacement: workspace having EITHER setting disables ALL globals --- + + #[test] + fn test_workspace_rules_disable_global_base() { + // Workspace has rules but no base. Global has base. + // Since workspace has a setting, globals are completely replaced. + let ws_rules = vec![(2, opts_with_ratio(0.8))]; + let global_base = opts_with_ratio(0.6); + // Rule matches → use it. Global base is NOT available as fallback. + let result = resolve_effective_options(3, None, &ws_rules, Some(global_base), &[]); + assert_eq!(result.unwrap().column_ratios.unwrap()[0], Some(0.8)); + } + + #[test] + fn test_workspace_rules_no_match_does_not_fall_to_global_base() { + // Workspace has rules (but they don't match). Global has base. + // Since workspace has a setting, globals are completely replaced → returns None. + let ws_rules = vec![(5, opts_with_ratio(0.8))]; + let global_base = opts_with_ratio(0.6); + let result = resolve_effective_options(3, None, &ws_rules, Some(global_base), &[]); + // No workspace base, no rule match, globals ignored → None + assert!(result.is_none()); + } + + #[test] + fn test_workspace_base_disables_global_rules() { + // Workspace has base but no rules. Global has rules. + // Since workspace has a setting, globals are completely replaced. + let ws_base = opts_with_ratio(0.7); + let global_rules = vec![(2, opts_with_ratio(0.5))]; + // No workspace rules → no rule match → use workspace base. Global rules ignored. + let result = resolve_effective_options(3, Some(ws_base), &[], None, &global_rules); + assert_eq!(result.unwrap().column_ratios, ws_base.column_ratios); + } + + #[test] + fn test_workspace_base_disables_global_rules_and_base() { + // Workspace has base. Global has both rules and base. + // Since workspace has a setting, all globals are completely replaced. + let ws_base = opts_with_ratio(0.7); + let global_base = opts_with_ratio(0.6); + let global_rules = vec![(2, opts_with_ratio(0.5))]; + let result = + resolve_effective_options(3, Some(ws_base), &[], Some(global_base), &global_rules); + // Only workspace base is used; global rules and base are both ignored + assert_eq!(result.unwrap().column_ratios, ws_base.column_ratios); + } + + #[test] + fn test_workspace_rules_disable_global_rules_and_base() { + // Workspace has rules. Global has both rules and base. + // Since workspace has a setting, all globals are completely replaced. + let ws_rules = vec![(2, opts_with_ratio(0.8))]; + let global_base = opts_with_ratio(0.6); + let global_rules = vec![(2, opts_with_ratio(0.5))]; + let result = + resolve_effective_options(3, None, &ws_rules, Some(global_base), &global_rules); + // Workspace rule matches → 0.8. Global base and rules both ignored. + assert_eq!(result.unwrap().column_ratios.unwrap()[0], Some(0.8)); + } + + // --- Full replacement semantics (rule match replaces base) --- + + #[test] + fn test_rule_match_is_full_replacement_not_merge() { + // When a rule matches, its options FULLY REPLACE the base. + // Fields not specified in the rule default to their standard defaults. + let ws_base = layout_options_with_ratios(&[0.7], &[0.4]); + let rule_opts = layout_options_with_column_ratios(&[0.5]); + // rule_opts has column_ratios but no row_ratios + let ws_rules = vec![(2, rule_opts)]; + let result = resolve_effective_options(3, Some(ws_base), &ws_rules, None, &[]); + let effective = result.unwrap(); + // Column ratios come from the rule + assert_eq!(effective.column_ratios.unwrap()[0], Some(0.5)); + // Row ratios are NOT inherited from ws_base - they're None (full replacement) + assert!(effective.row_ratios.is_none()); + } + + // --- Edge cases --- + + #[test] + fn test_exact_threshold_match() { + let rules = vec![(3, opts_with_ratio(0.6))]; + let result = resolve_effective_options(3, None, &rules, None, &[]); + assert_eq!(result.unwrap().column_ratios.unwrap()[0], Some(0.6)); + } + + #[test] + fn test_container_count_one_below_threshold() { + let rules = vec![(3, opts_with_ratio(0.6))]; + let result = resolve_effective_options(2, None, &rules, None, &[]); + assert!(result.is_none()); + } + + #[test] + fn test_zero_containers() { + let ws_base = opts_with_ratio(0.7); + let rules = vec![(1, opts_with_ratio(0.5))]; + let result = resolve_effective_options(0, Some(ws_base), &rules, None, &[]); + // 0 containers doesn't match threshold 1 → falls back to workspace base + assert_eq!(result.unwrap().column_ratios, ws_base.column_ratios); + } + + #[test] + fn test_many_thresholds_correct_match() { + let rules = vec![ + (1, opts_with_ratio(0.8)), + (3, opts_with_ratio(0.6)), + (5, opts_with_ratio(0.4)), + (8, opts_with_ratio(0.3)), + ]; + // 6 containers: matches 1, 3, 5 but not 8. Highest match is 5. + let result = resolve_effective_options(6, None, &rules, None, &[]); + assert_eq!(result.unwrap().column_ratios.unwrap()[0], Some(0.4)); + } + + #[test] + fn test_workspace_rules_disable_global_rules_even_if_ws_rules_dont_match() { + // Key behavior: if workspace has ANY setting, globals are entirely ignored. + // Even if workspace rules don't match, we don't fall back to global rules. + let ws_rules = vec![(10, opts_with_ratio(0.8))]; // threshold too high + let global_rules = vec![(2, opts_with_ratio(0.5))]; // would match + let result = resolve_effective_options(3, None, &ws_rules, None, &global_rules); + // Workspace has rules → all globals ignored. WS rules don't match → None. + assert!(result.is_none()); + } + + #[test] + fn test_all_four_sources_present_rules_match() { + // All four sources present: workspace base, workspace rules, global base, global rules + let ws_base = opts_with_ratio(0.7); + let ws_rules = vec![(2, opts_with_ratio(0.8))]; + let global_base = opts_with_ratio(0.6); + let global_rules = vec![(2, opts_with_ratio(0.5))]; + let result = resolve_effective_options( + 3, + Some(ws_base), + &ws_rules, + Some(global_base), + &global_rules, + ); + // Workspace has settings → uses workspace only. Rule matches → 0.8 + assert_eq!(result.unwrap().column_ratios.unwrap()[0], Some(0.8)); + } + + #[test] + fn test_all_four_sources_present_rules_no_match() { + // All four sources present, but workspace rules don't match + let ws_base = opts_with_ratio(0.7); + let ws_rules = vec![(10, opts_with_ratio(0.8))]; // threshold too high + let global_base = opts_with_ratio(0.6); + let global_rules = vec![(10, opts_with_ratio(0.5))]; // also too high + let result = resolve_effective_options( + 3, + Some(ws_base), + &ws_rules, + Some(global_base), + &global_rules, + ); + // Workspace has settings → uses workspace only. No rule match → workspace base 0.7 + assert_eq!(result.unwrap().column_ratios, ws_base.column_ratios); + } + + // --- Workspace with both base and rules --- + + #[test] + fn test_workspace_both_rule_matches() { + let ws_base = opts_with_ratio(0.7); + let ws_rules = vec![(2, opts_with_ratio(0.5))]; + let result = resolve_effective_options(3, Some(ws_base), &ws_rules, None, &[]); + // Rule matches → use rule (full replacement), not ws_base + assert_eq!(result.unwrap().column_ratios.unwrap()[0], Some(0.5)); + } + + #[test] + fn test_workspace_both_rule_no_match() { + let ws_base = opts_with_ratio(0.7); + let ws_rules = vec![(10, opts_with_ratio(0.5))]; + let result = resolve_effective_options(3, Some(ws_base), &ws_rules, None, &[]); + // Rule doesn't match → fall back to ws_base + assert_eq!(result.unwrap().column_ratios, ws_base.column_ratios); + } +} diff --git a/komorebi/src/core/mod.rs b/komorebi/src/core/mod.rs index 9357ce80..e66e023b 100644 --- a/komorebi/src/core/mod.rs +++ b/komorebi/src/core/mod.rs @@ -32,6 +32,7 @@ pub use komorebi_layouts::DefaultLayout; pub use komorebi_layouts::Direction; pub use komorebi_layouts::GridLayoutOptions; pub use komorebi_layouts::Layout; +pub use komorebi_layouts::LayoutDefaultEntry; pub use komorebi_layouts::LayoutOptions; pub use komorebi_layouts::MAX_RATIO; pub use komorebi_layouts::MAX_RATIOS; diff --git a/komorebi/src/lib.rs b/komorebi/src/lib.rs index ac672e5f..1824e6a7 100644 --- a/komorebi/src/lib.rs +++ b/komorebi/src/lib.rs @@ -238,6 +238,9 @@ lazy_static! { static ref FLOATING_WINDOW_TOGGLE_ASPECT_RATIO: Arc> = Arc::new(Mutex::new(AspectRatio::Predefined(PredefinedAspectRatio::Widescreen))); static ref CURRENT_VIRTUAL_DESKTOP: Arc>>> = Arc::new(Mutex::new(None)); + + pub static ref LAYOUT_DEFAULTS: Arc>> = + Arc::new(Mutex::new(HashMap::new())); } pub static DEFAULT_WORKSPACE_PADDING: AtomicI32 = AtomicI32::new(10); diff --git a/komorebi/src/state.rs b/komorebi/src/state.rs index 5a16c926..8bc8a373 100644 --- a/komorebi/src/state.rs +++ b/komorebi/src/state.rs @@ -254,6 +254,7 @@ impl From<&WindowManager> for State { layout_options: workspace.layout_options, layout_rules: workspace.layout_rules.clone(), layout_options_rules: workspace.layout_options_rules.clone(), + layout_defaults_cache: workspace.layout_defaults_cache.clone(), work_area_offset_rules: workspace.work_area_offset_rules.clone(), layout_flip: workspace.layout_flip, workspace_padding: workspace.workspace_padding, diff --git a/komorebi/src/static_config.rs b/komorebi/src/static_config.rs index c22d8deb..58b89d4a 100644 --- a/komorebi/src/static_config.rs +++ b/komorebi/src/static_config.rs @@ -13,6 +13,7 @@ use crate::FloatingLayerBehaviour; use crate::HIDING_BEHAVIOUR; use crate::IGNORE_IDENTIFIERS; use crate::LAYERED_WHITELIST; +use crate::LAYOUT_DEFAULTS; use crate::MANAGE_IDENTIFIERS; use crate::MONITOR_INDEX_PREFERENCES; use crate::NO_TITLEBAR; @@ -53,6 +54,7 @@ use crate::core::DefaultLayout; use crate::core::FocusFollowsMouseImplementation; use crate::core::HidingBehaviour; use crate::core::Layout; +use crate::core::LayoutDefaultEntry; use crate::core::LayoutOptions; use crate::core::MoveBehaviour; use crate::core::OperationBehaviour; @@ -593,6 +595,11 @@ pub struct StaticConfig { #[serde(skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "schemars", schemars(extend("default" = DEFAULT_CONTAINER_PADDING)))] pub default_container_padding: Option, + /// Per-layout default options and rules, keyed by layout name. + /// Applied as fallback when a workspace does not define its own layout_options or layout_options_rules. + /// If a workspace defines either setting, all global defaults for that layout are completely replaced. + #[serde(skip_serializing_if = "Option::is_none")] + pub layout_defaults: Option>, /// Monitor and workspace configurations #[serde(skip_serializing_if = "Option::is_none")] pub monitors: Option>, @@ -902,6 +909,14 @@ impl From<&WindowManager> for StaticConfig { default_container_padding: Option::from( DEFAULT_CONTAINER_PADDING.load(Ordering::SeqCst), ), + layout_defaults: { + let guard = LAYOUT_DEFAULTS.lock(); + if guard.is_empty() { + None + } else { + Some(guard.clone()) + } + }, monitors: Option::from(monitors), window_hiding_behaviour: Option::from(*HIDING_BEHAVIOUR.lock()), global_work_area_offset: value.work_area_offset, @@ -1017,6 +1032,12 @@ impl StaticConfig { DEFAULT_WORKSPACE_PADDING.store(workspace, Ordering::SeqCst); } + if let Some(defaults) = &self.layout_defaults { + *LAYOUT_DEFAULTS.lock() = defaults.clone(); + } else { + LAYOUT_DEFAULTS.lock().clear(); + } + if let Some(border_width) = self.border_width { border_manager::BORDER_WIDTH.store(border_width, Ordering::SeqCst); } @@ -1425,7 +1446,7 @@ impl StaticConfig { workspace_config.layout = Some(DefaultLayout::Columns); } - ws.load_static_config(workspace_config)?; + ws.load_static_config(workspace_config, value.layout_defaults.as_ref())?; } } @@ -1508,7 +1529,10 @@ impl StaticConfig { for (j, ws) in m.workspaces_mut().iter_mut().enumerate() { if let Some(workspace_config) = monitor_config.workspaces.get(j) { - ws.load_static_config(workspace_config)?; + ws.load_static_config( + workspace_config, + value.layout_defaults.as_ref(), + )?; } } @@ -1590,7 +1614,7 @@ impl StaticConfig { for (j, ws) in monitor.workspaces_mut().iter_mut().enumerate() { if let Some(workspace_config) = monitor_config.workspaces.get(j) { - ws.load_static_config(workspace_config)?; + ws.load_static_config(workspace_config, value.layout_defaults.as_ref())?; } } @@ -1673,7 +1697,10 @@ impl StaticConfig { for (j, ws) in m.workspaces_mut().iter_mut().enumerate() { if let Some(workspace_config) = monitor_config.workspaces.get(j) { - ws.load_static_config(workspace_config)?; + ws.load_static_config( + workspace_config, + value.layout_defaults.as_ref(), + )?; } } diff --git a/komorebi/src/workspace.rs b/komorebi/src/workspace.rs index b6dc2fb7..53e06aaf 100644 --- a/komorebi/src/workspace.rs +++ b/komorebi/src/workspace.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::collections::VecDeque; use std::ffi::OsStr; use std::fmt::Display; @@ -25,6 +26,7 @@ use crate::core::CustomLayout; use crate::core::CycleDirection; use crate::core::DefaultLayout; use crate::core::Layout; +use crate::core::LayoutDefaultEntry; use crate::core::LayoutOptions; use crate::core::OperationDirection; use crate::core::Rect; @@ -65,6 +67,10 @@ pub struct Workspace { /// Sorted by threshold ascending at load time. #[serde(default)] pub layout_options_rules: Vec<(usize, LayoutOptions)>, + /// Cached per-layout defaults from the global `layout_defaults` config setting. + /// Pre-sorted at config load time; used as fallback when workspace has no overrides. + #[serde(skip)] + pub(crate) layout_defaults_cache: HashMap, pub work_area_offset_rules: Vec<(usize, Rect)>, pub layout_flip: Option, pub workspace_padding: Option, @@ -124,6 +130,7 @@ impl Default for Workspace { layout_options: None, layout_rules: vec![], layout_options_rules: vec![], + layout_defaults_cache: HashMap::new(), work_area_offset_rules: vec![], layout_flip: None, workspace_padding: Option::from(DEFAULT_WORKSPACE_PADDING.load(Ordering::SeqCst)), @@ -170,8 +177,49 @@ pub struct WorkspaceGlobals { pub floating_layer_behaviour: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +/// Cached per-layout default options (pre-sorted rules) derived from the global `layout_defaults`. +pub(crate) struct CachedLayoutDefault { + pub layout_options: Option, + /// Threshold-based rules, sorted by threshold ascending at load time + pub layout_options_rules: Vec<(usize, LayoutOptions)>, +} + +/// Convert an optional HashMap of threshold-based layout options rules into a Vec sorted by +/// threshold ascending. +fn sorted_layout_options_rules( + rules: Option<&HashMap>, +) -> Vec<(usize, LayoutOptions)> { + match rules { + Some(rules) => { + let mut sorted: Vec<(usize, LayoutOptions)> = + rules.iter().map(|(t, o)| (*t, *o)).collect(); + sorted.sort_by_key(|(t, _)| *t); + sorted + } + None => vec![], + } +} + +/// Find the highest matching threshold rule for the given container count. +/// Rules must be sorted by threshold ascending. +fn resolve_threshold_match( + rules: &[(usize, LayoutOptions)], + container_count: usize, +) -> Option { + rules + .iter() + .rev() + .find(|(threshold, _)| container_count >= *threshold) + .map(|(_, opts)| *opts) +} + impl Workspace { - pub fn load_static_config(&mut self, config: &WorkspaceConfig) -> eyre::Result<()> { + pub fn load_static_config( + &mut self, + config: &WorkspaceConfig, + layout_defaults: Option<&HashMap>, + ) -> eyre::Result<()> { self.name = Option::from(config.name.clone()); self.container_padding = config.container_padding; @@ -261,14 +309,8 @@ impl Workspace { self.layout_options = config.layout_options; // Load threshold-based layout options rules, sorted by threshold ascending - self.layout_options_rules = if let Some(rules) = &config.layout_options_rules { - let mut sorted_rules: Vec<(usize, LayoutOptions)> = - rules.iter().map(|(t, o)| (*t, *o)).collect(); - sorted_rules.sort_by_key(|(t, _)| *t); - sorted_rules - } else { - vec![] - }; + self.layout_options_rules = + sorted_layout_options_rules(config.layout_options_rules.as_ref()); tracing::debug!( "Workspace '{}' loaded layout_options: {:?}, layout_options_rules: {} entries", @@ -277,11 +319,63 @@ impl Workspace { self.layout_options_rules.len(), ); + // Cache per-layout defaults from global layout_defaults, pre-sorting rules + self.layout_defaults_cache = if let Some(defaults) = layout_defaults { + defaults + .iter() + .map(|(layout, entry)| { + ( + *layout, + CachedLayoutDefault { + layout_options: entry.layout_options, + layout_options_rules: sorted_layout_options_rules( + entry.layout_options_rules.as_ref(), + ), + }, + ) + }) + .collect() + } else { + HashMap::new() + }; + self.workspace_config = Some(config.clone()); Ok(()) } + /// Compute effective layout options using the complete-replacement cascade: + /// + /// If the workspace defines EITHER `layout_options` OR `layout_options_rules`, + /// it completely replaces the global `layout_defaults` for this layout. + /// Global defaults are only used when the workspace has NEITHER setting. + /// + /// Within the effective source (workspace or global): + /// 1. Try threshold match from rules (highest matching threshold wins) + /// 2. If a rule matches -> use it (full replacement of base) + /// 3. Else -> use the base `layout_options` + fn effective_layout_options(&self) -> Option { + let container_count = self.containers().len(); + + let has_workspace_overrides = + self.layout_options.is_some() || !self.layout_options_rules.is_empty(); + + let (effective_base, effective_rules): (Option, &[(usize, LayoutOptions)]) = + if has_workspace_overrides { + (self.layout_options, &self.layout_options_rules) + } else { + match &self.layout { + Layout::Default(dl) => match self.layout_defaults_cache.get(dl) { + Some(entry) => (entry.layout_options, &entry.layout_options_rules), + None => (None, &[]), + }, + Layout::Custom(_) => (None, &[]), + } + }; + + resolve_threshold_match(effective_rules, container_count).or(effective_base) + } + pub fn hide(&mut self, omit: Option) { for window in self.floating_windows_mut().iter_mut().rev() { let mut should_hide = omit.is_none(); @@ -602,27 +696,7 @@ impl Workspace { } else if let Some(window) = &mut self.maximized_window { window.maximize(); } else if !self.containers().is_empty() { - // Compute effective layout options: - // 1. If layout_options_rules has a matching threshold (container_count >= threshold), - // use the highest matching threshold's options (full replacement) - // 2. Otherwise, use the workspace base layout_options - let effective_layout_options = if !self.layout_options_rules.is_empty() { - let container_count = self.containers().len(); - let mut matched = None; - for (threshold, opts) in &self.layout_options_rules { - if container_count >= *threshold { - matched = Some(*opts); - } - } - // If a rule matched, use it entirely (replaces base layout_options) - if matched.is_some() { - matched - } else { - self.layout_options - } - } else { - self.layout_options - }; + let effective_layout_options = self.effective_layout_options(); tracing::debug!( "Workspace '{}' update() - effective_layout_options: {:?} (base: {:?}, rules: {})", diff --git a/schema.json b/schema.json index 340eba02..5e582ab6 100644 --- a/schema.json +++ b/schema.json @@ -304,6 +304,16 @@ "$ref": "#/$defs/MatchingRule" } }, + "layout_defaults": { + "description": "Per-layout default options and rules, keyed by layout name.\nApplied as fallback when a workspace does not define its own layout_options or layout_options_rules.\nIf a workspace defines either setting, all global defaults for that layout are completely replaced.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "$ref": "#/$defs/LayoutDefaultEntry" + } + }, "manage_rules": { "description": "Individual window force-manage rules", "type": [ @@ -3290,6 +3300,36 @@ "colours" ] }, + "LayoutDefaultEntry": { + "description": "Per-layout default options entry for the `layout_defaults` global setting.\nContains both base layout options and threshold-based layout options rules.", + "type": "object", + "properties": { + "layout_options": { + "description": "Default layout options for this layout", + "anyOf": [ + { + "$ref": "#/$defs/LayoutOptions" + }, + { + "type": "null" + } + ] + }, + "layout_options_rules": { + "description": "Threshold-based layout options rules in the format of threshold => options.\nWhen container count >= threshold, the highest matching threshold's options\nfully replace the base `layout_options`.", + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "patternProperties": { + "^\\d+$": { + "$ref": "#/$defs/LayoutOptions" + } + } + } + } + }, "LayoutOptions": { "description": "Options for specific layouts", "type": "object",