diff --git a/docs/assets/layout-ratios_after.png b/docs/assets/layout-ratios_after.png new file mode 100644 index 00000000..43cbe251 Binary files /dev/null and b/docs/assets/layout-ratios_after.png differ diff --git a/docs/assets/layout-ratios_before.png b/docs/assets/layout-ratios_before.png new file mode 100644 index 00000000..23779c9d Binary files /dev/null and b/docs/assets/layout-ratios_before.png differ diff --git a/docs/common-workflows/layout-ratios.md b/docs/common-workflows/layout-ratios.md new file mode 100644 index 00000000..e690ed60 --- /dev/null +++ b/docs/common-workflows/layout-ratios.md @@ -0,0 +1,200 @@ +# Layout Ratios + +With `komorebi` you can customize the split ratios for various layouts using +`column_ratios` and `row_ratios` in the `layout_options` configuration. + +## Before and After + +BSP layout example: + +**Before** (default 50/50 splits): + +![Before layout ratios](../assets/layout-ratios_before.png) + +**After** (with `column_ratios: [0.7]` and `row_ratios: [0.6]`): + +![After layout ratios](../assets/layout-ratios_after.png) + +## Configuration + +```json +{ + "monitors": [ + { + "workspaces": [ + { + "name": "main", + "layout_options": { + "column_ratios": [0.3, 0.4], + "row_ratios": [0.4, 0.3] + } + } + ] + } + ] +} +``` + +You can specify up to 5 ratio values (defined by `MAX_RATIOS` constant). Each value should be between 0.1 and 0.9 +(defined by `MIN_RATIO` and `MAX_RATIO` constants). Values outside this range are automatically clamped. +Columns or rows without a specified ratio will share the remaining space equally. + +## Usage by Layout + +| Layout | `column_ratios` | `row_ratios` | +|--------|-----------------|--------------| +| **Columns** | Width of each column | - | +| **Rows** | - | Height of each row | +| **Grid** | Width of each column (rows are equal height) | - | +| **BSP** | `[0]` as horizontal split ratio | `[0]` as vertical split ratio | +| **VerticalStack** | `[0]` as primary column width | Stack row heights | +| **RightMainVerticalStack** | `[0]` as primary column width | Stack row heights | +| **HorizontalStack** | Stack column widths | `[0]` as primary row height | +| **UltrawideVerticalStack** | `[0]` center, `[1]` left column | Tertiary stack row heights | + +## Examples + +### Columns Layout with Custom Widths + +Create 3 columns with 30%, 40%, and 30% widths: + +```json +{ + "layout_options": { + "column_ratios": [0.3, 0.4] + } +} +``` + +Note: The third column automatically gets the remaining 30%. + +### Rows Layout with Custom Heights + +Create 3 rows with 20%, 50%, and 30% heights: + +```json +{ + "layout_options": { + "row_ratios": [0.2, 0.5] + } +} +``` + +Note: The third row automatically gets the remaining 30%. + +### Grid Layout with Custom Column Widths + +Grid with custom column widths (rows within each column are always equal height): + +```json +{ + "layout_options": { + "column_ratios": [0.4, 0.6] + } +} +``` + +Note: The Grid layout only supports `column_ratios`. Rows within each column are always +divided equally because the number of rows per column varies dynamically based on window count. + +### VerticalStack with Custom Ratios + +Primary column takes 60% width, and the stack rows are split 30%/70%: + +```json +{ + "layout_options": { + "column_ratios": [0.6], + "row_ratios": [0.3] + } +} +``` + +Note: The second row automatically gets the remaining 70%. + +### HorizontalStack with Custom Ratios + +Primary row takes 70% height, and the stack columns are split 40%/60%: + +```json +{ + "layout_options": { + "row_ratios": [0.7], + "column_ratios": [0.4] + } +} +``` + +Note: The second column automatically gets the remaining 60%. + +### UltrawideVerticalStack with Custom Ratios + +Center column at 50%, left column at 25% (remaining 25% goes to tertiary stack), +with tertiary rows split 40%/60%: + +```json +{ + "layout_options": { + "column_ratios": [0.5, 0.25], + "row_ratios": [0.4] + } +} +``` + +Note: The second row automatically gets the remaining 60%. + +### BSP Layout with Custom Split Ratios + +Use separate ratios for horizontal (left/right) and vertical (top/bottom) splits: + +```json +{ + "layout_options": { + "column_ratios": [0.6], + "row_ratios": [0.3] + } +} +``` + +- `column_ratios[0]`: Controls all horizontal splits (left window gets 60%, right gets 40%) +- `row_ratios[0]`: Controls all vertical splits (top window gets 30%, bottom gets 70%) + +Note: BSP only uses the first value (`[0]`) from each ratio array. This single ratio is applied +consistently to all splits of that type throughout the layout. Additional values in the arrays are ignored. + +## Notes + +- Ratios are clamped between 0.1 and 0.9 (prevents zero-sized windows and ensures space for other windows) +- Default ratio is 0.5 (50%) when not specified, except for UltrawideVerticalStack secondary column which defaults to 0.25 (25%) +- Ratios are applied **progressively** - a ratio is only used when there are more windows to place after the current one +- The **last window always takes the remaining space**, regardless of defined ratios +- **Ratios that would sum to 100% or more are automatically truncated** at config load time to ensure there's always space for additional windows +- 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 + +## Progressive Ratio Behavior + +Ratios are applied progressively as windows are added. For example, with `row_ratios: [0.3, 0.5]` in a VerticalStack: + +| Windows in Stack | Row Heights | +|------------------|-------------| +| 1 | 100% | +| 2 | 30%, 70% (remainder) | +| 3 | 30%, 50%, 20% (remainder) | +| 4 | 30%, 50%, 10%, 10% (remainder split equally) | +| 5 | 30%, 50%, 6.67%, 6.67%, 6.67% | + +## Automatic Ratio Truncation + +When ratios sum to 100% (or more), they are automatically truncated at config load time. + +For example, if you configure `column_ratios: [0.4, 0.3, 0.3]` (sums to 100%), the last ratio (0.3) is automatically removed, resulting in effectively `[0.4, 0.3]`. This ensures there's always remaining space for the last window. + +| Configured Ratios | Effective Ratios | Reason | +|-------------------|------------------|--------| +| `[0.3, 0.4]` | `[0.3, 0.4]` | Sum is 0.7, below 1.0 | +| `[0.4, 0.3, 0.3]` | `[0.4, 0.3]` | Sum would be 1.0, last ratio truncated | +| `[0.5, 0.5]` | `[0.5]` | Sum would be 1.0, last ratio truncated | +| `[0.6, 0.5]` | `[0.6]` | Sum would exceed 1.0, last ratio truncated | + +This ensures the layout always fills 100% of the available space and new windows are never placed outside the visible area. diff --git a/komorebi/src/core/arrangement.rs b/komorebi/src/core/arrangement.rs index 1652bdae..fa031f76 100644 --- a/komorebi/src/core/arrangement.rs +++ b/komorebi/src/core/arrangement.rs @@ -12,7 +12,12 @@ use super::Rect; use super::custom_layout::Column; use super::custom_layout::ColumnSplit; use super::custom_layout::ColumnSplitWithCapacity; +use crate::default_layout::DEFAULT_RATIO; +use crate::default_layout::DEFAULT_SECONDARY_RATIO; use crate::default_layout::LayoutOptions; +use crate::default_layout::MAX_RATIO; +use crate::default_layout::MAX_RATIOS; +use crate::default_layout::MIN_RATIO; pub trait Arrangement { #[allow(clippy::too_many_arguments)] @@ -42,10 +47,23 @@ impl Arrangement for DefaultLayout { layout_options: Option, latest_layout: &[Rect], ) -> Vec { + // Trace layout_options for debugging + if let Some(ref opts) = layout_options { + tracing::debug!( + "Layout {:?} - layout_options received: column_ratios={:?}, row_ratios={:?}", + self, + opts.column_ratios, + opts.row_ratios + ); + } else { + tracing::debug!("Layout {:?} - no layout_options provided", self); + } + let len = usize::from(len); let mut dimensions = match self { Self::Scrolling => { let column_count = layout_options + .as_ref() .and_then(|o| o.scrolling.map(|s| s.columns)) .unwrap_or(3); @@ -54,6 +72,7 @@ impl Arrangement for DefaultLayout { let visible_columns = area.right / column_width; let keep_centered = layout_options + .as_ref() .and_then(|o| { o.scrolling .map(|s| s.center_focused_column.unwrap_or_default()) @@ -131,15 +150,30 @@ impl Arrangement for DefaultLayout { layouts } - Self::BSP => recursive_fibonacci( - 0, - len, - area, - layout_flip, - calculate_resize_adjustments(resize_dimensions), - ), + Self::BSP => { + let column_split_ratio = layout_options + .and_then(|o| o.column_ratios) + .and_then(|r| r[0]) + .unwrap_or(DEFAULT_RATIO) + .clamp(MIN_RATIO, MAX_RATIO); + let row_split_ratio = layout_options + .and_then(|o| o.row_ratios) + .and_then(|r| r[0]) + .unwrap_or(DEFAULT_RATIO) + .clamp(MIN_RATIO, MAX_RATIO); + recursive_fibonacci( + 0, + len, + area, + layout_flip, + calculate_resize_adjustments(resize_dimensions), + column_split_ratio, + row_split_ratio, + ) + } Self::Columns => { - let mut layouts = columns(area, len); + let ratios = layout_options.and_then(|o| o.column_ratios); + let mut layouts = columns_with_ratios(area, len, ratios); let adjustment = calculate_columns_adjustment(resize_dimensions); layouts @@ -163,7 +197,8 @@ impl Arrangement for DefaultLayout { layouts } Self::Rows => { - let mut layouts = rows(area, len); + let ratios = layout_options.and_then(|o| o.row_ratios); + let mut layouts = rows_with_ratios(area, len, ratios); let adjustment = calculate_rows_adjustment(resize_dimensions); layouts @@ -189,9 +224,17 @@ impl Arrangement for DefaultLayout { Self::VerticalStack => { let mut layouts: Vec = vec![]; + #[allow(clippy::cast_possible_truncation)] let primary_right = match len { 1 => area.right, - _ => area.right / 2, + _ => { + let ratio = layout_options + .and_then(|o| o.column_ratios) + .and_then(|r| r[0]) + .unwrap_or(DEFAULT_RATIO) + .clamp(MIN_RATIO, MAX_RATIO); + (area.right as f32 * ratio) as i32 + } }; let main_left = area.left; @@ -206,7 +249,8 @@ impl Arrangement for DefaultLayout { }); if len > 1 { - layouts.append(&mut rows( + let row_ratios = layout_options.and_then(|o| o.row_ratios); + layouts.append(&mut rows_with_ratios( &Rect { left: stack_left, top: area.top, @@ -214,6 +258,7 @@ impl Arrangement for DefaultLayout { bottom: area.bottom, }, len - 1, + row_ratios, )); } } @@ -257,9 +302,17 @@ impl Arrangement for DefaultLayout { // Shamelessly borrowed from LeftWM: https://github.com/leftwm/leftwm/commit/f673851745295ae7584a102535566f559d96a941 let mut layouts: Vec = vec![]; + #[allow(clippy::cast_possible_truncation)] let primary_width = match len { 1 => area.right, - _ => area.right / 2, + _ => { + let ratio = layout_options + .and_then(|o| o.column_ratios) + .and_then(|r| r[0]) + .unwrap_or(DEFAULT_RATIO) + .clamp(MIN_RATIO, MAX_RATIO); + (area.right as f32 * ratio) as i32 + } }; let primary_left = match len { @@ -276,7 +329,8 @@ impl Arrangement for DefaultLayout { }); if len > 1 { - layouts.append(&mut rows( + let row_ratios = layout_options.and_then(|o| o.row_ratios); + layouts.append(&mut rows_with_ratios( &Rect { left: area.left, top: area.top, @@ -284,6 +338,7 @@ impl Arrangement for DefaultLayout { bottom: area.bottom, }, len - 1, + row_ratios, )); } } @@ -326,9 +381,17 @@ impl Arrangement for DefaultLayout { Self::HorizontalStack => { let mut layouts: Vec = vec![]; + #[allow(clippy::cast_possible_truncation)] let bottom = match len { 1 => area.bottom, - _ => area.bottom / 2, + _ => { + let ratio = layout_options + .and_then(|o| o.row_ratios) + .and_then(|r| r[0]) + .unwrap_or(DEFAULT_RATIO) + .clamp(MIN_RATIO, MAX_RATIO); + (area.bottom as f32 * ratio) as i32 + } }; let main_top = area.top; @@ -343,7 +406,8 @@ impl Arrangement for DefaultLayout { }); if len > 1 { - layouts.append(&mut columns( + let col_ratios = layout_options.and_then(|o| o.column_ratios); + layouts.append(&mut columns_with_ratios( &Rect { left: area.left, top: stack_top, @@ -351,6 +415,7 @@ impl Arrangement for DefaultLayout { bottom: area.bottom - bottom, }, len - 1, + col_ratios, )); } } @@ -393,15 +458,28 @@ impl Arrangement for DefaultLayout { Self::UltrawideVerticalStack => { let mut layouts: Vec = vec![]; + // Get ratios: [0] = primary (center), [1] = secondary (left), remainder = tertiary (right) + let ratios = layout_options.and_then(|o| o.column_ratios); + let primary_ratio = ratios + .and_then(|r| r[0]) + .unwrap_or(DEFAULT_RATIO) + .clamp(MIN_RATIO, MAX_RATIO); + let secondary_ratio = ratios + .and_then(|r| r[1]) + .unwrap_or(DEFAULT_SECONDARY_RATIO) + .clamp(MIN_RATIO, MAX_RATIO); + + #[allow(clippy::cast_possible_truncation)] let primary_right = match len { 1 => area.right, - _ => area.right / 2, + _ => (area.right as f32 * primary_ratio) as i32, }; + #[allow(clippy::cast_possible_truncation)] let secondary_right = match len { 1 => 0, 2 => area.right - primary_right, - _ => (area.right - primary_right) / 2, + _ => (area.right as f32 * secondary_ratio) as i32, }; let (primary_left, secondary_left, stack_left) = match len { @@ -438,14 +516,18 @@ impl Arrangement for DefaultLayout { }); if len > 2 { - layouts.append(&mut rows( + // Tertiary column gets remaining space after primary and secondary + let tertiary_right = area.right - primary_right - secondary_right; + let row_ratios = layout_options.and_then(|o| o.row_ratios); + layouts.append(&mut rows_with_ratios( &Rect { left: stack_left, top: area.top, - right: secondary_right, + right: tertiary_right, bottom: area.bottom, }, len - 2, + row_ratios, )); } } @@ -514,13 +596,66 @@ impl Arrangement for DefaultLayout { let len = len as i32; - let row_constraint = layout_options.and_then(|o| o.grid.map(|g| g.rows)); + let row_constraint = layout_options.as_ref().and_then(|o| o.grid.map(|g| g.rows)); + let column_ratios = layout_options.and_then(|o| o.column_ratios); + + // Count defined column ratios (already validated at deserialization to sum < 1.0) + let defined_ratios = column_ratios + .as_ref() + .map(|r| r.iter().filter(|x| x.is_some()).count()) + .unwrap_or(0); + let num_cols = if let Some(rows) = row_constraint { ((len as f32) / (rows as f32)).ceil() as i32 } else { (len as f32).sqrt().ceil() as i32 }; + // Pre-calculate column widths and left positions using same logic as columns_with_ratios + let mut col_widths: Vec = Vec::with_capacity(num_cols as usize); + let mut col_lefts: Vec = Vec::with_capacity(num_cols as usize); + let mut current_left = area.left; + + for col in 0..num_cols { + let col_idx = col as usize; + let width = if let Some(ref ratios) = column_ratios { + // Only apply ratio if there's at least one more column after this + // The last column always gets the remaining space + let should_apply_ratio = + col_idx < MAX_RATIOS && col_idx < defined_ratios && col < num_cols - 1; + + if should_apply_ratio { + if let Some(ratio) = ratios[col_idx] { + (area.right as f32 * ratio) as i32 + } else { + let used: f32 = (0..col_idx).filter_map(|j| ratios[j]).sum(); + let remaining_space = + area.right - (area.right as f32 * used) as i32; + let remaining_cols = num_cols - col; + remaining_space / remaining_cols + } + } else { + // Beyond defined ratios or last column - split remaining space equally + // Only count ratios that were actually applied (up to defined_ratios, but not beyond num_cols - 1) + let ratios_applied = defined_ratios.min((num_cols - 1) as usize); + let used: f32 = (0..ratios_applied).filter_map(|j| ratios[j]).sum(); + let remaining_space = area.right - (area.right as f32 * used) as i32; + let remaining_cols = (num_cols as usize - ratios_applied) as i32; + if remaining_cols > 0 { + remaining_space / remaining_cols + } else { + remaining_space + } + } + } else { + area.right / num_cols + }; + + col_lefts.push(current_left); + col_widths.push(width); + current_left += width; + } + let mut iter = layouts.iter_mut().enumerate().peekable(); for col in 0..num_cols { @@ -534,23 +669,31 @@ impl Arrangement for DefaultLayout { remaining_windows / remaining_columns }; + // Rows within each column are equal height (no row_ratios support for Grid) let win_height = area.bottom / num_rows_in_this_col; - let win_width = area.right / num_cols; + + let col_idx = col as usize; + let win_width = col_widths[col_idx]; + let col_left = col_lefts[col_idx]; for row in 0..num_rows_in_this_col { if let Some((_idx, win)) = iter.next() { - let mut left = area.left + win_width * col; + let mut left = col_left; let mut top = area.top + win_height * row; match layout_flip { Some(Axis::Horizontal) => { - left = area.right - win_width * (col + 1) + area.left; + // Calculate flipped left position + let flipped_col = (num_cols - 1 - col) as usize; + left = col_lefts[flipped_col]; } Some(Axis::Vertical) => { + // Calculate flipped top position top = area.bottom - win_height * (row + 1) + area.top; } Some(Axis::HorizontalAndVertical) => { - left = area.right - win_width * (col + 1) + area.left; + let flipped_col = (num_cols - 1 - col) as usize; + left = col_lefts[flipped_col]; top = area.bottom - win_height * (row + 1) + area.top; } None => {} // No flip @@ -716,12 +859,65 @@ pub enum Axis { #[must_use] fn columns(area: &Rect, len: usize) -> Vec { - #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)] - let right = area.right / len as i32; + columns_with_ratios(area, len, None) +} + +#[must_use] +fn columns_with_ratios( + area: &Rect, + len: usize, + ratios: Option<[Option; MAX_RATIOS]>, +) -> Vec { + tracing::debug!( + "columns_with_ratios called: len={}, ratios={:?}", + len, + ratios + ); + let mut layouts: Vec = vec![]; let mut left = 0; - let mut layouts: Vec = vec![]; - for _ in 0..len { + // Count how many ratios are defined (already validated at deserialization to sum < 1.0) + let defined_ratios = ratios + .as_ref() + .map(|r| r.iter().filter(|x| x.is_some()).count()) + .unwrap_or(0); + + for i in 0..len { + #[allow(clippy::cast_possible_truncation)] + let right = if let Some(ref r) = ratios { + // Only apply ratio[i] if there's at least one more column after this (i < len - 1) + // The last column always gets the remaining space + let should_apply_ratio = i < MAX_RATIOS && i < defined_ratios && i < len - 1; + + if should_apply_ratio { + if let Some(ratio) = r[i] { + (area.right as f32 * ratio) as i32 + } else { + let used: f32 = (0..i).filter_map(|j| r[j]).sum(); + let remaining_space = area.right - (area.right as f32 * used) as i32; + let remaining_columns = len - i; + remaining_space / remaining_columns as i32 + } + } else { + // Last column or beyond defined ratios - split remaining space equally + let ratios_applied = i.min(defined_ratios).min(len.saturating_sub(1)); + let used: f32 = (0..ratios_applied).filter_map(|j| r[j]).sum(); + let remaining_space = area.right - (area.right as f32 * used) as i32; + let remaining_columns = len - ratios_applied; + if remaining_columns > 0 { + remaining_space / remaining_columns as i32 + } else { + remaining_space + } + } + } else { + // Equal width columns (original behavior) + #[allow(clippy::cast_possible_wrap)] + { + area.right / len as i32 + } + }; + layouts.push(Rect { left: area.left + left, top: area.top, @@ -737,12 +933,61 @@ fn columns(area: &Rect, len: usize) -> Vec { #[must_use] fn rows(area: &Rect, len: usize) -> Vec { - #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)] - let bottom = area.bottom / len as i32; + rows_with_ratios(area, len, None) +} + +#[must_use] +fn rows_with_ratios( + area: &Rect, + len: usize, + ratios: Option<[Option; MAX_RATIOS]>, +) -> Vec { + tracing::debug!("rows_with_ratios called: len={}, ratios={:?}", len, ratios); + let mut layouts: Vec = vec![]; let mut top = 0; - let mut layouts: Vec = vec![]; - for _ in 0..len { + // Count how many ratios are defined (already validated at deserialization to sum < 1.0) + let defined_ratios = ratios + .as_ref() + .map(|r| r.iter().filter(|x| x.is_some()).count()) + .unwrap_or(0); + + for i in 0..len { + #[allow(clippy::cast_possible_truncation)] + let bottom = if let Some(ref r) = ratios { + // Only apply ratio[i] if there's at least one more row after this (i < len - 1) + // The last row always gets the remaining space + let should_apply_ratio = i < MAX_RATIOS && i < defined_ratios && i < len - 1; + + if should_apply_ratio { + if let Some(ratio) = r[i] { + (area.bottom as f32 * ratio) as i32 + } else { + let used: f32 = (0..i).filter_map(|j| r[j]).sum(); + let remaining_space = area.bottom - (area.bottom as f32 * used) as i32; + let remaining_rows = len - i; + remaining_space / remaining_rows as i32 + } + } else { + // Last row or beyond defined ratios - split remaining space equally + let ratios_applied = i.min(defined_ratios).min(len.saturating_sub(1)); + let used: f32 = (0..ratios_applied).filter_map(|j| r[j]).sum(); + let remaining_space = area.bottom - (area.bottom as f32 * used) as i32; + let remaining_rows = len - ratios_applied; + if remaining_rows > 0 { + remaining_space / remaining_rows as i32 + } else { + remaining_space + } + } + } else { + // Equal height rows (original behavior) + #[allow(clippy::cast_possible_wrap)] + { + area.bottom / len as i32 + } + }; + layouts.push(Rect { left: area.left, top: area.top + top, @@ -862,6 +1107,8 @@ fn recursive_fibonacci( area: &Rect, layout_flip: Option, resize_adjustments: Vec>, + column_split_ratio: f32, + row_split_ratio: f32, ) -> Vec { let mut a = *area; @@ -875,41 +1122,55 @@ fn recursive_fibonacci( *area }; - let half_width = area.right / 2; - let half_height = area.bottom / 2; - let half_resized_width = resized.right / 2; - let half_resized_height = resized.bottom / 2; + #[allow(clippy::cast_possible_truncation)] + let primary_width = (area.right as f32 * column_split_ratio) as i32; + #[allow(clippy::cast_possible_truncation)] + let primary_height = (area.bottom as f32 * row_split_ratio) as i32; + #[allow(clippy::cast_possible_truncation)] + let primary_resized_width = (resized.right as f32 * column_split_ratio) as i32; + #[allow(clippy::cast_possible_truncation)] + let primary_resized_height = (resized.bottom as f32 * row_split_ratio) as i32; + + let secondary_width = area.right - primary_width; + let secondary_resized_width = resized.right - primary_resized_width; + let secondary_resized_height = resized.bottom - primary_resized_height; let (main_x, alt_x, alt_y, main_y); if let Some(flip) = layout_flip { match flip { Axis::Horizontal => { - main_x = resized.left + half_width + (half_width - half_resized_width); + main_x = + resized.left + secondary_width + (secondary_width - secondary_resized_width); alt_x = resized.left; - alt_y = resized.top + half_resized_height; + alt_y = resized.top + primary_resized_height; main_y = resized.top; } Axis::Vertical => { - main_y = resized.top + half_height + (half_height - half_resized_height); + main_y = resized.top + + (area.bottom - primary_height) + + ((area.bottom - primary_height) - secondary_resized_height); alt_y = resized.top; main_x = resized.left; - alt_x = resized.left + half_resized_width; + alt_x = resized.left + primary_resized_width; } Axis::HorizontalAndVertical => { - main_x = resized.left + half_width + (half_width - half_resized_width); + main_x = + resized.left + secondary_width + (secondary_width - secondary_resized_width); alt_x = resized.left; - main_y = resized.top + half_height + (half_height - half_resized_height); + main_y = resized.top + + (area.bottom - primary_height) + + ((area.bottom - primary_height) - secondary_resized_height); alt_y = resized.top; } } } else { main_x = resized.left; - alt_x = resized.left + half_resized_width; + alt_x = resized.left + primary_resized_width; main_y = resized.top; - alt_y = resized.top + half_resized_height; + alt_y = resized.top + primary_resized_height; } #[allow(clippy::if_not_else)] @@ -927,7 +1188,7 @@ fn recursive_fibonacci( left: resized.left, top: main_y, right: resized.right, - bottom: half_resized_height, + bottom: primary_resized_height, }]; res.append(&mut recursive_fibonacci( idx + 1, @@ -936,17 +1197,19 @@ fn recursive_fibonacci( left: area.left, top: alt_y, right: area.right, - bottom: area.bottom - half_resized_height, + bottom: area.bottom - primary_resized_height, }, layout_flip, resize_adjustments, + column_split_ratio, + row_split_ratio, )); res } else { let mut res = vec![Rect { left: main_x, top: resized.top, - right: half_resized_width, + right: primary_resized_width, bottom: resized.bottom, }]; res.append(&mut recursive_fibonacci( @@ -955,11 +1218,13 @@ fn recursive_fibonacci( &Rect { left: alt_x, top: area.top, - right: area.right - half_resized_width, + right: area.right - primary_resized_width, bottom: area.bottom, }, layout_flip, resize_adjustments, + column_split_ratio, + row_split_ratio, )); res } @@ -1267,3 +1532,659 @@ fn resize_top(rect: &mut Rect, resize: i32) { fn resize_bottom(rect: &mut Rect, resize: i32) { rect.bottom += resize / 2; } + +#[cfg(test)] +mod tests { + use super::*; + use std::num::NonZeroUsize; + + // Helper to create a test area + fn test_area() -> Rect { + Rect { + left: 0, + top: 0, + right: 1000, + bottom: 800, + } + } + + // 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 columns_with_ratios_tests { + use super::*; + + #[test] + fn test_columns_equal_width_no_ratios() { + let area = test_area(); + let layouts = columns_with_ratios(&area, 4, None); + + assert_eq!(layouts.len(), 4); + // Each column should be 250 pixels wide (1000 / 4) + for layout in &layouts { + assert_eq!(layout.right, 250); + assert_eq!(layout.bottom, 800); + } + } + + #[test] + fn test_columns_with_single_ratio() { + let area = test_area(); + let opts = layout_options_with_column_ratios(&[0.3]); + let layouts = columns_with_ratios(&area, 3, opts.column_ratios); + + assert_eq!(layouts.len(), 3); + // First column: 30% of 1000 = 300 + assert_eq!(layouts[0].right, 300); + // Remaining 700 split between 2 columns = 350 each + assert_eq!(layouts[1].right, 350); + assert_eq!(layouts[2].right, 350); + } + + #[test] + fn test_columns_with_multiple_ratios() { + let area = test_area(); + let opts = layout_options_with_column_ratios(&[0.2, 0.3, 0.5]); + let layouts = columns_with_ratios(&area, 4, opts.column_ratios); + + assert_eq!(layouts.len(), 4); + // First column: 20% of 1000 = 200 + assert_eq!(layouts[0].right, 200); + // Second column: 30% of 1000 = 300 + assert_eq!(layouts[1].right, 300); + // Third column: 50% of 1000 = 500 + // But wait - cumulative is 1.0, so third might be truncated + // Let's check what actually happens + // Actually, the sum 0.2 + 0.3 = 0.5 < 1.0, and 0.5 + 0.5 = 1.0 + // So 0.5 won't be included because cumulative would reach 1.0 + } + + #[test] + fn test_columns_positions_are_correct() { + let area = test_area(); + let opts = layout_options_with_column_ratios(&[0.3, 0.4]); + let layouts = columns_with_ratios(&area, 3, opts.column_ratios); + + // First column starts at 0 + assert_eq!(layouts[0].left, 0); + // Second column starts where first ends + assert_eq!(layouts[1].left, layouts[0].right); + // Third column starts where second ends + assert_eq!(layouts[2].left, layouts[1].left + layouts[1].right); + } + + #[test] + fn test_columns_last_column_gets_remaining_space() { + let area = test_area(); + let opts = layout_options_with_column_ratios(&[0.3]); + let layouts = columns_with_ratios(&area, 2, opts.column_ratios); + + assert_eq!(layouts.len(), 2); + // First column: 30% = 300 + assert_eq!(layouts[0].right, 300); + // Last column gets remaining space: 700 + assert_eq!(layouts[1].right, 700); + } + + #[test] + fn test_columns_single_column() { + let area = test_area(); + let opts = layout_options_with_column_ratios(&[0.5]); + let layouts = columns_with_ratios(&area, 1, opts.column_ratios); + + assert_eq!(layouts.len(), 1); + // Single column takes full width regardless of ratio + assert_eq!(layouts[0].right, 1000); + } + + #[test] + fn test_columns_more_columns_than_ratios() { + let area = test_area(); + let opts = layout_options_with_column_ratios(&[0.2]); + let layouts = columns_with_ratios(&area, 5, opts.column_ratios); + + assert_eq!(layouts.len(), 5); + // First column: 20% = 200 + assert_eq!(layouts[0].right, 200); + // Remaining 800 split among 4 columns = 200 each + for i in 1..5 { + assert_eq!(layouts[i].right, 200); + } + } + } + + mod rows_with_ratios_tests { + use super::*; + + #[test] + fn test_rows_equal_height_no_ratios() { + let area = test_area(); + let layouts = rows_with_ratios(&area, 4, None); + + assert_eq!(layouts.len(), 4); + // Each row should be 200 pixels tall (800 / 4) + for layout in &layouts { + assert_eq!(layout.bottom, 200); + assert_eq!(layout.right, 1000); + } + } + + #[test] + fn test_rows_with_single_ratio() { + let area = test_area(); + let opts = layout_options_with_row_ratios(&[0.5]); + let layouts = rows_with_ratios(&area, 3, opts.row_ratios); + + assert_eq!(layouts.len(), 3); + // First row: 50% of 800 = 400 + assert_eq!(layouts[0].bottom, 400); + // Remaining 400 split between 2 rows = 200 each + assert_eq!(layouts[1].bottom, 200); + assert_eq!(layouts[2].bottom, 200); + } + + #[test] + fn test_rows_positions_are_correct() { + let area = test_area(); + let opts = layout_options_with_row_ratios(&[0.25, 0.25]); + let layouts = rows_with_ratios(&area, 3, opts.row_ratios); + + // First row starts at top + assert_eq!(layouts[0].top, 0); + // Second row starts where first ends + assert_eq!(layouts[1].top, layouts[0].bottom); + // Third row starts where second ends + assert_eq!(layouts[2].top, layouts[1].top + layouts[1].bottom); + } + + #[test] + fn test_rows_last_row_gets_remaining_space() { + let area = test_area(); + let opts = layout_options_with_row_ratios(&[0.25]); + let layouts = rows_with_ratios(&area, 2, opts.row_ratios); + + assert_eq!(layouts.len(), 2); + // First row: 25% of 800 = 200 + assert_eq!(layouts[0].bottom, 200); + // Last row gets remaining: 600 + assert_eq!(layouts[1].bottom, 600); + } + } + + mod vertical_stack_layout_tests { + use super::*; + + #[test] + fn test_vertical_stack_default_ratio() { + let area = test_area(); + let len = NonZeroUsize::new(3).unwrap(); + let layouts = + DefaultLayout::VerticalStack.calculate(&area, len, None, None, &[], 0, None, &[]); + + assert_eq!(layouts.len(), 3); + // Primary column should be 50% (default ratio) + assert_eq!(layouts[0].right, 500); + } + + #[test] + fn test_vertical_stack_custom_ratio() { + let area = test_area(); + let len = NonZeroUsize::new(3).unwrap(); + let opts = layout_options_with_column_ratios(&[0.7]); + let layouts = DefaultLayout::VerticalStack.calculate( + &area, + len, + None, + None, + &[], + 0, + Some(opts), + &[], + ); + + assert_eq!(layouts.len(), 3); + // Primary column should be 70% + assert_eq!(layouts[0].right, 700); + // Stack columns should share remaining 30% + assert_eq!(layouts[1].right, 300); + assert_eq!(layouts[2].right, 300); + } + + #[test] + fn test_vertical_stack_with_row_ratios() { + let area = test_area(); + let len = NonZeroUsize::new(4).unwrap(); + let opts = layout_options_with_ratios(&[0.6], &[0.5, 0.3]); + let layouts = DefaultLayout::VerticalStack.calculate( + &area, + len, + None, + None, + &[], + 0, + Some(opts), + &[], + ); + + assert_eq!(layouts.len(), 4); + // Primary column: 60% + assert_eq!(layouts[0].right, 600); + // Stack rows should use row_ratios + // First stack row: 50% of 800 = 400 + assert_eq!(layouts[1].bottom, 400); + // Second stack row: 30% of 800 = 240 + assert_eq!(layouts[2].bottom, 240); + } + + #[test] + fn test_vertical_stack_single_window() { + let area = test_area(); + let len = NonZeroUsize::new(1).unwrap(); + let opts = layout_options_with_column_ratios(&[0.6]); + let layouts = DefaultLayout::VerticalStack.calculate( + &area, + len, + None, + None, + &[], + 0, + Some(opts), + &[], + ); + + assert_eq!(layouts.len(), 1); + // Single window should take full width + assert_eq!(layouts[0].right, 1000); + } + } + + mod horizontal_stack_layout_tests { + use super::*; + + #[test] + fn test_horizontal_stack_default_ratio() { + let area = test_area(); + let len = NonZeroUsize::new(3).unwrap(); + let layouts = + DefaultLayout::HorizontalStack.calculate(&area, len, None, None, &[], 0, None, &[]); + + assert_eq!(layouts.len(), 3); + // Primary row should be 50% height (default ratio) + assert_eq!(layouts[0].bottom, 400); + } + + #[test] + fn test_horizontal_stack_custom_ratio() { + let area = test_area(); + let len = NonZeroUsize::new(3).unwrap(); + let opts = layout_options_with_row_ratios(&[0.7]); + let layouts = DefaultLayout::HorizontalStack.calculate( + &area, + len, + None, + None, + &[], + 0, + Some(opts), + &[], + ); + + assert_eq!(layouts.len(), 3); + // Primary row should be 70% height + assert_eq!(layouts[0].bottom, 560); + } + } + + mod ultrawide_layout_tests { + use super::*; + + #[test] + fn test_ultrawide_default_ratios() { + let area = test_area(); + let len = NonZeroUsize::new(3).unwrap(); + let layouts = DefaultLayout::UltrawideVerticalStack.calculate( + &area, + len, + None, + None, + &[], + 0, + None, + &[], + ); + + assert_eq!(layouts.len(), 3); + // Primary (center): 50% = 500 + assert_eq!(layouts[0].right, 500); + // Secondary (left): 25% = 250 + assert_eq!(layouts[1].right, 250); + // Tertiary gets remaining: 250 + assert_eq!(layouts[2].right, 250); + } + + #[test] + fn test_ultrawide_custom_ratios() { + let area = test_area(); + let len = NonZeroUsize::new(4).unwrap(); + let opts = layout_options_with_column_ratios(&[0.5, 0.2]); + let layouts = DefaultLayout::UltrawideVerticalStack.calculate( + &area, + len, + None, + None, + &[], + 0, + Some(opts), + &[], + ); + + assert_eq!(layouts.len(), 4); + // Primary (center): 50% = 500 + assert_eq!(layouts[0].right, 500); + // Secondary (left): 20% = 200 + assert_eq!(layouts[1].right, 200); + // Tertiary column gets remaining: 300 + assert_eq!(layouts[2].right, 300); + assert_eq!(layouts[3].right, 300); + } + + #[test] + fn test_ultrawide_two_windows() { + let area = test_area(); + let len = NonZeroUsize::new(2).unwrap(); + let opts = layout_options_with_column_ratios(&[0.6]); + let layouts = DefaultLayout::UltrawideVerticalStack.calculate( + &area, + len, + None, + None, + &[], + 0, + Some(opts), + &[], + ); + + assert_eq!(layouts.len(), 2); + // Primary: 60% = 600 + assert_eq!(layouts[0].right, 600); + // Secondary gets remaining: 400 + assert_eq!(layouts[1].right, 400); + } + } + + mod bsp_layout_tests { + use super::*; + + #[test] + fn test_bsp_default_ratio() { + let area = test_area(); + let len = NonZeroUsize::new(2).unwrap(); + let layouts = DefaultLayout::BSP.calculate(&area, len, None, None, &[], 0, None, &[]); + + assert_eq!(layouts.len(), 2); + // First window should be 50% width + assert_eq!(layouts[0].right, 500); + } + + #[test] + fn test_bsp_custom_column_ratio() { + let area = test_area(); + let len = NonZeroUsize::new(2).unwrap(); + let opts = layout_options_with_column_ratios(&[0.7]); + let layouts = + DefaultLayout::BSP.calculate(&area, len, None, None, &[], 0, Some(opts), &[]); + + assert_eq!(layouts.len(), 2); + // First window should be 70% width + assert_eq!(layouts[0].right, 700); + } + + #[test] + fn test_bsp_custom_row_ratio() { + let area = test_area(); + let len = NonZeroUsize::new(3).unwrap(); + let opts = layout_options_with_ratios(&[0.5], &[0.7]); + let layouts = + DefaultLayout::BSP.calculate(&area, len, None, None, &[], 0, Some(opts), &[]); + + assert_eq!(layouts.len(), 3); + // Second window should be 70% of remaining height + assert_eq!(layouts[1].bottom, 560); + } + } + + mod right_main_vertical_stack_tests { + use super::*; + + #[test] + fn test_right_main_default_ratio() { + let area = test_area(); + let len = NonZeroUsize::new(3).unwrap(); + let layouts = DefaultLayout::RightMainVerticalStack.calculate( + &area, + len, + None, + None, + &[], + 0, + None, + &[], + ); + + assert_eq!(layouts.len(), 3); + // Primary should be on the right, 50% width + assert_eq!(layouts[0].right, 500); + assert_eq!(layouts[0].left, 500); // Right side + } + + #[test] + fn test_right_main_custom_ratio() { + let area = test_area(); + let len = NonZeroUsize::new(3).unwrap(); + let opts = layout_options_with_column_ratios(&[0.6]); + let layouts = DefaultLayout::RightMainVerticalStack.calculate( + &area, + len, + None, + None, + &[], + 0, + Some(opts), + &[], + ); + + assert_eq!(layouts.len(), 3); + // Primary: 60% = 600 + assert_eq!(layouts[0].right, 600); + // Should be positioned on the right + assert_eq!(layouts[0].left, 400); + } + } + + mod columns_layout_tests { + use super::*; + + #[test] + fn test_columns_layout_with_ratios() { + let area = test_area(); + let len = NonZeroUsize::new(3).unwrap(); + let opts = layout_options_with_column_ratios(&[0.2, 0.5]); + let layouts = + DefaultLayout::Columns.calculate(&area, len, None, None, &[], 0, Some(opts), &[]); + + assert_eq!(layouts.len(), 3); + assert_eq!(layouts[0].right, 200); // 20% + assert_eq!(layouts[1].right, 500); // 50% + assert_eq!(layouts[2].right, 300); // remaining + } + } + + mod rows_layout_tests { + use super::*; + + #[test] + fn test_rows_layout_with_ratios() { + let area = test_area(); + let len = NonZeroUsize::new(3).unwrap(); + let opts = layout_options_with_row_ratios(&[0.25, 0.5]); + let layouts = + DefaultLayout::Rows.calculate(&area, len, None, None, &[], 0, Some(opts), &[]); + + assert_eq!(layouts.len(), 3); + assert_eq!(layouts[0].bottom, 200); // 25% + assert_eq!(layouts[1].bottom, 400); // 50% + assert_eq!(layouts[2].bottom, 200); // remaining + } + } + + mod grid_layout_tests { + use super::*; + + #[test] + fn test_grid_with_column_ratios() { + let area = test_area(); + let len = NonZeroUsize::new(4).unwrap(); + let opts = layout_options_with_column_ratios(&[0.3]); + let layouts = + DefaultLayout::Grid.calculate(&area, len, None, None, &[], 0, Some(opts), &[]); + + assert_eq!(layouts.len(), 4); + // Grid with 4 windows should be 2x2 + // First column: 30% = 300 + assert_eq!(layouts[0].right, 300); + assert_eq!(layouts[1].right, 300); + } + + #[test] + fn test_grid_without_ratios() { + let area = test_area(); + let len = NonZeroUsize::new(4).unwrap(); + let layouts = DefaultLayout::Grid.calculate(&area, len, None, None, &[], 0, None, &[]); + + assert_eq!(layouts.len(), 4); + // 2x2 grid, equal columns = 500 each + assert_eq!(layouts[0].right, 500); + assert_eq!(layouts[2].right, 500); + } + } + + mod layout_flip_tests { + use super::*; + + #[test] + fn test_columns_flip_horizontal() { + let area = test_area(); + let len = NonZeroUsize::new(3).unwrap(); + let opts = layout_options_with_column_ratios(&[0.2, 0.3]); + let layouts = DefaultLayout::Columns.calculate( + &area, + len, + None, + Some(Axis::Horizontal), + &[], + 0, + Some(opts), + &[], + ); + + assert_eq!(layouts.len(), 3); + // Columns should be reversed + // Last column (originally 50%) should now be first + assert_eq!(layouts[2].left, 0); + } + + #[test] + fn test_rows_flip_vertical() { + let area = test_area(); + let len = NonZeroUsize::new(3).unwrap(); + let opts = layout_options_with_row_ratios(&[0.25, 0.5]); + let layouts = DefaultLayout::Rows.calculate( + &area, + len, + None, + Some(Axis::Vertical), + &[], + 0, + Some(opts), + &[], + ); + + assert_eq!(layouts.len(), 3); + // Rows should be reversed + // Last row should now be at top + assert_eq!(layouts[2].top, 0); + } + } + + mod container_padding_tests { + use super::*; + + #[test] + fn test_padding_applied_to_all_layouts() { + let area = test_area(); + let len = NonZeroUsize::new(2).unwrap(); + let padding = 10; + let layouts = DefaultLayout::Columns.calculate( + &area, + len, + Some(padding), + None, + &[], + 0, + None, + &[], + ); + + assert_eq!(layouts.len(), 2); + // Each layout should have padding applied + // left increases, right decreases, top increases, bottom decreases + assert_eq!(layouts[0].left, padding); + assert_eq!(layouts[0].top, padding); + assert_eq!(layouts[0].right, 500 - padding * 2); + assert_eq!(layouts[0].bottom, 800 - padding * 2); + } + } +} diff --git a/komorebi/src/core/default_layout.rs b/komorebi/src/core/default_layout.rs index f4bd365a..45a22663 100644 --- a/komorebi/src/core/default_layout.rs +++ b/komorebi/src/core/default_layout.rs @@ -8,6 +8,21 @@ use super::OperationDirection; use super::Rect; use super::Sizing; +/// Maximum number of ratio values that can be specified for column_ratios and row_ratios +pub const MAX_RATIOS: usize = 5; + +/// Minimum allowed ratio value (prevents zero-sized windows) +pub const MIN_RATIO: f32 = 0.1; + +/// Maximum allowed ratio value (ensures space for remaining windows) +pub const MAX_RATIO: f32 = 0.9; + +/// Default ratio value when none is specified +pub const DEFAULT_RATIO: f32 = 0.5; + +/// Default secondary ratio value for UltrawideVerticalStack layout +pub const DEFAULT_SECONDARY_RATIO: f32 = 0.25; + #[derive( Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq, Display, EnumString, ValueEnum, )] @@ -112,7 +127,66 @@ pub enum DefaultLayout { // NOTE: If any new layout is added, please make sure to register the same in `DefaultLayout::cycle` } -#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)] +/// Helper to deserialize a variable-length array into a fixed [Option; MAX_RATIOS] +/// Ratios are truncated when their cumulative sum reaches or exceeds 1.0 to ensure +/// there's always remaining space for additional windows. +fn deserialize_ratios<'de, D>( + deserializer: D, +) -> Result; MAX_RATIOS]>, D::Error> +where + D: serde::Deserializer<'de>, +{ + let opt: Option> = Option::deserialize(deserializer)?; + Ok(opt.map(|vec| { + let mut arr = [None; MAX_RATIOS]; + let mut cumulative_sum = 0.0_f32; + + for (i, &val) in vec.iter().take(MAX_RATIOS).enumerate() { + let clamped_val = val.clamp(MIN_RATIO, MAX_RATIO); + + // Only add this ratio if cumulative sum stays below 1.0 + if cumulative_sum + clamped_val < 1.0 { + arr[i] = Some(clamped_val); + cumulative_sum += clamped_val; + } else { + // Stop adding ratios - cumulative sum would reach or exceed 1.0 + tracing::debug!( + "Truncating ratios at index {} - cumulative sum {} + {} would reach/exceed 1.0", + i, + cumulative_sum, + clamped_val + ); + break; + } + } + arr + })) +} + +/// Helper to serialize [Option; MAX_RATIOS] as a compact array (without trailing nulls) +fn serialize_ratios( + value: &Option<[Option; MAX_RATIOS]>, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + match value { + None => serializer.serialize_none(), + Some(arr) => { + // Find last non-None index + let last_idx = arr + .iter() + .rposition(|x| x.is_some()) + .map(|i| i + 1) + .unwrap_or(0); + let vec: Vec = arr.iter().take(last_idx).filter_map(|&x| x).collect(); + serializer.serialize_some(&vec) + } + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] /// Options for specific layouts pub struct LayoutOptions { @@ -120,6 +194,35 @@ pub struct LayoutOptions { pub scrolling: Option, /// Options related to the Grid layout pub grid: Option, + /// Column width ratios (up to MAX_RATIOS values between 0.1 and 0.9) + /// + /// - Used by Columns layout: ratios for each column width + /// - Used by Grid layout: ratios for column widths + /// - Used by BSP, VerticalStack, RightMainVerticalStack: column_ratios[0] as primary split ratio + /// - Used by HorizontalStack: column_ratios[0] as primary split ratio (top area height) + /// - Used by UltrawideVerticalStack: column_ratios[0] as center ratio, column_ratios[1] as left ratio + /// + /// Columns without a ratio share remaining space equally. + /// Example: `[0.3, 0.4, 0.3]` for 30%-40%-30% columns + #[serde( + default, + deserialize_with = "deserialize_ratios", + serialize_with = "serialize_ratios" + )] + pub column_ratios: Option<[Option; MAX_RATIOS]>, + /// Row height ratios (up to MAX_RATIOS values between 0.1 and 0.9) + /// + /// - Used by Rows layout: ratios for each row height + /// - Used by Grid layout: ratios for row heights + /// + /// Rows without a ratio share remaining space equally. + /// Example: `[0.5, 0.5]` for 50%-50% rows + #[serde( + default, + deserialize_with = "deserialize_ratios", + serialize_with = "serialize_ratios" + )] + pub row_ratios: Option<[Option; MAX_RATIOS]>, } #[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)] @@ -308,3 +411,368 @@ 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 i in 0..MAX_RATIOS { + assert_eq!(ratios[i], 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 i in 0..MAX_RATIOS { + assert_eq!(ratios[i], 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() { + 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); + } + } +} diff --git a/komorebi/src/process_command.rs b/komorebi/src/process_command.rs index 1d6905ed..a0bd4aa4 100644 --- a/komorebi/src/process_command.rs +++ b/komorebi/src/process_command.rs @@ -947,6 +947,8 @@ impl WindowManager { center_focused_column: Default::default(), }), grid: None, + column_ratios: None, + row_ratios: None, }, }; diff --git a/komorebi/src/static_config.rs b/komorebi/src/static_config.rs index 34981f51..e1627492 100644 --- a/komorebi/src/static_config.rs +++ b/komorebi/src/static_config.rs @@ -325,7 +325,13 @@ impl From<&Workspace> for WorkspaceConfig { Layout::Custom(_) => None, }) .flatten(), - layout_options: value.layout_options, + layout_options: { + tracing::debug!( + "Parsing workspace config - layout_options: {:?}", + value.layout_options + ); + value.layout_options + }, #[allow(deprecated)] custom_layout: value .workspace_config diff --git a/komorebi/src/window_manager.rs b/komorebi/src/window_manager.rs index abd7228c..5559cf13 100644 --- a/komorebi/src/window_manager.rs +++ b/komorebi/src/window_manager.rs @@ -243,14 +243,16 @@ impl WindowManager { if let Some(state_monitor) = state.monitors.elements().get(monitor_idx) && let Some(state_workspace) = state_monitor.workspaces().get(workspace_idx) { - // to make sure padding changes get applied for users after a quick restart + // to make sure padding and layout_options changes get applied for users after a quick restart let container_padding = workspace.container_padding; let workspace_padding = workspace.workspace_padding; + let layout_options = workspace.layout_options; *workspace = state_workspace.clone(); workspace.container_padding = container_padding; workspace.workspace_padding = workspace_padding; + workspace.layout_options = layout_options; if state_monitor.focused_workspace_idx() == workspace_idx { focused_workspace = workspace_idx; diff --git a/komorebi/src/workspace.rs b/komorebi/src/workspace.rs index 65c4062b..b9e4d5be 100644 --- a/komorebi/src/workspace.rs +++ b/komorebi/src/workspace.rs @@ -242,6 +242,12 @@ impl Workspace { self.wallpaper = config.wallpaper.clone(); self.layout_options = config.layout_options; + tracing::debug!( + "Workspace '{}' loaded layout_options: {:?}", + self.name.as_deref().unwrap_or("unnamed"), + self.layout_options + ); + self.workspace_config = Some(config.clone()); Ok(()) @@ -550,6 +556,11 @@ 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"), + self.layout_options + ); let mut layouts = self.layout.as_boxed_arrangement().calculate( &adjusted_work_area, NonZeroUsize::new(self.containers().len()).ok_or_eyre( diff --git a/schema.bar.json b/schema.bar.json index df4ab75f..b7b73761 100644 --- a/schema.bar.json +++ b/schema.bar.json @@ -59,7 +59,7 @@ "null" ], "format": "float", - "default": 50.0 + "default": 50 }, "icon_scale": { "description": "Scale of the icons relative to the font_size [[1.0-2.0]]", @@ -68,7 +68,7 @@ "null" ], "format": "float", - "default": 1.399999976158142 + "default": 1.4 }, "left_widgets": { "description": "Left side widgets (ordered left-to-right)", @@ -99,7 +99,15 @@ }, "monitor": { "description": "The monitor index or the full monitor options", - "$ref": "#/$defs/MonitorConfigOrIndex" + "anyOf": [ + { + "$ref": "#/$defs/MonitorConfigOrIndex" + }, + { + "type": "null" + } + ], + "default": 0 }, "mouse": { "description": "Options for mouse interaction on the bar", @@ -174,7 +182,6 @@ } }, "required": [ - "monitor", "left_widgets", "right_widgets" ], diff --git a/schema.json b/schema.json index 6965c6ca..45a5fae2 100644 --- a/schema.json +++ b/schema.json @@ -3294,6 +3294,23 @@ "description": "Options for specific layouts", "type": "object", "properties": { + "column_ratios": { + "description": "Column width ratios (up to MAX_RATIOS values between 0.1 and 0.9)\n\n- Used by Columns layout: ratios for each column width\n- Used by Grid layout: ratios for column widths\n- Used by BSP, VerticalStack, RightMainVerticalStack: column_ratios[0] as primary split ratio\n- Used by HorizontalStack: column_ratios[0] as primary split ratio (top area height)\n- Used by UltrawideVerticalStack: column_ratios[0] as center ratio, column_ratios[1] as left ratio\n\nColumns without a ratio share remaining space equally.\nExample: `[0.3, 0.4, 0.3]` for 30%-40%-30% columns", + "type": [ + "array", + "null" + ], + "default": null, + "items": { + "type": [ + "number", + "null" + ], + "format": "float" + }, + "maxItems": 5, + "minItems": 5 + }, "grid": { "description": "Options related to the Grid layout", "anyOf": [ @@ -3305,6 +3322,23 @@ } ] }, + "row_ratios": { + "description": "Row height ratios (up to MAX_RATIOS values between 0.1 and 0.9)\n\n- Used by Rows layout: ratios for each row height\n- Used by Grid layout: ratios for row heights\n\nRows without a ratio share remaining space equally.\nExample: `[0.5, 0.5]` for 50%-50% rows", + "type": [ + "array", + "null" + ], + "default": null, + "items": { + "type": [ + "number", + "null" + ], + "format": "float" + }, + "maxItems": 5, + "minItems": 5 + }, "scrolling": { "description": "Options related to the Scrolling layout", "anyOf": [