diff --git a/docs/common-workflows/layout-ratios.md b/docs/common-workflows/layout-ratios.md index e690ed60..d5e3f310 100644 --- a/docs/common-workflows/layout-ratios.md +++ b/docs/common-workflows/layout-ratios.md @@ -172,6 +172,71 @@ 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. + ## Progressive Ratio Behavior Ratios are applied progressively as windows are added. For example, with `row_ratios: [0.3, 0.5]` in a VerticalStack: diff --git a/komorebi-layouts/src/default_layout.rs b/komorebi-layouts/src/default_layout.rs index e60fa1af..fd42773c 100644 --- a/komorebi-layouts/src/default_layout.rs +++ b/komorebi-layouts/src/default_layout.rs @@ -420,370 +420,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(¤t), - "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; diff --git a/komorebi-layouts/src/default_layout_tests.rs b/komorebi-layouts/src/default_layout_tests.rs new file mode 100644 index 00000000..b4f22582 --- /dev/null +++ b/komorebi-layouts/src/default_layout_tests.rs @@ -0,0 +1,435 @@ +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(¤t), + "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 = + 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 = + 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()); + } +} diff --git a/komorebi/src/state.rs b/komorebi/src/state.rs index 9309feae..5a16c926 100644 --- a/komorebi/src/state.rs +++ b/komorebi/src/state.rs @@ -253,6 +253,7 @@ 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(), work_area_offset_rules: workspace.work_area_offset_rules.clone(), layout_flip: workspace.layout_flip, workspace_padding: workspace.workspace_padding, diff --git a/komorebi/src/static_config.rs b/komorebi/src/static_config.rs index 223ec171..c22d8deb 100644 --- a/komorebi/src/static_config.rs +++ b/komorebi/src/static_config.rs @@ -215,6 +215,12 @@ pub struct WorkspaceConfig { /// Layout-specific options #[serde(skip_serializing_if = "Option::is_none")] pub layout_options: Option, + /// Threshold-based layout options rules in the format of threshold => options. + /// When container count >= threshold, the highest matching threshold's options + /// fully replace the base `layout_options`. + /// This follows the same threshold logic as `layout_rules`. + #[serde(skip_serializing_if = "Option::is_none")] + pub layout_options_rules: Option>, /// END OF LIFE FEATURE: Custom Layout #[deprecated(note = "End of life feature")] #[serde(skip_serializing_if = "Option::is_none")] @@ -342,6 +348,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 diff --git a/komorebi/src/workspace.rs b/komorebi/src/workspace.rs index f9665035..b6dc2fb7 100644 --- a/komorebi/src/workspace.rs +++ b/komorebi/src/workspace.rs @@ -61,6 +61,10 @@ pub struct Workspace { pub layout: Layout, pub layout_options: Option, 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)>, pub work_area_offset_rules: Vec<(usize, Rect)>, pub layout_flip: Option, pub workspace_padding: Option, @@ -119,6 +123,7 @@ impl Default for Workspace { layout: Layout::Default(DefaultLayout::BSP), layout_options: None, layout_rules: vec![], + layout_options_rules: vec![], work_area_offset_rules: vec![], layout_flip: None, workspace_padding: Option::from(DEFAULT_WORKSPACE_PADDING.load(Ordering::SeqCst)), @@ -251,12 +256,25 @@ 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 = if let Some(rules) = &config.layout_options_rules { + let mut sorted_rules: Vec<(usize, LayoutOptions)> = + rules.iter().map(|(t, o)| (*t, *o)).collect(); + sorted_rules.sort_by_key(|(t, _)| *t); + sorted_rules + } else { + vec![] + }; + 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(), ); self.workspace_config = Some(config.clone()); @@ -584,10 +602,34 @@ impl Workspace { } else if let Some(window) = &mut self.maximized_window { window.maximize(); } else if !self.containers().is_empty() { - tracing::debug!( - "Workspace '{}' update() - self.layout_options before calculate: {:?}", - self.name.as_deref().unwrap_or("unnamed"), + // Compute effective layout options: + // 1. If layout_options_rules has a matching threshold (container_count >= threshold), + // use the highest matching threshold's options (full replacement) + // 2. Otherwise, use the workspace base layout_options + let effective_layout_options = if !self.layout_options_rules.is_empty() { + let container_count = self.containers().len(); + let mut matched = None; + for (threshold, opts) in &self.layout_options_rules { + if container_count >= *threshold { + matched = Some(*opts); + } + } + // If a rule matched, use it entirely (replaces base layout_options) + if matched.is_some() { + matched + } else { + self.layout_options + } + } else { self.layout_options + }; + + tracing::debug!( + "Workspace '{}' update() - effective_layout_options: {:?} (base: {:?}, rules: {})", + self.name.as_deref().unwrap_or("unnamed"), + 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 +640,7 @@ impl Workspace { self.layout_flip, &self.resize_dimensions, self.focused_container_idx(), - self.layout_options, + effective_layout_options, &self.latest_layout, ); diff --git a/schema.bar.json b/schema.bar.json index f96db73e..480b4f57 100644 --- a/schema.bar.json +++ b/schema.bar.json @@ -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": [ diff --git a/schema.json b/schema.json index 5a775b77..340eba02 100644 --- a/schema.json +++ b/schema.json @@ -4214,6 +4214,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": [