Compare commits

...

2 Commits

Author SHA1 Message Date
Csaba
24c0ce0b1d 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.
2026-05-03 10:16:20 -07:00
Csaba
d6b17bbc7c feat(wm): add threshold-based layout_options_rules for workspaces
Adds layout_options_rules to workspace configuration, allowing
layout_options to dynamically change based on container count. Uses the
same threshold semantics as layout_rules: when container count >=
threshold, the highest matching rule fully replaces the base
layout_options.
2026-05-03 10:16:18 -07:00
10 changed files with 1367 additions and 378 deletions

View File

@@ -172,6 +172,143 @@ consistently to all splits of that type throughout the layout. Additional values
- Unspecified ratios default to sharing the remaining space equally
- You only need to specify the ratios you want to customize; trailing values can be omitted
## Layout Options Rules
You can dynamically change `layout_options` based on the number of containers on a workspace
using `layout_options_rules`. This uses the same threshold-based logic as `layout_rules`:
when the container count is greater than or equal to a threshold, the highest matching
threshold's options are used.
Rules **fully replace** the base `layout_options` when they match. If no rule matches, the
base `layout_options` is used.
### Configuration
```json
{
"monitors": [
{
"workspaces": [
{
"name": "main",
"layout": "VerticalStack",
"layout_options": {
"column_ratios": [0.6],
"row_ratios": [0.4]
},
"layout_options_rules": {
"3": { "column_ratios": [0.55] },
"5": { "column_ratios": [0.3, 0.3, 0.3], "row_ratios": [0.5] }
}
}
]
}
]
}
```
In the example above:
| Container Count | Effective `layout_options` |
|-----------------|---------------------------|
| 1-2 | Base: `column_ratios: [0.6]`, `row_ratios: [0.4]` |
| 3-4 | Rule "3": `column_ratios: [0.55]` (no row_ratios, no scrolling, no grid) |
| 5+ | Rule "5": `column_ratios: [0.3, 0.3, 0.3]`, `row_ratios: [0.5]` |
Rules can include any field that `layout_options` supports: `column_ratios`, `row_ratios`,
`scrolling`, and `grid`. When a rule matches, it completely replaces the base options. Fields
not specified in the matching rule default to their standard defaults (not the base
`layout_options` values).
### Example: Scrolling Layout with Dynamic Columns
```json
{
"layout": "Scrolling",
"layout_options": {
"scrolling": { "columns": 2 }
},
"layout_options_rules": {
"4": { "scrolling": { "columns": 3 } },
"7": { "scrolling": { "columns": 4 } }
}
}
```
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:

View File

