mirror of
https://github.com/LGUG2Z/komorebi.git
synced 2026-05-20 18:56:59 +02:00
feat(wm): add global layout_defaults for per-layout default options
Adds a top-level layout_defaults setting that defines default layout_options and layout_options_rules per layout. Workspaces without their own layout_options or layout_options_rules automatically inherit the global defaults. If a workspace defines either setting, all global defaults for that layout are fully replaced.
This commit is contained in:
@@ -237,6 +237,78 @@ not specified in the matching rule default to their standard defaults (not the b
|
||||
|
||||
This increases the visible scrolling columns as more windows are added.
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -433,3 +433,522 @@ mod layout_options_rules_tests {
|
||||
assert!(opts.grid.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
mod layout_default_entry_tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_default_layout_as_hashmap_key() {
|
||||
let mut map: HashMap<DefaultLayout, &str> = HashMap::new();
|
||||
map.insert(DefaultLayout::BSP, "bsp");
|
||||
map.insert(DefaultLayout::VerticalStack, "vstack");
|
||||
map.insert(DefaultLayout::Columns, "cols");
|
||||
|
||||
assert_eq!(map.len(), 3);
|
||||
assert_eq!(map[&DefaultLayout::BSP], "bsp");
|
||||
assert_eq!(map[&DefaultLayout::VerticalStack], "vstack");
|
||||
assert_eq!(map[&DefaultLayout::Columns], "cols");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_layout_hash_consistency() {
|
||||
// Same variant inserted twice should overwrite
|
||||
let mut map: HashMap<DefaultLayout, i32> = HashMap::new();
|
||||
map.insert(DefaultLayout::Grid, 1);
|
||||
map.insert(DefaultLayout::Grid, 2);
|
||||
assert_eq!(map.len(), 1);
|
||||
assert_eq!(map[&DefaultLayout::Grid], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_layout_default_entry_deserialize_full() {
|
||||
let json = r#"{
|
||||
"layout_options": {"column_ratios": [0.7]},
|
||||
"layout_options_rules": {
|
||||
"2": {"column_ratios": [0.7]},
|
||||
"3": {"column_ratios": [0.55]},
|
||||
"5": {"column_ratios": [0.3, 0.3, 0.3]}
|
||||
}
|
||||
}"#;
|
||||
let entry: LayoutDefaultEntry = serde_json::from_str(json).unwrap();
|
||||
|
||||
let base = entry.layout_options.unwrap();
|
||||
assert_eq!(base.column_ratios.unwrap()[0], Some(0.7));
|
||||
|
||||
let rules = entry.layout_options_rules.unwrap();
|
||||
assert_eq!(rules.len(), 3);
|
||||
assert_eq!(rules[&2].column_ratios.unwrap()[0], Some(0.7));
|
||||
assert_eq!(rules[&3].column_ratios.unwrap()[0], Some(0.55));
|
||||
let r5 = rules[&5].column_ratios.unwrap();
|
||||
assert_eq!(r5[0], Some(0.3));
|
||||
assert_eq!(r5[1], Some(0.3));
|
||||
assert_eq!(r5[2], Some(0.3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_layout_default_entry_deserialize_only_base() {
|
||||
let json = r#"{
|
||||
"layout_options": {"column_ratios": [0.6]}
|
||||
}"#;
|
||||
let entry: LayoutDefaultEntry = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert!(entry.layout_options.is_some());
|
||||
assert_eq!(
|
||||
entry.layout_options.unwrap().column_ratios.unwrap()[0],
|
||||
Some(0.6)
|
||||
);
|
||||
assert!(entry.layout_options_rules.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_layout_default_entry_deserialize_only_rules() {
|
||||
let json = r#"{
|
||||
"layout_options_rules": {
|
||||
"3": {"column_ratios": [0.4]}
|
||||
}
|
||||
}"#;
|
||||
let entry: LayoutDefaultEntry = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert!(entry.layout_options.is_none());
|
||||
let rules = entry.layout_options_rules.unwrap();
|
||||
assert_eq!(rules.len(), 1);
|
||||
assert_eq!(rules[&3].column_ratios.unwrap()[0], Some(0.4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_layout_default_entry_deserialize_empty() {
|
||||
let json = r#"{}"#;
|
||||
let entry: LayoutDefaultEntry = serde_json::from_str(json).unwrap();
|
||||
assert!(entry.layout_options.is_none());
|
||||
assert!(entry.layout_options_rules.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_layout_default_entry_roundtrip() {
|
||||
let json = r#"{
|
||||
"layout_options": {"column_ratios": [0.7]},
|
||||
"layout_options_rules": {
|
||||
"2": {"column_ratios": [0.6]},
|
||||
"5": {"column_ratios": [0.3, 0.3, 0.3]}
|
||||
}
|
||||
}"#;
|
||||
let original: LayoutDefaultEntry = serde_json::from_str(json).unwrap();
|
||||
let serialized = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: LayoutDefaultEntry = serde_json::from_str(&serialized).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
original.layout_options.unwrap().column_ratios,
|
||||
deserialized.layout_options.unwrap().column_ratios
|
||||
);
|
||||
let orig_rules = original.layout_options_rules.unwrap();
|
||||
let deser_rules = deserialized.layout_options_rules.unwrap();
|
||||
assert_eq!(orig_rules.len(), deser_rules.len());
|
||||
for (key, orig_opts) in &orig_rules {
|
||||
let deser_opts = &deser_rules[key];
|
||||
assert_eq!(orig_opts.column_ratios, deser_opts.column_ratios);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_layout_defaults_full_config_deserialize() {
|
||||
// Simulate the top-level layout_defaults field
|
||||
let json = r#"{
|
||||
"VerticalStack": {
|
||||
"layout_options": {"column_ratios": [0.7]},
|
||||
"layout_options_rules": {
|
||||
"2": {"column_ratios": [0.7]},
|
||||
"3": {"column_ratios": [0.55]}
|
||||
}
|
||||
},
|
||||
"HorizontalStack": {
|
||||
"layout_options": {"column_ratios": [0.6]}
|
||||
},
|
||||
"Columns": {
|
||||
"layout_options_rules": {
|
||||
"4": {"column_ratios": [0.3, 0.3, 0.3]}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
let defaults: HashMap<DefaultLayout, LayoutDefaultEntry> =
|
||||
serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(defaults.len(), 3);
|
||||
|
||||
// VerticalStack: has both base and rules
|
||||
let vs = &defaults[&DefaultLayout::VerticalStack];
|
||||
assert!(vs.layout_options.is_some());
|
||||
assert_eq!(vs.layout_options_rules.as_ref().unwrap().len(), 2);
|
||||
|
||||
// HorizontalStack: has only base
|
||||
let hs = &defaults[&DefaultLayout::HorizontalStack];
|
||||
assert!(hs.layout_options.is_some());
|
||||
assert!(hs.layout_options_rules.is_none());
|
||||
|
||||
// Columns: has only rules
|
||||
let cols = &defaults[&DefaultLayout::Columns];
|
||||
assert!(cols.layout_options.is_none());
|
||||
assert_eq!(cols.layout_options_rules.as_ref().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_layout_default_entry_with_scrolling_and_grid() {
|
||||
let json = r#"{
|
||||
"layout_options": {
|
||||
"column_ratios": [0.5],
|
||||
"scrolling": {"columns": 3},
|
||||
"grid": {"rows": 2}
|
||||
},
|
||||
"layout_options_rules": {
|
||||
"4": {
|
||||
"scrolling": {"columns": 5, "center_focused_column": true}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
let entry: LayoutDefaultEntry = serde_json::from_str(json).unwrap();
|
||||
|
||||
let base = entry.layout_options.unwrap();
|
||||
assert_eq!(base.scrolling.unwrap().columns, 3);
|
||||
assert_eq!(base.grid.unwrap().rows, 2);
|
||||
|
||||
let rules = entry.layout_options_rules.unwrap();
|
||||
let r4 = &rules[&4];
|
||||
assert_eq!(r4.scrolling.unwrap().columns, 5);
|
||||
assert_eq!(r4.scrolling.unwrap().center_focused_column, Some(true));
|
||||
// Rule doesn't inherit base fields - full replacement
|
||||
assert!(r4.column_ratios.is_none());
|
||||
assert!(r4.grid.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_layout_default_entry_skip_serializing_none() {
|
||||
// When both fields are None, they should not appear in output
|
||||
let entry = LayoutDefaultEntry {
|
||||
layout_options: None,
|
||||
layout_options_rules: None,
|
||||
};
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
assert!(!json.contains("layout_options"));
|
||||
assert!(!json.contains("layout_options_rules"));
|
||||
assert_eq!(json, "{}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Tests for the complete-replacement cascade logic.
|
||||
///
|
||||
/// This mirrors the resolution algorithm in workspace.rs::update():
|
||||
/// - If the workspace defines EITHER layout_options OR layout_options_rules,
|
||||
/// it completely replaces the global layout_defaults for this layout.
|
||||
/// - Global defaults are only used when the workspace has NEITHER setting.
|
||||
/// - Within the effective source (workspace or global):
|
||||
/// 1. Try threshold match from rules (highest matching threshold wins)
|
||||
/// 2. If a rule matches -> use it (full replacement of base)
|
||||
/// 3. Else -> use the base layout_options
|
||||
///
|
||||
/// Since the actual cascade is in workspace.rs (which has heavy WM dependencies),
|
||||
/// we test the pure algorithm here using the same data structures.
|
||||
mod cascade_resolution_tests {
|
||||
use super::*;
|
||||
|
||||
/// Simulates the cascade resolution logic from workspace.rs::update().
|
||||
/// This is a pure function equivalent of the inline code in update().
|
||||
fn resolve_effective_options(
|
||||
container_count: usize,
|
||||
workspace_base: Option<LayoutOptions>,
|
||||
workspace_rules: &[(usize, LayoutOptions)], // sorted by threshold ascending
|
||||
global_base: Option<LayoutOptions>,
|
||||
global_rules: &[(usize, LayoutOptions)], // sorted by threshold ascending
|
||||
) -> Option<LayoutOptions> {
|
||||
let has_workspace_overrides = workspace_base.is_some() || !workspace_rules.is_empty();
|
||||
|
||||
let (effective_base, effective_rules): (Option<LayoutOptions>, &[(usize, LayoutOptions)]) =
|
||||
if has_workspace_overrides {
|
||||
(workspace_base, workspace_rules)
|
||||
} else {
|
||||
(global_base, global_rules)
|
||||
};
|
||||
|
||||
// Try threshold match from effective rules
|
||||
let mut matched = None;
|
||||
for (threshold, opts) in effective_rules {
|
||||
if container_count >= *threshold {
|
||||
matched = Some(*opts);
|
||||
}
|
||||
}
|
||||
|
||||
// If a rule matched, use it (full replacement); otherwise use effective base
|
||||
if matched.is_some() {
|
||||
matched
|
||||
} else {
|
||||
effective_base
|
||||
}
|
||||
}
|
||||
|
||||
fn opts_with_ratio(ratio: f32) -> LayoutOptions {
|
||||
layout_options_with_column_ratios(&[ratio])
|
||||
}
|
||||
|
||||
// --- No overrides ---
|
||||
|
||||
#[test]
|
||||
fn test_no_workspace_no_global_returns_none() {
|
||||
let result = resolve_effective_options(3, None, &[], None, &[]);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
// --- Base-only scenarios ---
|
||||
|
||||
#[test]
|
||||
fn test_workspace_base_only() {
|
||||
let ws_base = opts_with_ratio(0.7);
|
||||
let result = resolve_effective_options(3, Some(ws_base), &[], None, &[]);
|
||||
assert_eq!(result.unwrap().column_ratios, ws_base.column_ratios);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_base_only() {
|
||||
let global_base = opts_with_ratio(0.6);
|
||||
let result = resolve_effective_options(3, None, &[], Some(global_base), &[]);
|
||||
assert_eq!(result.unwrap().column_ratios, global_base.column_ratios);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workspace_base_overrides_all_globals() {
|
||||
// Workspace has base → globals (both base and rules) are ignored entirely
|
||||
let ws_base = opts_with_ratio(0.7);
|
||||
let global_base = opts_with_ratio(0.6);
|
||||
let global_rules = vec![(2, opts_with_ratio(0.5))];
|
||||
let result =
|
||||
resolve_effective_options(3, Some(ws_base), &[], Some(global_base), &global_rules);
|
||||
// Workspace base wins; global rules are NOT used even though they would match
|
||||
assert_eq!(result.unwrap().column_ratios, ws_base.column_ratios);
|
||||
}
|
||||
|
||||
// --- Rules-only scenarios ---
|
||||
|
||||
#[test]
|
||||
fn test_global_rules_match() {
|
||||
let global_rules = vec![(2, opts_with_ratio(0.6)), (4, opts_with_ratio(0.5))];
|
||||
// 3 containers: matches threshold 2, not 4
|
||||
let result = resolve_effective_options(3, None, &[], None, &global_rules);
|
||||
assert_eq!(result.unwrap().column_ratios.unwrap()[0], Some(0.6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_rules_highest_matching_threshold_wins() {
|
||||
let global_rules = vec![(2, opts_with_ratio(0.6)), (4, opts_with_ratio(0.5))];
|
||||
// 5 containers: matches both thresholds 2 and 4; highest (4) wins
|
||||
let result = resolve_effective_options(5, None, &[], None, &global_rules);
|
||||
assert_eq!(result.unwrap().column_ratios.unwrap()[0], Some(0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_rules_no_match_falls_through_to_none() {
|
||||
let global_rules = vec![(5, opts_with_ratio(0.5))];
|
||||
// 3 containers: doesn't match threshold 5
|
||||
let result = resolve_effective_options(3, None, &[], None, &global_rules);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_rules_no_match_falls_through_to_global_base() {
|
||||
let global_base = opts_with_ratio(0.6);
|
||||
let global_rules = vec![(5, opts_with_ratio(0.5))];
|
||||
// 3 containers: doesn't match threshold 5, falls back to global base
|
||||
let result = resolve_effective_options(3, None, &[], Some(global_base), &global_rules);
|
||||
assert_eq!(result.unwrap().column_ratios, global_base.column_ratios);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workspace_rules_override_global_rules() {
|
||||
let ws_rules = vec![(2, opts_with_ratio(0.8))];
|
||||
let global_rules = vec![(2, opts_with_ratio(0.6))];
|
||||
// Workspace has rules → global rules are ignored entirely
|
||||
let result = resolve_effective_options(3, None, &ws_rules, None, &global_rules);
|
||||
assert_eq!(result.unwrap().column_ratios.unwrap()[0], Some(0.8));
|
||||
}
|
||||
|
||||
// --- Complete replacement: workspace having EITHER setting disables ALL globals ---
|
||||
|
||||
#[test]
|
||||
fn test_workspace_rules_disable_global_base() {
|
||||
// Workspace has rules but no base. Global has base.
|
||||
// Since workspace has a setting, globals are completely replaced.
|
||||
let ws_rules = vec![(2, opts_with_ratio(0.8))];
|
||||
let global_base = opts_with_ratio(0.6);
|
||||
// Rule matches → use it. Global base is NOT available as fallback.
|
||||
let result = resolve_effective_options(3, None, &ws_rules, Some(global_base), &[]);
|
||||
assert_eq!(result.unwrap().column_ratios.unwrap()[0], Some(0.8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workspace_rules_no_match_does_not_fall_to_global_base() {
|
||||
// Workspace has rules (but they don't match). Global has base.
|
||||
// Since workspace has a setting, globals are completely replaced → returns None.
|
||||
let ws_rules = vec![(5, opts_with_ratio(0.8))];
|
||||
let global_base = opts_with_ratio(0.6);
|
||||
let result = resolve_effective_options(3, None, &ws_rules, Some(global_base), &[]);
|
||||
// No workspace base, no rule match, globals ignored → None
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workspace_base_disables_global_rules() {
|
||||
// Workspace has base but no rules. Global has rules.
|
||||
// Since workspace has a setting, globals are completely replaced.
|
||||
let ws_base = opts_with_ratio(0.7);
|
||||
let global_rules = vec![(2, opts_with_ratio(0.5))];
|
||||
// No workspace rules → no rule match → use workspace base. Global rules ignored.
|
||||
let result = resolve_effective_options(3, Some(ws_base), &[], None, &global_rules);
|
||||
assert_eq!(result.unwrap().column_ratios, ws_base.column_ratios);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workspace_base_disables_global_rules_and_base() {
|
||||
// Workspace has base. Global has both rules and base.
|
||||
// Since workspace has a setting, all globals are completely replaced.
|
||||
let ws_base = opts_with_ratio(0.7);
|
||||
let global_base = opts_with_ratio(0.6);
|
||||
let global_rules = vec![(2, opts_with_ratio(0.5))];
|
||||
let result =
|
||||
resolve_effective_options(3, Some(ws_base), &[], Some(global_base), &global_rules);
|
||||
// Only workspace base is used; global rules and base are both ignored
|
||||
assert_eq!(result.unwrap().column_ratios, ws_base.column_ratios);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workspace_rules_disable_global_rules_and_base() {
|
||||
// Workspace has rules. Global has both rules and base.
|
||||
// Since workspace has a setting, all globals are completely replaced.
|
||||
let ws_rules = vec![(2, opts_with_ratio(0.8))];
|
||||
let global_base = opts_with_ratio(0.6);
|
||||
let global_rules = vec![(2, opts_with_ratio(0.5))];
|
||||
let result =
|
||||
resolve_effective_options(3, None, &ws_rules, Some(global_base), &global_rules);
|
||||
// Workspace rule matches → 0.8. Global base and rules both ignored.
|
||||
assert_eq!(result.unwrap().column_ratios.unwrap()[0], Some(0.8));
|
||||
}
|
||||
|
||||
// --- Full replacement semantics (rule match replaces base) ---
|
||||
|
||||
#[test]
|
||||
fn test_rule_match_is_full_replacement_not_merge() {
|
||||
// When a rule matches, its options FULLY REPLACE the base.
|
||||
// Fields not specified in the rule default to their standard defaults.
|
||||
let ws_base = layout_options_with_ratios(&[0.7], &[0.4]);
|
||||
let rule_opts = layout_options_with_column_ratios(&[0.5]);
|
||||
// rule_opts has column_ratios but no row_ratios
|
||||
let ws_rules = vec![(2, rule_opts)];
|
||||
let result = resolve_effective_options(3, Some(ws_base), &ws_rules, None, &[]);
|
||||
let effective = result.unwrap();
|
||||
// Column ratios come from the rule
|
||||
assert_eq!(effective.column_ratios.unwrap()[0], Some(0.5));
|
||||
// Row ratios are NOT inherited from ws_base - they're None (full replacement)
|
||||
assert!(effective.row_ratios.is_none());
|
||||
}
|
||||
|
||||
// --- Edge cases ---
|
||||
|
||||
#[test]
|
||||
fn test_exact_threshold_match() {
|
||||
let rules = vec![(3, opts_with_ratio(0.6))];
|
||||
let result = resolve_effective_options(3, None, &rules, None, &[]);
|
||||
assert_eq!(result.unwrap().column_ratios.unwrap()[0], Some(0.6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_container_count_one_below_threshold() {
|
||||
let rules = vec![(3, opts_with_ratio(0.6))];
|
||||
let result = resolve_effective_options(2, None, &rules, None, &[]);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zero_containers() {
|
||||
let ws_base = opts_with_ratio(0.7);
|
||||
let rules = vec![(1, opts_with_ratio(0.5))];
|
||||
let result = resolve_effective_options(0, Some(ws_base), &rules, None, &[]);
|
||||
// 0 containers doesn't match threshold 1 → falls back to workspace base
|
||||
assert_eq!(result.unwrap().column_ratios, ws_base.column_ratios);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_many_thresholds_correct_match() {
|
||||
let rules = vec![
|
||||
(1, opts_with_ratio(0.8)),
|
||||
(3, opts_with_ratio(0.6)),
|
||||
(5, opts_with_ratio(0.4)),
|
||||
(8, opts_with_ratio(0.3)),
|
||||
];
|
||||
// 6 containers: matches 1, 3, 5 but not 8. Highest match is 5.
|
||||
let result = resolve_effective_options(6, None, &rules, None, &[]);
|
||||
assert_eq!(result.unwrap().column_ratios.unwrap()[0], Some(0.4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workspace_rules_disable_global_rules_even_if_ws_rules_dont_match() {
|
||||
// Key behavior: if workspace has ANY setting, globals are entirely ignored.
|
||||
// Even if workspace rules don't match, we don't fall back to global rules.
|
||||
let ws_rules = vec![(10, opts_with_ratio(0.8))]; // threshold too high
|
||||
let global_rules = vec![(2, opts_with_ratio(0.5))]; // would match
|
||||
let result = resolve_effective_options(3, None, &ws_rules, None, &global_rules);
|
||||
// Workspace has rules → all globals ignored. WS rules don't match → None.
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_four_sources_present_rules_match() {
|
||||
// All four sources present: workspace base, workspace rules, global base, global rules
|
||||
let ws_base = opts_with_ratio(0.7);
|
||||
let ws_rules = vec![(2, opts_with_ratio(0.8))];
|
||||
let global_base = opts_with_ratio(0.6);
|
||||
let global_rules = vec![(2, opts_with_ratio(0.5))];
|
||||
let result = resolve_effective_options(
|
||||
3,
|
||||
Some(ws_base),
|
||||
&ws_rules,
|
||||
Some(global_base),
|
||||
&global_rules,
|
||||
);
|
||||
// Workspace has settings → uses workspace only. Rule matches → 0.8
|
||||
assert_eq!(result.unwrap().column_ratios.unwrap()[0], Some(0.8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_four_sources_present_rules_no_match() {
|
||||
// All four sources present, but workspace rules don't match
|
||||
let ws_base = opts_with_ratio(0.7);
|
||||
let ws_rules = vec![(10, opts_with_ratio(0.8))]; // threshold too high
|
||||
let global_base = opts_with_ratio(0.6);
|
||||
let global_rules = vec![(10, opts_with_ratio(0.5))]; // also too high
|
||||
let result = resolve_effective_options(
|
||||
3,
|
||||
Some(ws_base),
|
||||
&ws_rules,
|
||||
Some(global_base),
|
||||
&global_rules,
|
||||
);
|
||||
// Workspace has settings → uses workspace only. No rule match → workspace base 0.7
|
||||
assert_eq!(result.unwrap().column_ratios, ws_base.column_ratios);
|
||||
}
|
||||
|
||||
// --- Workspace with both base and rules ---
|
||||
|
||||
#[test]
|
||||
fn test_workspace_both_rule_matches() {
|
||||
let ws_base = opts_with_ratio(0.7);
|
||||
let ws_rules = vec![(2, opts_with_ratio(0.5))];
|
||||
let result = resolve_effective_options(3, Some(ws_base), &ws_rules, None, &[]);
|
||||
// Rule matches → use rule (full replacement), not ws_base
|
||||
assert_eq!(result.unwrap().column_ratios.unwrap()[0], Some(0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workspace_both_rule_no_match() {
|
||||
let ws_base = opts_with_ratio(0.7);
|
||||
let ws_rules = vec![(10, opts_with_ratio(0.5))];
|
||||
let result = resolve_effective_options(3, Some(ws_base), &ws_rules, None, &[]);
|
||||
// Rule doesn't match → fall back to ws_base
|
||||
assert_eq!(result.unwrap().column_ratios, ws_base.column_ratios);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ pub use komorebi_layouts::DefaultLayout;
|
||||
pub use komorebi_layouts::Direction;
|
||||
pub use komorebi_layouts::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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -254,6 +254,7 @@ impl From<&WindowManager> for State {
|
||||
layout_options: workspace.layout_options,
|
||||
layout_rules: workspace.layout_rules.clone(),
|
||||
layout_options_rules: workspace.layout_options_rules.clone(),
|
||||
layout_defaults_cache: workspace.layout_defaults_cache.clone(),
|
||||
work_area_offset_rules: workspace.work_area_offset_rules.clone(),
|
||||
layout_flip: workspace.layout_flip,
|
||||
workspace_padding: workspace.workspace_padding,
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::FloatingLayerBehaviour;
|
||||
use crate::HIDING_BEHAVIOUR;
|
||||
use crate::IGNORE_IDENTIFIERS;
|
||||
use crate::LAYERED_WHITELIST;
|
||||
use crate::LAYOUT_DEFAULTS;
|
||||
use crate::MANAGE_IDENTIFIERS;
|
||||
use crate::MONITOR_INDEX_PREFERENCES;
|
||||
use crate::NO_TITLEBAR;
|
||||
@@ -53,6 +54,7 @@ use crate::core::DefaultLayout;
|
||||
use crate::core::FocusFollowsMouseImplementation;
|
||||
use crate::core::HidingBehaviour;
|
||||
use crate::core::Layout;
|
||||
use crate::core::LayoutDefaultEntry;
|
||||
use crate::core::LayoutOptions;
|
||||
use crate::core::MoveBehaviour;
|
||||
use crate::core::OperationBehaviour;
|
||||
@@ -593,6 +595,11 @@ pub struct StaticConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "schemars", schemars(extend("default" = DEFAULT_CONTAINER_PADDING)))]
|
||||
pub default_container_padding: Option<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>>,
|
||||
@@ -902,6 +909,14 @@ impl From<&WindowManager> for StaticConfig {
|
||||
default_container_padding: Option::from(
|
||||
DEFAULT_CONTAINER_PADDING.load(Ordering::SeqCst),
|
||||
),
|
||||
layout_defaults: {
|
||||
let guard = LAYOUT_DEFAULTS.lock();
|
||||
if guard.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(guard.clone())
|
||||
}
|
||||
},
|
||||
monitors: Option::from(monitors),
|
||||
window_hiding_behaviour: Option::from(*HIDING_BEHAVIOUR.lock()),
|
||||
global_work_area_offset: value.work_area_offset,
|
||||
@@ -1017,6 +1032,12 @@ impl StaticConfig {
|
||||
DEFAULT_WORKSPACE_PADDING.store(workspace, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
if let Some(defaults) = &self.layout_defaults {
|
||||
*LAYOUT_DEFAULTS.lock() = defaults.clone();
|
||||
} else {
|
||||
LAYOUT_DEFAULTS.lock().clear();
|
||||
}
|
||||
|
||||
if let Some(border_width) = self.border_width {
|
||||
border_manager::BORDER_WIDTH.store(border_width, Ordering::SeqCst);
|
||||
}
|
||||
@@ -1425,7 +1446,7 @@ impl StaticConfig {
|
||||
workspace_config.layout = Some(DefaultLayout::Columns);
|
||||
}
|
||||
|
||||
ws.load_static_config(workspace_config)?;
|
||||
ws.load_static_config(workspace_config, value.layout_defaults.as_ref())?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1508,7 +1529,10 @@ impl StaticConfig {
|
||||
|
||||
for (j, ws) in m.workspaces_mut().iter_mut().enumerate() {
|
||||
if let Some(workspace_config) = monitor_config.workspaces.get(j) {
|
||||
ws.load_static_config(workspace_config)?;
|
||||
ws.load_static_config(
|
||||
workspace_config,
|
||||
value.layout_defaults.as_ref(),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1590,7 +1614,7 @@ impl StaticConfig {
|
||||
|
||||
for (j, ws) in monitor.workspaces_mut().iter_mut().enumerate() {
|
||||
if let Some(workspace_config) = monitor_config.workspaces.get(j) {
|
||||
ws.load_static_config(workspace_config)?;
|
||||
ws.load_static_config(workspace_config, value.layout_defaults.as_ref())?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1673,7 +1697,10 @@ impl StaticConfig {
|
||||
|
||||
for (j, ws) in m.workspaces_mut().iter_mut().enumerate() {
|
||||
if let Some(workspace_config) = monitor_config.workspaces.get(j) {
|
||||
ws.load_static_config(workspace_config)?;
|
||||
ws.load_static_config(
|
||||
workspace_config,
|
||||
value.layout_defaults.as_ref(),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+104
-30
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::VecDeque;
|
||||
use std::ffi::OsStr;
|
||||
use std::fmt::Display;
|
||||
@@ -25,6 +26,7 @@ use crate::core::CustomLayout;
|
||||
use crate::core::CycleDirection;
|
||||
use crate::core::DefaultLayout;
|
||||
use crate::core::Layout;
|
||||
use crate::core::LayoutDefaultEntry;
|
||||
use crate::core::LayoutOptions;
|
||||
use crate::core::OperationDirection;
|
||||
use crate::core::Rect;
|
||||
@@ -65,6 +67,10 @@ pub struct Workspace {
|
||||
/// Sorted by threshold ascending at load time.
|
||||
#[serde(default)]
|
||||
pub layout_options_rules: Vec<(usize, LayoutOptions)>,
|
||||
/// Cached per-layout defaults from the global `layout_defaults` config setting.
|
||||
/// Pre-sorted at config load time; used as fallback when workspace has no overrides.
|
||||
#[serde(skip)]
|
||||
pub(crate) layout_defaults_cache: HashMap<DefaultLayout, CachedLayoutDefault>,
|
||||
pub work_area_offset_rules: Vec<(usize, Rect)>,
|
||||
pub layout_flip: Option<Axis>,
|
||||
pub workspace_padding: Option<i32>,
|
||||
@@ -124,6 +130,7 @@ impl Default for Workspace {
|
||||
layout_options: None,
|
||||
layout_rules: vec![],
|
||||
layout_options_rules: vec![],
|
||||
layout_defaults_cache: HashMap::new(),
|
||||
work_area_offset_rules: vec![],
|
||||
layout_flip: None,
|
||||
workspace_padding: Option::from(DEFAULT_WORKSPACE_PADDING.load(Ordering::SeqCst)),
|
||||
@@ -170,8 +177,49 @@ pub struct WorkspaceGlobals {
|
||||
pub floating_layer_behaviour: Option<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;
|
||||
@@ -261,14 +309,8 @@ impl Workspace {
|
||||
self.layout_options = config.layout_options;
|
||||
|
||||
// Load threshold-based layout options rules, sorted by threshold ascending
|
||||
self.layout_options_rules = if let Some(rules) = &config.layout_options_rules {
|
||||
let mut sorted_rules: Vec<(usize, LayoutOptions)> =
|
||||
rules.iter().map(|(t, o)| (*t, *o)).collect();
|
||||
sorted_rules.sort_by_key(|(t, _)| *t);
|
||||
sorted_rules
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
self.layout_options_rules =
|
||||
sorted_layout_options_rules(config.layout_options_rules.as_ref());
|
||||
|
||||
tracing::debug!(
|
||||
"Workspace '{}' loaded layout_options: {:?}, layout_options_rules: {} entries",
|
||||
@@ -277,11 +319,63 @@ impl Workspace {
|
||||
self.layout_options_rules.len(),
|
||||
);
|
||||
|
||||
// Cache per-layout defaults from global layout_defaults, pre-sorting rules
|
||||
self.layout_defaults_cache = if let Some(defaults) = layout_defaults {
|
||||
defaults
|
||||
.iter()
|
||||
.map(|(layout, entry)| {
|
||||
(
|
||||
*layout,
|
||||
CachedLayoutDefault {
|
||||
layout_options: entry.layout_options,
|
||||
layout_options_rules: sorted_layout_options_rules(
|
||||
entry.layout_options_rules.as_ref(),
|
||||
),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
self.workspace_config = Some(config.clone());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compute effective layout options using the complete-replacement cascade:
|
||||
///
|
||||
/// If the workspace defines EITHER `layout_options` OR `layout_options_rules`,
|
||||
/// it completely replaces the global `layout_defaults` for this layout.
|
||||
/// Global defaults are only used when the workspace has NEITHER setting.
|
||||
///
|
||||
/// Within the effective source (workspace or global):
|
||||
/// 1. Try threshold match from rules (highest matching threshold wins)
|
||||
/// 2. If a rule matches -> use it (full replacement of base)
|
||||
/// 3. Else -> use the base `layout_options`
|
||||
fn effective_layout_options(&self) -> Option<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();
|
||||
@@ -602,27 +696,7 @@ impl Workspace {
|
||||
} else if let Some(window) = &mut self.maximized_window {
|
||||
window.maximize();
|
||||
} else if !self.containers().is_empty() {
|
||||
// Compute effective layout options:
|
||||
// 1. If layout_options_rules has a matching threshold (container_count >= threshold),
|
||||
// use the highest matching threshold's options (full replacement)
|
||||
// 2. Otherwise, use the workspace base layout_options
|
||||
let effective_layout_options = if !self.layout_options_rules.is_empty() {
|
||||
let container_count = self.containers().len();
|
||||
let mut matched = None;
|
||||
for (threshold, opts) in &self.layout_options_rules {
|
||||
if container_count >= *threshold {
|
||||
matched = Some(*opts);
|
||||
}
|
||||
}
|
||||
// If a rule matched, use it entirely (replaces base layout_options)
|
||||
if matched.is_some() {
|
||||
matched
|
||||
} else {
|
||||
self.layout_options
|
||||
}
|
||||
} else {
|
||||
self.layout_options
|
||||
};
|
||||
let effective_layout_options = self.effective_layout_options();
|
||||
|
||||
tracing::debug!(
|
||||
"Workspace '{}' update() - effective_layout_options: {:?} (base: {:?}, rules: {})",
|
||||
|
||||
+40
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user