mirror of
https://github.com/LGUG2Z/komorebi.git
synced 2026-05-19 02:06:57 +02:00
feat(wm): add global layout_defaults for per-layout default options
Adds a top-level layout_defaults setting that defines default layout_options and layout_options_rules per layout. Workspaces without their own layout_options or layout_options_rules automatically inherit the global defaults. If a workspace defines either setting, all global defaults for that layout are fully replaced.
This commit is contained in:
@@ -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.
|
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
|
## Progressive Ratio Behavior
|
||||||
|
|
||||||
Ratios are applied progressively as windows are added. For example, with `row_ratios: [0.3, 0.5]` in a VerticalStack:
|
Ratios are applied progressively as windows are added. For example, with `row_ratios: [0.3, 0.5]` in a VerticalStack:
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use clap::ValueEnum;
|
use clap::ValueEnum;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -54,7 +56,7 @@ pub fn validate_ratios(ratios: &[f32]) -> [Option<f32>; MAX_RATIOS] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(
|
#[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))]
|
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||||
/// A predefined komorebi layout
|
/// A predefined komorebi layout
|
||||||
@@ -250,6 +252,21 @@ pub struct GridLayoutOptions {
|
|||||||
pub rows: usize,
|
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<LayoutOptions>,
|
||||||
|
/// 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<HashMap<usize, LayoutOptions>>,
|
||||||
|
}
|
||||||
|
|
||||||
impl DefaultLayout {
|
impl DefaultLayout {
|
||||||
pub fn leftmost_index(&self, len: usize) -> usize {
|
pub fn leftmost_index(&self, len: usize) -> usize {
|
||||||
match self {
|
match self {
|
||||||
|
|||||||
@@ -433,3 +433,522 @@ mod layout_options_rules_tests {
|
|||||||
assert!(opts.grid.is_none());
|
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<DefaultLayout, &str> = 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<DefaultLayout, i32> = 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<DefaultLayout, LayoutDefaultEntry> =
|
||||||
|
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<LayoutOptions>,
|
||||||
|
workspace_rules: &[(usize, LayoutOptions)], // sorted by threshold ascending
|
||||||
|
global_base: Option<LayoutOptions>,
|
||||||
|
global_rules: &[(usize, LayoutOptions)], // sorted by threshold ascending
|
||||||
|
) -> Option<LayoutOptions> {
|
||||||
|
let has_workspace_overrides = workspace_base.is_some() || !workspace_rules.is_empty();
|
||||||
|
|
||||||
|
let (effective_base, effective_rules): (Option<LayoutOptions>, &[(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ pub use komorebi_layouts::DefaultLayout;
|
|||||||
pub use komorebi_layouts::Direction;
|
pub use komorebi_layouts::Direction;
|
||||||
pub use komorebi_layouts::GridLayoutOptions;
|
pub use komorebi_layouts::GridLayoutOptions;
|
||||||
pub use komorebi_layouts::Layout;
|
pub use komorebi_layouts::Layout;
|
||||||
|
pub use komorebi_layouts::LayoutDefaultEntry;
|
||||||
pub use komorebi_layouts::LayoutOptions;
|
pub use komorebi_layouts::LayoutOptions;
|
||||||
pub use komorebi_layouts::MAX_RATIO;
|
pub use komorebi_layouts::MAX_RATIO;
|
||||||
pub use komorebi_layouts::MAX_RATIOS;
|
pub use komorebi_layouts::MAX_RATIOS;
|
||||||
|
|||||||
@@ -238,6 +238,9 @@ lazy_static! {
|
|||||||
static ref FLOATING_WINDOW_TOGGLE_ASPECT_RATIO: Arc<Mutex<AspectRatio>> = Arc::new(Mutex::new(AspectRatio::Predefined(PredefinedAspectRatio::Widescreen)));
|
static ref FLOATING_WINDOW_TOGGLE_ASPECT_RATIO: Arc<Mutex<AspectRatio>> = Arc::new(Mutex::new(AspectRatio::Predefined(PredefinedAspectRatio::Widescreen)));
|
||||||
|
|
||||||
static ref CURRENT_VIRTUAL_DESKTOP: Arc<Mutex<Option<Vec<u8>>>> = Arc::new(Mutex::new(None));
|
static ref CURRENT_VIRTUAL_DESKTOP: Arc<Mutex<Option<Vec<u8>>>> = Arc::new(Mutex::new(None));
|
||||||
|
|
||||||
|
pub static ref LAYOUT_DEFAULTS: Arc<Mutex<HashMap<DefaultLayout, LayoutDefaultEntry>>> =
|
||||||
|
Arc::new(Mutex::new(HashMap::new()));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub static DEFAULT_WORKSPACE_PADDING: AtomicI32 = AtomicI32::new(10);
|
pub static DEFAULT_WORKSPACE_PADDING: AtomicI32 = AtomicI32::new(10);
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ impl From<&WindowManager> for State {
|
|||||||
layout_options: workspace.layout_options,
|
layout_options: workspace.layout_options,
|
||||||
layout_rules: workspace.layout_rules.clone(),
|
layout_rules: workspace.layout_rules.clone(),
|
||||||
layout_options_rules: workspace.layout_options_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(),
|
work_area_offset_rules: workspace.work_area_offset_rules.clone(),
|
||||||
layout_flip: workspace.layout_flip,
|
layout_flip: workspace.layout_flip,
|
||||||
workspace_padding: workspace.workspace_padding,
|
workspace_padding: workspace.workspace_padding,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use crate::FloatingLayerBehaviour;
|
|||||||
use crate::HIDING_BEHAVIOUR;
|
use crate::HIDING_BEHAVIOUR;
|
||||||
use crate::IGNORE_IDENTIFIERS;
|
use crate::IGNORE_IDENTIFIERS;
|
||||||
use crate::LAYERED_WHITELIST;
|
use crate::LAYERED_WHITELIST;
|
||||||
|
use crate::LAYOUT_DEFAULTS;
|
||||||
use crate::MANAGE_IDENTIFIERS;
|
use crate::MANAGE_IDENTIFIERS;
|
||||||
use crate::MONITOR_INDEX_PREFERENCES;
|
use crate::MONITOR_INDEX_PREFERENCES;
|
||||||
use crate::NO_TITLEBAR;
|
use crate::NO_TITLEBAR;
|
||||||
@@ -53,6 +54,7 @@ use crate::core::DefaultLayout;
|
|||||||
use crate::core::FocusFollowsMouseImplementation;
|
use crate::core::FocusFollowsMouseImplementation;
|
||||||
use crate::core::HidingBehaviour;
|
use crate::core::HidingBehaviour;
|
||||||
use crate::core::Layout;
|
use crate::core::Layout;
|
||||||
|
use crate::core::LayoutDefaultEntry;
|
||||||
use crate::core::LayoutOptions;
|
use crate::core::LayoutOptions;
|
||||||
use crate::core::MoveBehaviour;
|
use crate::core::MoveBehaviour;
|
||||||
use crate::core::OperationBehaviour;
|
use crate::core::OperationBehaviour;
|
||||||
@@ -593,6 +595,11 @@ pub struct StaticConfig {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[cfg_attr(feature = "schemars", schemars(extend("default" = DEFAULT_CONTAINER_PADDING)))]
|
#[cfg_attr(feature = "schemars", schemars(extend("default" = DEFAULT_CONTAINER_PADDING)))]
|
||||||
pub default_container_padding: Option<i32>,
|
pub default_container_padding: Option<i32>,
|
||||||
|
/// 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<HashMap<DefaultLayout, LayoutDefaultEntry>>,
|
||||||
/// Monitor and workspace configurations
|
/// Monitor and workspace configurations
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub monitors: Option<Vec<MonitorConfig>>,
|
pub monitors: Option<Vec<MonitorConfig>>,
|
||||||
@@ -902,6 +909,14 @@ impl From<&WindowManager> for StaticConfig {
|
|||||||
default_container_padding: Option::from(
|
default_container_padding: Option::from(
|
||||||
DEFAULT_CONTAINER_PADDING.load(Ordering::SeqCst),
|
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),
|
monitors: Option::from(monitors),
|
||||||
window_hiding_behaviour: Option::from(*HIDING_BEHAVIOUR.lock()),
|
window_hiding_behaviour: Option::from(*HIDING_BEHAVIOUR.lock()),
|
||||||
global_work_area_offset: value.work_area_offset,
|
global_work_area_offset: value.work_area_offset,
|
||||||
@@ -1017,6 +1032,12 @@ impl StaticConfig {
|
|||||||
DEFAULT_WORKSPACE_PADDING.store(workspace, Ordering::SeqCst);
|
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 {
|
if let Some(border_width) = self.border_width {
|
||||||
border_manager::BORDER_WIDTH.store(border_width, Ordering::SeqCst);
|
border_manager::BORDER_WIDTH.store(border_width, Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
@@ -1425,7 +1446,7 @@ impl StaticConfig {
|
|||||||
workspace_config.layout = Some(DefaultLayout::Columns);
|
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() {
|
for (j, ws) in m.workspaces_mut().iter_mut().enumerate() {
|
||||||
if let Some(workspace_config) = monitor_config.workspaces.get(j) {
|
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() {
|
for (j, ws) in monitor.workspaces_mut().iter_mut().enumerate() {
|
||||||
if let Some(workspace_config) = monitor_config.workspaces.get(j) {
|
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() {
|
for (j, ws) in m.workspaces_mut().iter_mut().enumerate() {
|
||||||
if let Some(workspace_config) = monitor_config.workspaces.get(j) {
|
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(),
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+104
-30
@@ -1,3 +1,4 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
@@ -25,6 +26,7 @@ use crate::core::CustomLayout;
|
|||||||
use crate::core::CycleDirection;
|
use crate::core::CycleDirection;
|
||||||
use crate::core::DefaultLayout;
|
use crate::core::DefaultLayout;
|
||||||
use crate::core::Layout;
|
use crate::core::Layout;
|
||||||
|
use crate::core::LayoutDefaultEntry;
|
||||||
use crate::core::LayoutOptions;
|
use crate::core::LayoutOptions;
|
||||||
use crate::core::OperationDirection;
|
use crate::core::OperationDirection;
|
||||||
use crate::core::Rect;
|
use crate::core::Rect;
|
||||||
@@ -65,6 +67,10 @@ pub struct Workspace {
|
|||||||
/// Sorted by threshold ascending at load time.
|
/// Sorted by threshold ascending at load time.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub layout_options_rules: Vec<(usize, LayoutOptions)>,
|
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<DefaultLayout, CachedLayoutDefault>,
|
||||||
pub work_area_offset_rules: Vec<(usize, Rect)>,
|
pub work_area_offset_rules: Vec<(usize, Rect)>,
|
||||||
pub layout_flip: Option<Axis>,
|
pub layout_flip: Option<Axis>,
|
||||||
pub workspace_padding: Option<i32>,
|
pub workspace_padding: Option<i32>,
|
||||||
@@ -124,6 +130,7 @@ impl Default for Workspace {
|
|||||||
layout_options: None,
|
layout_options: None,
|
||||||
layout_rules: vec![],
|
layout_rules: vec![],
|
||||||
layout_options_rules: vec![],
|
layout_options_rules: vec![],
|
||||||
|
layout_defaults_cache: HashMap::new(),
|
||||||
work_area_offset_rules: vec![],
|
work_area_offset_rules: vec![],
|
||||||
layout_flip: None,
|
layout_flip: None,
|
||||||
workspace_padding: Option::from(DEFAULT_WORKSPACE_PADDING.load(Ordering::SeqCst)),
|
workspace_padding: Option::from(DEFAULT_WORKSPACE_PADDING.load(Ordering::SeqCst)),
|
||||||
@@ -170,8 +177,49 @@ pub struct WorkspaceGlobals {
|
|||||||
pub floating_layer_behaviour: Option<FloatingLayerBehaviour>,
|
pub floating_layer_behaviour: Option<FloatingLayerBehaviour>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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<LayoutOptions>,
|
||||||
|
/// 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<usize, LayoutOptions>>,
|
||||||
|
) -> 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<LayoutOptions> {
|
||||||
|
rules
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find(|(threshold, _)| container_count >= *threshold)
|
||||||
|
.map(|(_, opts)| *opts)
|
||||||
|
}
|
||||||
|
|
||||||
impl Workspace {
|
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<DefaultLayout, LayoutDefaultEntry>>,
|
||||||
|
) -> eyre::Result<()> {
|
||||||
self.name = Option::from(config.name.clone());
|
self.name = Option::from(config.name.clone());
|
||||||
|
|
||||||
self.container_padding = config.container_padding;
|
self.container_padding = config.container_padding;
|
||||||
@@ -261,14 +309,8 @@ impl Workspace {
|
|||||||
self.layout_options = config.layout_options;
|
self.layout_options = config.layout_options;
|
||||||
|
|
||||||
// Load threshold-based layout options rules, sorted by threshold ascending
|
// Load threshold-based layout options rules, sorted by threshold ascending
|
||||||
self.layout_options_rules = if let Some(rules) = &config.layout_options_rules {
|
self.layout_options_rules =
|
||||||
let mut sorted_rules: Vec<(usize, LayoutOptions)> =
|
sorted_layout_options_rules(config.layout_options_rules.as_ref());
|
||||||
rules.iter().map(|(t, o)| (*t, *o)).collect();
|
|
||||||
sorted_rules.sort_by_key(|(t, _)| *t);
|
|
||||||
sorted_rules
|
|
||||||
} else {
|
|
||||||
vec![]
|
|
||||||
};
|
|
||||||
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"Workspace '{}' loaded layout_options: {:?}, layout_options_rules: {} entries",
|
"Workspace '{}' loaded layout_options: {:?}, layout_options_rules: {} entries",
|
||||||
@@ -277,11 +319,63 @@ impl Workspace {
|
|||||||
self.layout_options_rules.len(),
|
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());
|
self.workspace_config = Some(config.clone());
|
||||||
|
|
||||||
Ok(())
|
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<LayoutOptions> {
|
||||||
|
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<LayoutOptions>, &[(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<isize>) {
|
pub fn hide(&mut self, omit: Option<isize>) {
|
||||||
for window in self.floating_windows_mut().iter_mut().rev() {
|
for window in self.floating_windows_mut().iter_mut().rev() {
|
||||||
let mut should_hide = omit.is_none();
|
let mut should_hide = omit.is_none();
|
||||||
@@ -602,27 +696,7 @@ impl Workspace {
|
|||||||
} else if let Some(window) = &mut self.maximized_window {
|
} else if let Some(window) = &mut self.maximized_window {
|
||||||
window.maximize();
|
window.maximize();
|
||||||
} else if !self.containers().is_empty() {
|
} else if !self.containers().is_empty() {
|
||||||
// Compute effective layout options:
|
let effective_layout_options = self.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
|
|
||||||
};
|
|
||||||
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"Workspace '{}' update() - effective_layout_options: {:?} (base: {:?}, rules: {})",
|
"Workspace '{}' update() - effective_layout_options: {:?} (base: {:?}, rules: {})",
|
||||||
|
|||||||
+40
@@ -304,6 +304,16 @@
|
|||||||
"$ref": "#/$defs/MatchingRule"
|
"$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": {
|
"manage_rules": {
|
||||||
"description": "Individual window force-manage rules",
|
"description": "Individual window force-manage rules",
|
||||||
"type": [
|
"type": [
|
||||||
@@ -3290,6 +3300,36 @@
|
|||||||
"colours"
|
"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": {
|
"LayoutOptions": {
|
||||||
"description": "Options for specific layouts",
|
"description": "Options for specific layouts",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
Reference in New Issue
Block a user