@@ -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<f32>; 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<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 {
pub fn leftmost_index(&self, len: usize) -> usize {
match self {
@@ -420,370 +437,5 @@ impl DefaultLayout {
}
#[cfg(test)]
mod tests {
use super::*;
// Helper to create LayoutOptions with column ratios
fn layout_options_with_column_ratios(ratios: &[f32]) -> LayoutOptions {
let mut arr = [None; MAX_RATIOS];
for (i, &r) in ratios.iter().take(MAX_RATIOS).enumerate() {
arr[i] = Some(r);
}
LayoutOptions {
scrolling: None,
grid: None,
column_ratios: Some(arr),
row_ratios: None,
}
}
// Helper to create LayoutOptions with row ratios
fn layout_options_with_row_ratios(ratios: &[f32]) -> LayoutOptions {
let mut arr = [None; MAX_RATIOS];
for (i, &r) in ratios.iter().take(MAX_RATIOS).enumerate() {
arr[i] = Some(r);
}
LayoutOptions {
scrolling: None,
grid: None,
column_ratios: None,
row_ratios: Some(arr),
}
}
// Helper to create LayoutOptions with both column and row ratios
fn layout_options_with_ratios(column_ratios: &[f32], row_ratios: &[f32]) -> LayoutOptions {
let mut col_arr = [None; MAX_RATIOS];
for (i, &r) in column_ratios.iter().take(MAX_RATIOS).enumerate() {
col_arr[i] = Some(r);
}
let mut row_arr = [None; MAX_RATIOS];
for (i, &r) in row_ratios.iter().take(MAX_RATIOS).enumerate() {
row_arr[i] = Some(r);
}
LayoutOptions {
scrolling: None,
grid: None,
column_ratios: Some(col_arr),
row_ratios: Some(row_arr),
}
}
mod deserialize_ratios_tests {
use super::*;
#[test]
fn test_deserialize_valid_ratios() {
let json = r#"{"column_ratios": [0.3, 0.4, 0.2]}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
let ratios = opts.column_ratios.unwrap();
assert_eq!(ratios[0], Some(0.3));
assert_eq!(ratios[1], Some(0.4));
assert_eq!(ratios[2], Some(0.2));
assert_eq!(ratios[3], None);
assert_eq!(ratios[4], None);
}
#[test]
fn test_deserialize_clamps_values_to_min() {
// Values below MIN_RATIO should be clamped
let json = r#"{"column_ratios": [0.05]}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
let ratios = opts.column_ratios.unwrap();
assert_eq!(ratios[0], Some(MIN_RATIO)); // Clamped to 0.1
}
#[test]
fn test_deserialize_clamps_values_to_max() {
// Values above MAX_RATIO should be clamped
let json = r#"{"column_ratios": [0.95]}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
let ratios = opts.column_ratios.unwrap();
// 0.9 is the max, so it should be clamped
assert!(ratios[0].unwrap() <= MAX_RATIO);
}
#[test]
fn test_deserialize_truncates_when_sum_exceeds_one() {
// Sum of ratios should not reach 1.0
// [0.5, 0.4] = 0.9, then 0.3 would make it 1.2, so it should be truncated
let json = r#"{"column_ratios": [0.5, 0.4, 0.3]}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
let ratios = opts.column_ratios.unwrap();
assert_eq!(ratios[0], Some(0.5));
assert_eq!(ratios[1], Some(0.4));
// Third ratio should be truncated because 0.5 + 0.4 + 0.3 >= 1.0
assert_eq!(ratios[2], None);
}
#[test]
fn test_deserialize_truncates_at_max_ratios() {
// More than MAX_RATIOS values should be truncated
let json = r#"{"column_ratios": [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
let ratios = opts.column_ratios.unwrap();
// Only MAX_RATIOS (5) values should be stored
for item in ratios.iter().take(MAX_RATIOS) {
assert_eq!(*item, Some(0.1));
}
}
#[test]
fn test_deserialize_empty_array() {
let json = r#"{"column_ratios": []}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
let ratios = opts.column_ratios.unwrap();
for item in ratios.iter().take(MAX_RATIOS) {
assert_eq!(*item, None);
}
}
#[test]
fn test_deserialize_null() {
let json = r#"{"column_ratios": null}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
assert!(opts.column_ratios.is_none());
}
#[test]
fn test_deserialize_row_ratios() {
let json = r#"{"row_ratios": [0.3, 0.5]}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
let ratios = opts.row_ratios.unwrap();
assert_eq!(ratios[0], Some(0.3));
assert_eq!(ratios[1], Some(0.5));
assert_eq!(ratios[2], None);
}
}
mod serialize_ratios_tests {
use super::*;
#[test]
fn test_serialize_ratios_compact() {
let opts = layout_options_with_column_ratios(&[0.3, 0.4]);
let json = serde_json::to_string(&opts).unwrap();
// Should serialize ratios as compact array without trailing nulls in the ratios array
assert!(json.contains("0.3") && json.contains("0.4"));
}
#[test]
fn test_serialize_none_ratios() {
let opts = LayoutOptions {
scrolling: None,
grid: None,
column_ratios: None,
row_ratios: None,
};
let json = serde_json::to_string(&opts).unwrap();
// None values should serialize as null or be omitted
assert!(!json.contains("["));
}
#[test]
fn test_roundtrip_serialization() {
let original = layout_options_with_column_ratios(&[0.3, 0.4, 0.2]);
let json = serde_json::to_string(&original).unwrap();
let deserialized: LayoutOptions = serde_json::from_str(&json).unwrap();
assert_eq!(original.column_ratios, deserialized.column_ratios);
}
#[test]
fn test_serialize_row_ratios() {
let opts = layout_options_with_row_ratios(&[0.3, 0.5]);
let json = serde_json::to_string(&opts).unwrap();
assert!(json.contains("row_ratios"));
assert!(json.contains("0.3") && json.contains("0.5"));
}
#[test]
fn test_roundtrip_row_ratios() {
let original = layout_options_with_row_ratios(&[0.4, 0.3]);
let json = serde_json::to_string(&original).unwrap();
let deserialized: LayoutOptions = serde_json::from_str(&json).unwrap();
assert_eq!(original.row_ratios, deserialized.row_ratios);
assert!(original.column_ratios.is_none());
}
#[test]
fn test_roundtrip_both_ratios() {
let original = layout_options_with_ratios(&[0.3, 0.4], &[0.5, 0.3]);
let json = serde_json::to_string(&original).unwrap();
let deserialized: LayoutOptions = serde_json::from_str(&json).unwrap();
assert_eq!(original.column_ratios, deserialized.column_ratios);
assert_eq!(original.row_ratios, deserialized.row_ratios);
}
}
mod ratio_constants_tests {
use super::*;
#[test]
fn test_constants_valid_ranges() {
const {
assert!(MIN_RATIO > 0.0);
assert!(MIN_RATIO < MAX_RATIO);
assert!(MAX_RATIO < 1.0);
assert!(DEFAULT_RATIO >= MIN_RATIO && DEFAULT_RATIO <= MAX_RATIO);
assert!(
DEFAULT_SECONDARY_RATIO >= MIN_RATIO && DEFAULT_SECONDARY_RATIO <= MAX_RATIO
);
assert!(MAX_RATIOS >= 1);
}
}
#[test]
fn test_default_ratio_is_half() {
assert_eq!(DEFAULT_RATIO, 0.5);
}
#[test]
fn test_max_ratios_is_five() {
assert_eq!(MAX_RATIOS, 5);
}
}
mod layout_options_tests {
use super::*;
#[test]
fn test_layout_options_default_values() {
let json = r#"{}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
assert!(opts.scrolling.is_none());
assert!(opts.grid.is_none());
assert!(opts.column_ratios.is_none());
assert!(opts.row_ratios.is_none());
}
#[test]
fn test_layout_options_with_all_fields() {
let json = r#"{
"scrolling": {"columns": 3},
"grid": {"rows": 2},
"column_ratios": [0.3, 0.4],
"row_ratios": [0.5]
}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
assert!(opts.scrolling.is_some());
assert_eq!(opts.scrolling.unwrap().columns, 3);
assert!(opts.grid.is_some());
assert_eq!(opts.grid.unwrap().rows, 2);
assert!(opts.column_ratios.is_some());
assert!(opts.row_ratios.is_some());
}
}
mod default_layout_tests {
use super::*;
#[test]
fn test_cycle_next_covers_all_layouts() {
let start = DefaultLayout::BSP;
let mut current = start;
let mut visited = vec![current];
loop {
current = current.cycle_next();
if current == start {
break;
}
assert!(
!visited.contains(&current),
"Cycle contains duplicate: {:?}",
current
);
visited.push(current);
}
// Should have visited all layouts
assert_eq!(visited.len(), 9); // 9 layouts total
}
#[test]
fn test_cycle_previous_is_inverse_of_next() {
// Note: cycle_previous has some inconsistencies in the current implementation
// This test documents the expected behavior for most layouts
let layouts_with_correct_inverse = [
DefaultLayout::Columns,
DefaultLayout::Rows,
DefaultLayout::VerticalStack,
DefaultLayout::HorizontalStack,
DefaultLayout::UltrawideVerticalStack,
DefaultLayout::Grid,
DefaultLayout::RightMainVerticalStack,
];
for layout in layouts_with_correct_inverse {
let next = layout.cycle_next();
assert_eq!(
next.cycle_previous(),
layout,
"cycle_previous should be inverse of cycle_next for {:?}",
layout
);
}
}
#[test]
fn test_leftmost_index_standard_layouts() {
assert_eq!(DefaultLayout::BSP.leftmost_index(5), 0);
assert_eq!(DefaultLayout::Columns.leftmost_index(5), 0);
assert_eq!(DefaultLayout::Rows.leftmost_index(5), 0);
assert_eq!(DefaultLayout::VerticalStack.leftmost_index(5), 0);
assert_eq!(DefaultLayout::HorizontalStack.leftmost_index(5), 0);
assert_eq!(DefaultLayout::Grid.leftmost_index(5), 0);
}
#[test]
fn test_leftmost_index_ultrawide() {
assert_eq!(DefaultLayout::UltrawideVerticalStack.leftmost_index(1), 0);
assert_eq!(DefaultLayout::UltrawideVerticalStack.leftmost_index(2), 1);
assert_eq!(DefaultLayout::UltrawideVerticalStack.leftmost_index(5), 1);
}
#[test]
fn test_leftmost_index_right_main() {
assert_eq!(DefaultLayout::RightMainVerticalStack.leftmost_index(1), 0);
assert_eq!(DefaultLayout::RightMainVerticalStack.leftmost_index(2), 1);
assert_eq!(DefaultLayout::RightMainVerticalStack.leftmost_index(5), 1);
}
#[test]
fn test_rightmost_index_standard_layouts() {
assert_eq!(DefaultLayout::BSP.rightmost_index(5), 4);
assert_eq!(DefaultLayout::Columns.rightmost_index(5), 4);
assert_eq!(DefaultLayout::Rows.rightmost_index(5), 4);
assert_eq!(DefaultLayout::VerticalStack.rightmost_index(5), 4);
}
#[test]
fn test_rightmost_index_right_main() {
assert_eq!(DefaultLayout::RightMainVerticalStack.rightmost_index(1), 0);
assert_eq!(DefaultLayout::RightMainVerticalStack.rightmost_index(5), 0);
}
#[test]
fn test_rightmost_index_ultrawide() {
assert_eq!(DefaultLayout::UltrawideVerticalStack.rightmost_index(1), 0);
assert_eq!(DefaultLayout::UltrawideVerticalStack.rightmost_index(2), 0);
assert_eq!(DefaultLayout::UltrawideVerticalStack.rightmost_index(3), 2);
assert_eq!(DefaultLayout::UltrawideVerticalStack.rightmost_index(5), 4);
}
}
}
#[path = "default_layout_tests.rs"]
mod tests;

View File

@@ -0,0 +1,954 @@
use super::*;
// Helper to create LayoutOptions with column ratios
fn layout_options_with_column_ratios(ratios: &[f32]) -> LayoutOptions {
let mut arr = [None; MAX_RATIOS];
for (i, &r) in ratios.iter().take(MAX_RATIOS).enumerate() {
arr[i] = Some(r);
}
LayoutOptions {
scrolling: None,
grid: None,
column_ratios: Some(arr),
row_ratios: None,
}
}
// Helper to create LayoutOptions with row ratios
fn layout_options_with_row_ratios(ratios: &[f32]) -> LayoutOptions {
let mut arr = [None; MAX_RATIOS];
for (i, &r) in ratios.iter().take(MAX_RATIOS).enumerate() {
arr[i] = Some(r);
}
LayoutOptions {
scrolling: None,
grid: None,
column_ratios: None,
row_ratios: Some(arr),
}
}
// Helper to create LayoutOptions with both column and row ratios
fn layout_options_with_ratios(column_ratios: &[f32], row_ratios: &[f32]) -> LayoutOptions {
let mut col_arr = [None; MAX_RATIOS];
for (i, &r) in column_ratios.iter().take(MAX_RATIOS).enumerate() {
col_arr[i] = Some(r);
}
let mut row_arr = [None; MAX_RATIOS];
for (i, &r) in row_ratios.iter().take(MAX_RATIOS).enumerate() {
row_arr[i] = Some(r);
}
LayoutOptions {
scrolling: None,
grid: None,
column_ratios: Some(col_arr),
row_ratios: Some(row_arr),
}
}
mod deserialize_ratios_tests {
use super::*;
#[test]
fn test_deserialize_valid_ratios() {
let json = r#"{"column_ratios": [0.3, 0.4, 0.2]}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
let ratios = opts.column_ratios.unwrap();
assert_eq!(ratios[0], Some(0.3));
assert_eq!(ratios[1], Some(0.4));
assert_eq!(ratios[2], Some(0.2));
assert_eq!(ratios[3], None);
assert_eq!(ratios[4], None);
}
#[test]
fn test_deserialize_clamps_values_to_min() {
// Values below MIN_RATIO should be clamped
let json = r#"{"column_ratios": [0.05]}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
let ratios = opts.column_ratios.unwrap();
assert_eq!(ratios[0], Some(MIN_RATIO)); // Clamped to 0.1
}
#[test]
fn test_deserialize_clamps_values_to_max() {
// Values above MAX_RATIO should be clamped
let json = r#"{"column_ratios": [0.95]}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
let ratios = opts.column_ratios.unwrap();
// 0.9 is the max, so it should be clamped
assert!(ratios[0].unwrap() <= MAX_RATIO);
}
#[test]
fn test_deserialize_truncates_when_sum_exceeds_one() {
// Sum of ratios should not reach 1.0
// [0.5, 0.4] = 0.9, then 0.3 would make it 1.2, so it should be truncated
let json = r#"{"column_ratios": [0.5, 0.4, 0.3]}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
let ratios = opts.column_ratios.unwrap();
assert_eq!(ratios[0], Some(0.5));
assert_eq!(ratios[1], Some(0.4));
// Third ratio should be truncated because 0.5 + 0.4 + 0.3 >= 1.0
assert_eq!(ratios[2], None);
}
#[test]
fn test_deserialize_truncates_at_max_ratios() {
// More than MAX_RATIOS values should be truncated
let json = r#"{"column_ratios": [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
let ratios = opts.column_ratios.unwrap();
// Only MAX_RATIOS (5) values should be stored
for item in ratios.iter().take(MAX_RATIOS) {
assert_eq!(*item, Some(0.1));
}
}
#[test]
fn test_deserialize_empty_array() {
let json = r#"{"column_ratios": []}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
let ratios = opts.column_ratios.unwrap();
for item in ratios.iter().take(MAX_RATIOS) {
assert_eq!(*item, None);
}
}
#[test]
fn test_deserialize_null() {
let json = r#"{"column_ratios": null}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
assert!(opts.column_ratios.is_none());
}
#[test]
fn test_deserialize_row_ratios() {
let json = r#"{"row_ratios": [0.3, 0.5]}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
let ratios = opts.row_ratios.unwrap();
assert_eq!(ratios[0], Some(0.3));
assert_eq!(ratios[1], Some(0.5));
assert_eq!(ratios[2], None);
}
}
mod serialize_ratios_tests {
use super::*;
#[test]
fn test_serialize_ratios_compact() {
let opts = layout_options_with_column_ratios(&[0.3, 0.4]);
let json = serde_json::to_string(&opts).unwrap();
// Should serialize ratios as compact array without trailing nulls in the ratios array
assert!(json.contains("0.3") && json.contains("0.4"));
}
#[test]
fn test_serialize_none_ratios() {
let opts = LayoutOptions {
scrolling: None,
grid: None,
column_ratios: None,
row_ratios: None,
};
let json = serde_json::to_string(&opts).unwrap();
// None values should serialize as null or be omitted
assert!(!json.contains("["));
}
#[test]
fn test_roundtrip_serialization() {
let original = layout_options_with_column_ratios(&[0.3, 0.4, 0.2]);
let json = serde_json::to_string(&original).unwrap();
let deserialized: LayoutOptions = serde_json::from_str(&json).unwrap();
assert_eq!(original.column_ratios, deserialized.column_ratios);
}
#[test]
fn test_serialize_row_ratios() {
let opts = layout_options_with_row_ratios(&[0.3, 0.5]);
let json = serde_json::to_string(&opts).unwrap();
assert!(json.contains("row_ratios"));
assert!(json.contains("0.3") && json.contains("0.5"));
}
#[test]
fn test_roundtrip_row_ratios() {
let original = layout_options_with_row_ratios(&[0.4, 0.3]);
let json = serde_json::to_string(&original).unwrap();
let deserialized: LayoutOptions = serde_json::from_str(&json).unwrap();
assert_eq!(original.row_ratios, deserialized.row_ratios);
assert!(original.column_ratios.is_none());
}
#[test]
fn test_roundtrip_both_ratios() {
let original = layout_options_with_ratios(&[0.3, 0.4], &[0.5, 0.3]);
let json = serde_json::to_string(&original).unwrap();
let deserialized: LayoutOptions = serde_json::from_str(&json).unwrap();
assert_eq!(original.column_ratios, deserialized.column_ratios);
assert_eq!(original.row_ratios, deserialized.row_ratios);
}
}
mod ratio_constants_tests {
use super::*;
#[test]
fn test_constants_valid_ranges() {
const {
assert!(MIN_RATIO > 0.0);
assert!(MIN_RATIO < MAX_RATIO);
assert!(MAX_RATIO < 1.0);
assert!(DEFAULT_RATIO >= MIN_RATIO && DEFAULT_RATIO <= MAX_RATIO);
assert!(DEFAULT_SECONDARY_RATIO >= MIN_RATIO && DEFAULT_SECONDARY_RATIO <= MAX_RATIO);
assert!(MAX_RATIOS >= 1);
}
}
#[test]
fn test_default_ratio_is_half() {
assert_eq!(DEFAULT_RATIO, 0.5);
}
#[test]
fn test_max_ratios_is_five() {
assert_eq!(MAX_RATIOS, 5);
}
}
mod layout_options_tests {
use super::*;
#[test]
fn test_layout_options_default_values() {
let json = r#"{}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
assert!(opts.scrolling.is_none());
assert!(opts.grid.is_none());
assert!(opts.column_ratios.is_none());
assert!(opts.row_ratios.is_none());
}
#[test]
fn test_layout_options_with_all_fields() {
let json = r#"{
"scrolling": {"columns": 3},
"grid": {"rows": 2},
"column_ratios": [0.3, 0.4],
"row_ratios": [0.5]
}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
assert!(opts.scrolling.is_some());
assert_eq!(opts.scrolling.unwrap().columns, 3);
assert!(opts.grid.is_some());
assert_eq!(opts.grid.unwrap().rows, 2);
assert!(opts.column_ratios.is_some());
assert!(opts.row_ratios.is_some());
}
}
mod default_layout_tests {
use super::*;
#[test]
fn test_cycle_next_covers_all_layouts() {
let start = DefaultLayout::BSP;
let mut current = start;
let mut visited = vec![current];
loop {
current = current.cycle_next();
if current == start {
break;
}
assert!(
!visited.contains(&current),
"Cycle contains duplicate: {:?}",
current
);
visited.push(current);
}
// Should have visited all layouts
assert_eq!(visited.len(), 9); // 9 layouts total
}
#[test]
fn test_cycle_previous_is_inverse_of_next() {
// Note: cycle_previous has some inconsistencies in the current implementation
// This test documents the expected behavior for most layouts
let layouts_with_correct_inverse = [
DefaultLayout::Columns,
DefaultLayout::Rows,
DefaultLayout::VerticalStack,
DefaultLayout::HorizontalStack,
DefaultLayout::UltrawideVerticalStack,
DefaultLayout::Grid,
DefaultLayout::RightMainVerticalStack,
];
for layout in layouts_with_correct_inverse {
let next = layout.cycle_next();
assert_eq!(
next.cycle_previous(),
layout,
"cycle_previous should be inverse of cycle_next for {:?}",
layout
);
}
}
#[test]
fn test_leftmost_index_standard_layouts() {
assert_eq!(DefaultLayout::BSP.leftmost_index(5), 0);
assert_eq!(DefaultLayout::Columns.leftmost_index(5), 0);
assert_eq!(DefaultLayout::Rows.leftmost_index(5), 0);
assert_eq!(DefaultLayout::VerticalStack.leftmost_index(5), 0);
assert_eq!(DefaultLayout::HorizontalStack.leftmost_index(5), 0);
assert_eq!(DefaultLayout::Grid.leftmost_index(5), 0);
}
#[test]
fn test_leftmost_index_ultrawide() {
assert_eq!(DefaultLayout::UltrawideVerticalStack.leftmost_index(1), 0);
assert_eq!(DefaultLayout::UltrawideVerticalStack.leftmost_index(2), 1);
assert_eq!(DefaultLayout::UltrawideVerticalStack.leftmost_index(5), 1);
}
#[test]
fn test_leftmost_index_right_main() {
assert_eq!(DefaultLayout::RightMainVerticalStack.leftmost_index(1), 0);
assert_eq!(DefaultLayout::RightMainVerticalStack.leftmost_index(2), 1);
assert_eq!(DefaultLayout::RightMainVerticalStack.leftmost_index(5), 1);
}
#[test]
fn test_rightmost_index_standard_layouts() {
assert_eq!(DefaultLayout::BSP.rightmost_index(5), 4);
assert_eq!(DefaultLayout::Columns.rightmost_index(5), 4);
assert_eq!(DefaultLayout::Rows.rightmost_index(5), 4);
assert_eq!(DefaultLayout::VerticalStack.rightmost_index(5), 4);
}
#[test]
fn test_rightmost_index_right_main() {
assert_eq!(DefaultLayout::RightMainVerticalStack.rightmost_index(1), 0);
assert_eq!(DefaultLayout::RightMainVerticalStack.rightmost_index(5), 0);
}
#[test]
fn test_rightmost_index_ultrawide() {
assert_eq!(DefaultLayout::UltrawideVerticalStack.rightmost_index(1), 0);
assert_eq!(DefaultLayout::UltrawideVerticalStack.rightmost_index(2), 0);
assert_eq!(DefaultLayout::UltrawideVerticalStack.rightmost_index(3), 2);
assert_eq!(DefaultLayout::UltrawideVerticalStack.rightmost_index(5), 4);
}
}
mod layout_options_rules_tests {
use super::*;
#[test]
fn test_hashmap_deserialization_ratios_only() {
// layout_options_rules entries with only ratios
// Note: ratios must sum to < 1.0 to avoid truncation by validate_ratios
let json = r#"{
"2": {"column_ratios": [0.7]},
"3": {"column_ratios": [0.55]},
"5": {"column_ratios": [0.3, 0.3, 0.3]}
}"#;
let rules: std::collections::HashMap<usize, LayoutOptions> =
serde_json::from_str(json).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));
// No scrolling/grid in these entries
assert!(rules[&2].scrolling.is_none());
assert!(rules[&2].grid.is_none());
}
#[test]
fn test_hashmap_deserialization_full_options() {
// layout_options_rules entries with full options including scrolling/grid
let json = r#"{
"2": {"column_ratios": [0.7], "scrolling": {"columns": 3}},
"5": {"column_ratios": [0.3, 0.3, 0.3], "grid": {"rows": 2}}
}"#;
let rules: std::collections::HashMap<usize, LayoutOptions> =
serde_json::from_str(json).unwrap();
assert_eq!(rules.len(), 2);
assert_eq!(rules[&2].scrolling.unwrap().columns, 3);
assert!(rules[&2].grid.is_none());
assert!(rules[&5].scrolling.is_none());
assert_eq!(rules[&5].grid.unwrap().rows, 2);
}
#[test]
fn test_rule_entry_with_all_fields() {
let json = r#"{
"column_ratios": [0.6, 0.3],
"scrolling": {"columns": 4, "center_focused_column": true},
"grid": {"rows": 2},
"row_ratios": [0.5]
}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
let col = opts.column_ratios.unwrap();
assert_eq!(col[0], Some(0.6));
assert_eq!(col[1], Some(0.3));
let row = opts.row_ratios.unwrap();
assert_eq!(row[0], Some(0.5));
assert_eq!(opts.scrolling.unwrap().columns, 4);
assert_eq!(opts.scrolling.unwrap().center_focused_column, Some(true));
assert_eq!(opts.grid.unwrap().rows, 2);
}
#[test]
fn test_rule_entry_empty_object_gives_defaults() {
let json = r#"{}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
assert!(opts.column_ratios.is_none());
assert!(opts.row_ratios.is_none());
assert!(opts.scrolling.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);
}
}

View File

@@ -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;

View File

@@ -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 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);

View File

@@ -253,6 +253,8 @@ impl From<&WindowManager> for State {
layout: workspace.layout.clone(),
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,

View File

@@ -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;
@@ -215,6 +217,12 @@ pub struct WorkspaceConfig {
/// Layout-specific options
#[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`.
/// This follows the same threshold logic as `layout_rules`.
#[serde(skip_serializing_if = "Option::is_none")]
pub layout_options_rules: Option<HashMap<usize, LayoutOptions>>,
/// END OF LIFE FEATURE: Custom Layout
#[deprecated(note = "End of life feature")]
#[serde(skip_serializing_if = "Option::is_none")]
@@ -342,6 +350,11 @@ impl From<&Workspace> for WorkspaceConfig {
);
value.layout_options
},
layout_options_rules: if value.layout_options_rules.is_empty() {
None
} else {
Some(value.layout_options_rules.iter().copied().collect())
},
#[allow(deprecated)]
custom_layout: value
.workspace_config
@@ -582,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<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
#[serde(skip_serializing_if = "Option::is_none")]
pub monitors: Option<Vec<MonitorConfig>>,
@@ -891,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,
@@ -1006,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);
}
@@ -1414,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())?;
}
}
@@ -1497,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(),
)?;
}
}
@@ -1579,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())?;
}
}
@@ -1662,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(),
)?;
}
}

View File

@@ -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;
@@ -61,6 +63,14 @@ pub struct Workspace {
pub layout: Layout,
pub layout_options: Option<LayoutOptions>,
pub layout_rules: Vec<(usize, Layout)>,
/// Threshold-based layout options rules (container_count >= threshold -> use these options).
/// 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<DefaultLayout, CachedLayoutDefault>,
pub work_area_offset_rules: Vec<(usize, Rect)>,
pub layout_flip: Option<Axis>,
pub workspace_padding: Option<i32>,
@@ -119,6 +129,8 @@ impl Default for Workspace {
layout: Layout::Default(DefaultLayout::BSP),
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)),
@@ -165,8 +177,49 @@ pub struct WorkspaceGlobals {
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 {
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.container_padding = config.container_padding;
@@ -251,19 +304,78 @@ impl Workspace {
self.layout_flip = config.layout_flip;
self.floating_layer_behaviour = config.floating_layer_behaviour;
self.wallpaper = config.wallpaper.clone();
// Load layout options directly (LayoutOptions is used in both config and runtime)
self.layout_options = config.layout_options;
// Load threshold-based layout options rules, sorted by threshold ascending
self.layout_options_rules =
sorted_layout_options_rules(config.layout_options_rules.as_ref());
tracing::debug!(
"Workspace '{}' loaded layout_options: {:?}",
"Workspace '{}' loaded layout_options: {:?}, layout_options_rules: {} entries",
self.name.as_deref().unwrap_or("unnamed"),
self.layout_options
self.layout_options,
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<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>) {
for window in self.floating_windows_mut().iter_mut().rev() {
let mut should_hide = omit.is_none();
@@ -584,10 +696,14 @@ impl Workspace {
} else if let Some(window) = &mut self.maximized_window {
window.maximize();
} else if !self.containers().is_empty() {
let effective_layout_options = self.effective_layout_options();
tracing::debug!(
"Workspace '{}' update() - self.layout_options before calculate: {:?}",
"Workspace '{}' update() - effective_layout_options: {:?} (base: {:?}, rules: {})",
self.name.as_deref().unwrap_or("unnamed"),
self.layout_options
effective_layout_options,
self.layout_options,
self.layout_options_rules.len(),
);
let mut layouts = self.layout.as_boxed_arrangement().calculate(
&adjusted_work_area,
@@ -598,7 +714,7 @@ impl Workspace {
self.layout_flip,
&self.resize_dimensions,
self.focused_container_idx(),
self.layout_options,
effective_layout_options,
&self.latest_layout,
);

View File

@@ -9683,6 +9683,26 @@
}
]
},
"layout_options_rules": {
"description": "Threshold-based layout options rules (container_count >= threshold -> use these options).\nSorted by threshold ascending at load time.",
"type": "array",
"default": [],
"items": {
"type": "array",
"maxItems": 2,
"minItems": 2,
"prefixItems": [
{
"type": "integer",
"format": "uint",
"minimum": 0
},
{
"$ref": "#/$defs/LayoutOptions"
}
]
}
},
"layout_rules": {
"type": "array",
"items": {
@@ -9985,6 +10005,19 @@
}
]
},
"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`.\nThis follows the same threshold logic as `layout_rules`.",
"type": [
"object",
"null"
],
"additionalProperties": false,
"patternProperties": {
"^\\d+$": {
"$ref": "#/$defs/LayoutOptions"
}
}
},
"layout_rules": {
"description": "Layout rules in the format of threshold => layout",
"type": [

View File

@@ -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",
@@ -4214,6 +4254,19 @@
}
]
},
"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`.\nThis follows the same threshold logic as `layout_rules`.",
"type": [
"object",
"null"
],
"additionalProperties": false,
"patternProperties": {
"^\\d+$": {
"$ref": "#/$defs/LayoutOptions"
}
}
},
"layout_rules": {
"description": "Layout rules in the format of threshold => layout",
"type": [