use crate::common::{Direction, WindowId};
use crate::layout::projection::{canvas_width, column_step_width};
use crate::layout::types::{Column, Padding, Row, VirtualLayout};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NeighborLocation {
pub col: usize,
pub row: usize,
}
#[derive(Debug, Clone, Copy)]
pub struct MutationConfig {
pub monitor_width: i32,
pub monitor_height: i32,
pub min_window_height_px: u32,
pub column_width: u32,
pub min_column_width_px: u32,
pub max_n: u32,
pub abs_max_width: i32,
pub padding: Padding,
pub columns_per_screen: u32,
}
impl MutationConfig {
#[must_use]
pub fn column_shift(&self) -> i32 {
self.column_width as i32 + self.padding.window_gap
}
#[must_use]
pub fn slot_max(&self) -> i32 {
self.column_width as i32 + self.max_n as i32 * self.column_shift()
}
#[must_use]
pub fn available_height(&self) -> i32 {
self.monitor_height - self.padding.up - self.padding.down
}
}
#[must_use]
pub fn distribute_heights(n: usize, available: i32, gap: i32) -> Vec<i32> {
if n == 0 {
return Vec::new();
}
let n_i = n as i32;
let numerator = available - (n_i + 1) * gap;
if numerator <= 0 {
return vec![0; n];
}
let base = numerator / n_i;
let remainder = (numerator % n_i) as usize;
(0..n)
.map(|i| if i < remainder { base + 1 } else { base })
.collect()
}
fn single_row_column(window: WindowId, width_px: i32, config: &MutationConfig) -> Column {
let h = distribute_heights(1, config.available_height(), config.padding.window_gap)[0];
Column::with_row(width_px, Row::new(window, h))
}
#[must_use]
pub fn find_neighbor_window(
layout: &VirtualLayout,
focused: WindowId,
direction: Direction,
) -> Option<NeighborLocation> {
let (col, row) = layout.find_window(focused)?;
match direction {
Direction::Up => {
if row == 0 {
return None;
}
Some(NeighborLocation { col, row: row - 1 })
}
Direction::Down => {
let max_row = layout.columns[col].rows.len().saturating_sub(1);
if row >= max_row {
return None;
}
Some(NeighborLocation { col, row: row + 1 })
}
Direction::Left => {
if col == 0 {
return None;
}
let target_col = col - 1;
let target_row = closest_row(&layout.columns[target_col], row);
Some(NeighborLocation {
col: target_col,
row: target_row,
})
}
Direction::Right => {
if col + 1 >= layout.columns.len() {
return None;
}
let target_col = col + 1;
let target_row = closest_row(&layout.columns[target_col], row);
Some(NeighborLocation {
col: target_col,
row: target_row,
})
}
}
}
fn closest_row(column: &Column, preferred_row: usize) -> usize {
preferred_row.min(column.rows.len().saturating_sub(1))
}
#[must_use]
pub fn next_available_window(layout: &VirtualLayout, col: usize, row: usize) -> Option<WindowId> {
let column = layout.columns.get(col)?;
if let Some(below) = column.rows.get(row + 1) {
return Some(below.window_id);
}
if row > 0 {
return Some(column.rows[row - 1].window_id);
}
if col > 0 {
let left = &layout.columns[col - 1];
let r = closest_row(left, row);
return Some(left.rows[r].window_id);
}
let right_idx = col + 1;
if right_idx < layout.columns.len() {
let right = &layout.columns[right_idx];
let r = closest_row(right, row);
return Some(right.rows[r].window_id);
}
None
}
#[must_use]
pub fn scroll_left(layout: &VirtualLayout, config: &MutationConfig) -> Option<VirtualLayout> {
if layout.viewport_offset <= 0 {
return None;
}
let step = first_visible_step(layout, config)?;
let new_offset = (layout.viewport_offset - step).max(0);
Some(VirtualLayout {
viewport_offset: new_offset,
..layout.clone()
})
}
#[must_use]
pub fn scroll_right(layout: &VirtualLayout, config: &MutationConfig) -> Option<VirtualLayout> {
let step = first_visible_step(layout, config)?;
let total_canvas = total_column_span(layout, config);
let max_offset = (total_canvas - config.monitor_width).max(0);
let new_offset = layout.viewport_offset + step;
if new_offset > max_offset {
return None;
}
Some(VirtualLayout {
viewport_offset: new_offset,
..layout.clone()
})
}
#[derive(Debug, Clone, PartialEq)]
pub struct FocusResult {
pub focused: WindowId,
pub new_layout: VirtualLayout,
}
#[must_use]
pub fn focus(
layout: &VirtualLayout,
focused: WindowId,
direction: Direction,
config: &MutationConfig,
) -> Option<FocusResult> {
let neighbor = find_neighbor_window(layout, focused, direction)?;
let target_window = layout.columns[neighbor.col].rows[neighbor.row].window_id;
match direction {
Direction::Left | Direction::Right => {
let new_layout = ensure_column_visible(layout, neighbor.col, config);
Some(FocusResult {
focused: target_window,
new_layout,
})
}
Direction::Up | Direction::Down => Some(FocusResult {
focused: target_window,
new_layout: layout.clone(),
}),
}
}
#[must_use]
pub(crate) fn ensure_column_visible(
layout: &VirtualLayout,
col_idx: usize,
config: &MutationConfig,
) -> VirtualLayout {
let gap = config.padding.window_gap;
let mut canvas_x: i32 = gap;
for (i, col) in layout.columns.iter().enumerate() {
let col_px = col.width_px;
if i == col_idx {
let col_left = canvas_x;
let col_right = canvas_x + col_px;
let vp_left = layout.viewport_offset;
let vp_right = vp_left + config.monitor_width;
if col_left < vp_left {
let ideal_vp = (col_left - gap).max(0);
return VirtualLayout {
viewport_offset: ideal_vp,
..layout.clone()
};
}
if col_right > vp_right {
let ideal_vp = (col_right + gap - config.monitor_width).max(0);
return VirtualLayout {
viewport_offset: ideal_vp,
..layout.clone()
};
}
return layout.clone();
}
canvas_x += col_px + gap;
}
layout.clone()
}
#[must_use]
pub fn swap_column(
layout: &VirtualLayout,
focused: WindowId,
direction: Direction,
config: &MutationConfig,
) -> Option<VirtualLayout> {
let (col, _row) = layout.find_window(focused)?;
match direction {
Direction::Up | Direction::Down => {
log::warn!(
"swap_column: vertical direction ({direction:?}) is invalid — \
columns only have horizontal neighbours; use swap_window instead"
);
None
}
Direction::Left => {
if col == 0 {
return None;
}
let swapped = swap_columns(layout, col, col - 1)?;
Some(ensure_column_visible(&swapped, col - 1, config))
}
Direction::Right => {
let max_col = layout.columns.len().saturating_sub(1);
if col >= max_col {
return None;
}
let swapped = swap_columns(layout, col, col + 1)?;
Some(ensure_column_visible(&swapped, col + 1, config))
}
}
}
#[must_use]
pub fn swap_window(
layout: &VirtualLayout,
focused: WindowId,
direction: Direction,
config: &MutationConfig,
) -> Option<VirtualLayout> {
let (src_col, src_row) = layout.find_window(focused)?;
let neighbor = find_neighbor_window(layout, focused, direction)?;
let (dst_col, dst_row) = (neighbor.col, neighbor.row);
if src_col == dst_col && src_row == dst_row {
return None;
}
let mut new_layout = layout.clone();
if src_col == dst_col {
new_layout.columns[src_col].rows.swap(src_row, dst_row);
} else {
let dst_row_val = new_layout.columns[dst_col].rows[dst_row];
let src_row_val = new_layout.columns[src_col].rows[src_row];
new_layout.columns[dst_col].rows[dst_row] = src_row_val;
new_layout.columns[src_col].rows[src_row] = dst_row_val;
}
Some(ensure_column_visible(&new_layout, dst_col, config))
}
fn swap_columns(layout: &VirtualLayout, col_a: usize, col_b: usize) -> Option<VirtualLayout> {
let mut new_layout = layout.clone();
new_layout.columns.swap(col_a, col_b);
Some(new_layout)
}
#[must_use]
pub fn set_column_width(
layout: &VirtualLayout,
focused: WindowId,
target_px: i32,
config: &MutationConfig,
) -> Option<VirtualLayout> {
let (col, _) = layout.find_window(focused)?;
let min = config.min_column_width_px as i32;
let max = config.abs_max_width;
if target_px < min || target_px > max {
return None;
}
if target_px == layout.columns[col].width_px {
return None;
}
let mut new_layout = layout.clone();
new_layout.columns[col].width_px = target_px;
Some(ensure_column_visible(&new_layout, col, config))
}
#[must_use]
pub fn resize_column(
layout: &VirtualLayout,
focused: WindowId,
delta_px: i32,
config: &MutationConfig,
) -> Option<VirtualLayout> {
let (col, _) = layout.find_window(focused)?;
let current_px = layout.columns[col].width_px;
let target_px = current_px + delta_px;
set_column_width(layout, focused, target_px, config)
}
#[must_use]
pub fn expand_column(
layout: &VirtualLayout,
focused: WindowId,
config: &MutationConfig,
) -> Option<VirtualLayout> {
let (col, _) = layout.find_window(focused)?;
let w = layout.columns[col].width_px;
let base = config.column_width as i32;
let shift = config.column_shift();
let abs_max = config.abs_max_width;
debug_assert!(shift > 0, "column_shift must be positive");
if w >= abs_max {
return None;
}
if w >= config.slot_max() {
return set_column_width(layout, focused, abs_max, config);
}
let n = (w - base).div_euclid(shift);
let target_n = (n + 1).min(config.max_n as i32);
let target_px = base + target_n * shift;
set_column_width(layout, focused, target_px, config)
}
#[must_use]
pub fn shrink_column(
layout: &VirtualLayout,
focused: WindowId,
config: &MutationConfig,
) -> Option<VirtualLayout> {
let (col, _) = layout.find_window(focused)?;
let w = layout.columns[col].width_px;
let base = config.column_width as i32;
let shift = config.column_shift();
debug_assert!(shift > 0, "column_shift must be positive");
if w <= base {
return None;
}
let n = ((w - base) + shift - 1).div_euclid(shift);
let target_n = (n - 1).max(0);
let target_px = base + target_n * shift;
set_column_width(layout, focused, target_px, config)
}
#[must_use]
pub fn toggle_monocle(
layout: &VirtualLayout,
focused: WindowId,
saved_width: Option<i32>,
config: &MutationConfig,
) -> Option<(VirtualLayout, Option<i32>)> {
let (col, _) = layout.find_window(focused)?;
let current = layout.columns[col].width_px;
if current == config.abs_max_width {
let restored = saved_width.unwrap_or(config.column_width as i32);
let mut new_layout = layout.clone();
new_layout.columns[col].width_px = restored;
Some((new_layout, None))
} else {
let mut new_layout = layout.clone();
new_layout.columns[col].width_px = config.abs_max_width;
Some((new_layout, Some(current)))
}
}
#[must_use]
pub fn center_viewport_on_focused(
layout: &VirtualLayout,
focus_col: usize,
config: &MutationConfig,
) -> i32 {
debug_assert!(
!layout.columns.is_empty() && focus_col < layout.columns.len(),
"focus_col {focus_col} out of range (0..{})",
layout.columns.len()
);
let gap = config.padding.window_gap;
let focused_width = layout.columns[focus_col].width_px;
let canvas_x: i32 = layout.columns[..focus_col]
.iter()
.map(|c| c.width_px)
.sum::<i32>()
+ (focus_col as i32 + 1) * gap;
canvas_x - (config.monitor_width - focused_width) / 2
}
#[must_use]
pub fn center_viewport_canvas(layout: &VirtualLayout, config: &MutationConfig) -> i32 {
let cw = canvas_width(layout, config.padding.window_gap);
(cw - config.monitor_width) / 2
}
pub(crate) fn quantize_to_ladder(raw_width: i32, config: &MutationConfig) -> i32 {
let base = config.column_width as i32;
let shift = config.column_shift();
let abs_max = config.abs_max_width;
let clamped = raw_width.clamp(base, abs_max);
if clamped <= base {
return base;
}
if clamped >= abs_max {
return abs_max;
}
if clamped > config.slot_max() {
let mid = (config.slot_max() + abs_max) / 2;
return if clamped >= mid {
abs_max
} else {
config.slot_max()
};
}
let n = ((clamped - base) + shift / 2) / shift;
let target_n = n.clamp(0, config.max_n as i32);
base + target_n * shift
}
#[must_use]
pub fn initialize_windows(
ids: &[WindowId],
config: &MutationConfig,
focus_col_idx: Option<usize>,
widths: Option<&[u32]>,
) -> VirtualLayout {
let columns: Vec<Column> = ids
.iter()
.enumerate()
.map(|(i, &id)| {
let width = match widths {
Some(ws) => quantize_to_ladder(ws[i] as i32, config),
None => config.column_width as i32,
};
single_row_column(id, width, config)
})
.collect();
let viewport_offset = if columns.is_empty() {
0
} else {
let gap = config.padding.window_gap;
let temp_layout = VirtualLayout::with_columns(columns.clone(), 0);
let cw = canvas_width(&temp_layout, gap);
if cw <= config.monitor_width {
center_viewport_canvas(&temp_layout, config)
} else if let Some(idx) = focus_col_idx {
if idx < columns.len() {
ensure_column_visible(&temp_layout, idx, config).viewport_offset
} else {
0
}
} else {
0
}
};
VirtualLayout {
columns,
viewport_offset,
}
}
#[must_use]
pub fn add_window(
layout: &VirtualLayout,
window: WindowId,
config: &MutationConfig,
) -> VirtualLayout {
let mut new_layout = layout.clone();
new_layout.columns.push(single_row_column(
window,
config.column_width as i32,
config,
));
new_layout
}
#[must_use]
pub fn insert_window_after_focused(
layout: &VirtualLayout,
focused: Option<WindowId>,
window: WindowId,
config: &MutationConfig,
) -> VirtualLayout {
let mut new_layout = layout.clone();
let new_column = single_row_column(window, config.column_width as i32, config);
let insert_idx = focused
.and_then(|f| layout.find_window(f))
.map(|(col, _)| col + 1)
.unwrap_or_else(|| new_layout.columns.len());
new_layout.columns.insert(insert_idx, new_column);
ensure_column_visible(&new_layout, insert_idx, config)
}
#[must_use]
pub fn insert_window_after_focused_with_width(
layout: &VirtualLayout,
focused: Option<WindowId>,
window: WindowId,
width: i32,
config: &MutationConfig,
) -> VirtualLayout {
let mut new_layout = layout.clone();
let new_column = single_row_column(window, width, config);
let insert_idx = focused
.and_then(|f| layout.find_window(f))
.map(|(col, _)| col + 1)
.unwrap_or_else(|| new_layout.columns.len());
new_layout.columns.insert(insert_idx, new_column);
ensure_column_visible(&new_layout, insert_idx, config)
}
#[must_use]
pub fn add_window_to_column(
layout: &VirtualLayout,
col_idx: usize,
window: WindowId,
config: &MutationConfig,
) -> VirtualLayout {
let mut new_layout = layout.clone();
let Some(col) = new_layout.columns.get_mut(col_idx) else {
return new_layout;
};
let n = col.rows.len() + 1;
let heights = distribute_heights(n, config.available_height(), config.padding.window_gap);
for (i, row) in col.rows.iter_mut().enumerate() {
row.height = heights[i];
}
col.rows.push(Row::new(window, heights[n - 1]));
new_layout
}
#[must_use]
pub fn remove_window(
layout: &VirtualLayout,
window: WindowId,
config: &MutationConfig,
) -> VirtualLayout {
let Some((col, row)) = layout.find_window(window) else {
return layout.clone();
};
let mut new_layout = layout.clone();
let col_ref = &mut new_layout.columns[col];
col_ref.rows.remove(row);
if col_ref.rows.is_empty() {
new_layout.columns.remove(col);
} else {
let n = col_ref.rows.len();
let heights = distribute_heights(n, config.available_height(), config.padding.window_gap);
for (i, r) in col_ref.rows.iter_mut().enumerate() {
r.height = heights[i];
}
}
let total = total_column_span(&new_layout, config);
let max_offset = (total - config.monitor_width).max(0);
new_layout.viewport_offset = new_layout.viewport_offset.min(max_offset);
new_layout
}
#[must_use]
pub fn merge_column(
layout: &VirtualLayout,
focused: WindowId,
direction: Direction,
config: &MutationConfig,
) -> Option<VirtualLayout> {
let (src_col, src_row) = layout.find_window(focused)?;
let dst_col = match direction {
Direction::Left => {
if src_col == 0 {
return None;
}
src_col - 1
}
Direction::Right => {
if src_col + 1 >= layout.columns.len() {
return None;
}
src_col + 1
}
Direction::Up | Direction::Down => {
log::warn!(
"merge_column: vertical direction ({direction:?}) is invalid — \
merge only crosses column boundaries; use swap_window for vertical motion"
);
return None;
}
};
let gap = config.padding.window_gap;
let new_count = layout.columns[dst_col].rows.len() + 1;
let heights = distribute_heights(new_count, config.available_height(), gap);
let floor = config.min_window_height_px as i32;
if heights.iter().any(|&h| h < floor) {
log::warn!(
"merge_column: destination would have {new_count} rows; at least one \
redistributed height is below min_window_height_px ({floor}px) — rejected"
);
return None;
}
let mut new_layout = layout.clone();
let moved_row = new_layout.columns[src_col].rows.remove(src_row);
let src_emptied = new_layout.columns[src_col].rows.is_empty();
if src_emptied {
new_layout.columns.remove(src_col);
} else {
let n = new_layout.columns[src_col].rows.len();
let redistributed = distribute_heights(n, config.available_height(), gap);
for (i, r) in new_layout.columns[src_col].rows.iter_mut().enumerate() {
r.height = redistributed[i];
}
}
let dst_after_removal = if direction == Direction::Right && src_emptied {
src_col
} else {
dst_col
};
let dst = &mut new_layout.columns[dst_after_removal];
for (i, r) in dst.rows.iter_mut().enumerate() {
r.height = heights[i];
}
dst.rows
.push(Row::new(moved_row.window_id, heights[new_count - 1]));
Some(ensure_column_visible(
&new_layout,
dst_after_removal,
config,
))
}
#[must_use]
pub fn promote_window(
layout: &VirtualLayout,
focused: WindowId,
direction: Direction,
config: &MutationConfig,
) -> Option<VirtualLayout> {
let (src_col, src_row) = layout.find_window(focused)?;
if layout.columns[src_col].rows.len() <= 1 {
return None;
}
let insert_at = match direction {
Direction::Left => src_col,
Direction::Right => src_col + 1,
Direction::Up | Direction::Down => {
log::warn!(
"promote_window: vertical direction ({direction:?}) is invalid — \
promotion only creates columns to the left or right"
);
return None;
}
};
let gap = config.padding.window_gap;
let mut new_layout = layout.clone();
let moved_row = new_layout.columns[src_col].rows.remove(src_row);
let n = new_layout.columns[src_col].rows.len();
let redistributed = distribute_heights(n, config.available_height(), gap);
for (i, r) in new_layout.columns[src_col].rows.iter_mut().enumerate() {
r.height = redistributed[i];
}
let new_column = single_row_column(moved_row.window_id, config.column_width as i32, config);
new_layout.columns.insert(insert_at, new_column);
Some(ensure_column_visible(&new_layout, insert_at, config))
}
fn first_visible_step(layout: &VirtualLayout, config: &MutationConfig) -> Option<i32> {
let gap = config.padding.window_gap;
let mut canvas_x: i32 = gap;
let vp_right = layout.viewport_offset + config.monitor_width;
for col in &layout.columns {
let col_px = col.width_px;
let col_left = canvas_x;
let col_right = canvas_x + col_px;
if col_right > layout.viewport_offset && col_left < vp_right {
return Some(column_step_width(col, gap));
}
canvas_x += col_px + gap;
}
None
}
fn total_column_span(layout: &VirtualLayout, config: &MutationConfig) -> i32 {
canvas_width(layout, config.padding.window_gap)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::common::WindowId;
use crate::layout::types::Padding;
fn test_config() -> MutationConfig {
MutationConfig {
monitor_width: 1920,
monitor_height: 1080,
min_window_height_px: 100,
column_width: 960,
min_column_width_px: 480,
max_n: 0,
abs_max_width: 1912,
padding: Padding {
window_gap: 4,
up: 0,
down: 0,
},
columns_per_screen: 4,
}
}
fn three_column_layout() -> VirtualLayout {
VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
Column::with_row(960, Row::new(WindowId(3), 0)),
],
0,
)
}
#[test]
fn distribute_heights_zero_count_returns_empty_vec() {
let result = distribute_heights(0, 1080, 4);
assert!(result.is_empty(), "n=0 must return empty Vec");
}
#[test]
fn distribute_heights_one_row_gets_full_available_minus_gaps() {
let result = distribute_heights(1, 1080, 4);
assert_eq!(result, vec![1072]);
}
#[test]
fn distribute_heights_two_rows_even_split_no_remainder() {
let result = distribute_heights(2, 1080, 4);
assert_eq!(result, vec![534, 534]);
}
#[test]
fn distribute_heights_three_rows_distributes_remainder_to_top() {
let result = distribute_heights(3, 1080, 4);
assert_eq!(result, vec![355, 355, 354]);
}
#[test]
fn distribute_heights_four_rows_even_split_no_remainder() {
let result = distribute_heights(4, 1080, 4);
assert_eq!(result, vec![265, 265, 265, 265]);
}
#[test]
fn distribute_heights_negative_numerator_returns_all_zeros() {
let result = distribute_heights(3, 10, 4);
assert_eq!(result, vec![0, 0, 0]);
}
#[test]
fn distribute_heights_insufficient_budget_returns_all_zeros() {
let result = distribute_heights(10, 20, 4);
assert_eq!(result, vec![0; 10]);
}
#[test]
fn distribute_heights_zero_gap_distributes_evenly() {
let result = distribute_heights(3, 1080, 0);
assert_eq!(result, vec![360, 360, 360]);
}
#[test]
fn distribute_heights_sums_to_available_minus_gap_overhead() {
let n = 5;
let available = 1080;
let gap = 7;
let result = distribute_heights(n, available, gap);
let expected_sum = available - (n as i32 + 1) * gap;
let actual_sum: i32 = result.iter().sum();
assert_eq!(
actual_sum, expected_sum,
"sum of heights must equal available - (n+1)*gap"
);
assert_eq!(result.len(), n);
}
#[test]
fn next_available_prefers_left_column() {
let layout = three_column_layout();
assert_eq!(next_available_window(&layout, 1, 0), Some(WindowId(1)));
}
#[test]
fn next_available_leftmost_falls_to_right() {
let layout = three_column_layout();
assert_eq!(next_available_window(&layout, 0, 0), Some(WindowId(2)));
}
#[test]
fn next_available_rightmost_falls_to_left() {
let layout = three_column_layout();
assert_eq!(next_available_window(&layout, 2, 0), Some(WindowId(2)));
}
#[test]
fn next_available_only_window_returns_none() {
let layout =
VirtualLayout::with_columns(vec![Column::with_row(960, Row::new(WindowId(1), 0))], 0);
assert_eq!(next_available_window(&layout, 0, 0), None);
}
#[test]
fn next_available_same_column_sibling_beats_left_column() {
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_rows(
960,
vec![Row::new(WindowId(2), 0), Row::new(WindowId(3), 0)],
),
],
0,
);
assert_eq!(next_available_window(&layout, 1, 1), Some(WindowId(2)));
}
#[test]
fn next_available_same_column_above_when_bottom_removed() {
let layout = VirtualLayout::with_columns(
vec![
Column::with_rows(
960,
vec![Row::new(WindowId(1), 0), Row::new(WindowId(4), 0)],
),
Column::with_rows(
960,
vec![
Row::new(WindowId(2), 0),
Row::new(WindowId(3), 0),
Row::new(WindowId(5), 0),
],
),
],
0,
);
assert_eq!(next_available_window(&layout, 1, 2), Some(WindowId(3)));
}
#[test]
fn next_available_single_column_multirow_picks_sibling() {
let layout = VirtualLayout::with_columns(
vec![Column::with_rows(
960,
vec![
Row::new(WindowId(1), 0),
Row::new(WindowId(2), 0),
Row::new(WindowId(3), 0),
],
)],
0,
);
assert_eq!(next_available_window(&layout, 0, 1), Some(WindowId(3)));
assert_eq!(next_available_window(&layout, 0, 0), Some(WindowId(2)));
assert_eq!(next_available_window(&layout, 0, 2), Some(WindowId(2)));
}
#[test]
fn next_available_prefers_same_column_below() {
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(9), 0)),
Column::with_rows(
960,
vec![
Row::new(WindowId(1), 0),
Row::new(WindowId(2), 0),
Row::new(WindowId(3), 0),
],
),
Column::with_row(960, Row::new(WindowId(4), 0)),
],
0,
);
assert_eq!(next_available_window(&layout, 1, 1), Some(WindowId(3)));
}
#[test]
fn scroll_right_advances_viewport() {
let layout = three_column_layout();
let config = test_config();
let result = scroll_right(&layout, &config).expect("scroll right");
assert!(result.viewport_offset > 0);
assert_eq!(result.viewport_offset, 964);
}
#[test]
fn scroll_left_returns_none_at_zero() {
let layout = three_column_layout();
let config = test_config();
assert!(scroll_left(&layout, &config).is_none());
}
#[test]
fn scroll_right_then_left_roundtrips() {
let layout = three_column_layout();
let config = test_config();
let scrolled = scroll_right(&layout, &config).expect("scroll right");
let back = scroll_left(&scrolled, &config).expect("scroll left");
assert_eq!(back.viewport_offset, 0);
}
#[test]
fn ensure_column_visible_left_scroll() {
let config = test_config();
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
Column::with_row(960, Row::new(WindowId(3), 0)),
],
2000, );
let result = ensure_column_visible(&layout, 0, &config);
assert_eq!(result.viewport_offset, 0);
}
#[test]
fn ensure_column_visible_right_scroll() {
let config = test_config();
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
Column::with_row(960, Row::new(WindowId(3), 0)),
],
0,
);
let result = ensure_column_visible(&layout, 2, &config);
assert_eq!(result.viewport_offset, 976);
}
#[test]
fn ensure_column_visible_left_scroll_has_gap_at_edge() {
let config = test_config();
let gap = config.padding.window_gap;
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
Column::with_row(960, Row::new(WindowId(3), 0)),
],
2000,
);
let result = ensure_column_visible(&layout, 0, &config);
let col_left = gap; assert!(
col_left - result.viewport_offset >= gap,
"left-edge gap must be >= window_gap"
);
}
#[test]
fn ensure_column_visible_right_scroll_has_gap_at_edge() {
let config = test_config();
let gap = config.padding.window_gap;
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
Column::with_row(960, Row::new(WindowId(3), 0)),
],
0,
);
let result = ensure_column_visible(&layout, 2, &config);
let col_right = gap + 2 * (960 + gap) + 960;
let vp_right = result.viewport_offset + config.monitor_width;
assert!(
vp_right - col_right >= gap,
"right-edge gap ({}) must be >= window_gap ({})",
vp_right - col_right,
gap
);
}
#[test]
fn ensure_column_visible_already_visible_no_change() {
let config = MutationConfig {
monitor_width: 1920,
monitor_height: 1080,
min_window_height_px: 100,
column_width: 960,
min_column_width_px: 480,
max_n: 1,
abs_max_width: 1920,
padding: Padding {
window_gap: 0,
up: 0,
down: 0,
},
columns_per_screen: 4,
};
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
],
0,
);
let result = ensure_column_visible(&layout, 1, &config);
assert_eq!(
result.viewport_offset, 0,
"no shift when column is already visible"
);
}
#[test]
fn ensure_column_visible_zero_gap_exact_alignment() {
let config = MutationConfig {
monitor_width: 1920,
monitor_height: 1080,
min_window_height_px: 100,
column_width: 960,
min_column_width_px: 480,
max_n: 1,
abs_max_width: 1920,
padding: Padding {
window_gap: 0,
up: 0,
down: 0,
},
columns_per_screen: 4,
};
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
Column::with_row(960, Row::new(WindowId(3), 0)),
],
0,
);
let result = ensure_column_visible(&layout, 2, &config);
assert_eq!(result.viewport_offset, 960);
}
#[test]
fn ensure_column_visible_non_uniform_widths() {
let config = test_config();
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(480, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
],
2000,
);
let result = ensure_column_visible(&layout, 1, &config);
assert_eq!(result.viewport_offset, 484);
let col_left = config.padding.window_gap + 480 + config.padding.window_gap;
let col_right = col_left + 960;
assert!(col_right <= result.viewport_offset + config.monitor_width);
assert!(col_left >= result.viewport_offset);
}
#[test]
fn focus_right_moves_to_next_column() {
let layout = three_column_layout();
let result =
focus(&layout, WindowId(1), Direction::Right, &test_config()).expect("focus right");
assert_eq!(result.focused, WindowId(2));
}
#[test]
fn focus_left_at_edge_returns_none() {
let layout = three_column_layout();
assert!(focus(&layout, WindowId(1), Direction::Left, &test_config()).is_none());
}
#[test]
fn focus_vertical_in_multirow_column() {
let layout = VirtualLayout::with_columns(
vec![Column::with_rows(
1920,
vec![
Row::new(WindowId(1), 0),
Row::new(WindowId(2), 0),
Row::new(WindowId(3), 0),
],
)],
0,
);
let r1 = focus(&layout, WindowId(1), Direction::Down, &test_config()).expect("down");
assert_eq!(r1.focused, WindowId(2));
let r2 = focus(&layout, WindowId(2), Direction::Down, &test_config()).expect("down");
assert_eq!(r2.focused, WindowId(3));
assert!(focus(&layout, WindowId(3), Direction::Down, &test_config()).is_none());
}
#[test]
fn focus_right_scrolls_if_column_offscreen() {
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
Column::with_row(960, Row::new(WindowId(3), 0)),
Column::with_row(960, Row::new(WindowId(4), 0)),
],
0,
);
let config = test_config();
let r1 = focus(&layout, WindowId(1), Direction::Right, &config).expect("r1");
assert_eq!(r1.focused, WindowId(2));
let r2 = focus(&r1.new_layout, WindowId(2), Direction::Right, &config).expect("r2");
assert_eq!(r2.focused, WindowId(3));
assert!(r2.new_layout.viewport_offset > r1.new_layout.viewport_offset);
}
#[test]
fn focus_right_crosses_to_column_with_different_row_count() {
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_rows(
960,
vec![
Row::new(WindowId(2), 0),
Row::new(WindowId(3), 0),
Row::new(WindowId(4), 0),
],
),
],
0,
);
let result =
focus(&layout, WindowId(1), Direction::Right, &test_config()).expect("focus right");
assert_eq!(result.focused, WindowId(2));
}
#[test]
fn swap_column_reorders() {
let layout = three_column_layout();
let result =
swap_column(&layout, WindowId(1), Direction::Right, &test_config()).expect("swap");
assert_eq!(result.columns[0].rows[0].window_id, WindowId(2));
assert_eq!(result.columns[1].rows[0].window_id, WindowId(1));
assert_eq!(result.columns[2].rows[0].window_id, WindowId(3));
}
#[test]
fn swap_column_down_returns_none() {
let layout = VirtualLayout::with_columns(
vec![Column::with_rows(
1920,
vec![Row::new(WindowId(1), 0), Row::new(WindowId(2), 0)],
)],
0,
);
assert!(swap_column(&layout, WindowId(1), Direction::Down, &test_config()).is_none());
}
#[test]
fn swap_column_up_returns_none() {
let layout = VirtualLayout::with_columns(
vec![Column::with_rows(
1920,
vec![Row::new(WindowId(1), 0), Row::new(WindowId(2), 0)],
)],
0,
);
assert!(swap_column(&layout, WindowId(2), Direction::Up, &test_config()).is_none());
}
#[test]
fn swap_column_left_at_edge_returns_none() {
let layout = three_column_layout();
assert!(swap_column(&layout, WindowId(1), Direction::Left, &test_config()).is_none());
}
#[test]
fn swap_column_shifts_viewport_when_target_offscreen() {
let config = test_config(); let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
Column::with_row(960, Row::new(WindowId(3), 0)),
],
960, );
let result = swap_column(&layout, WindowId(2), Direction::Left, &config).expect("swap");
assert!(
result.viewport_offset < 960,
"camera should shift left to reveal col 0"
);
}
#[test]
fn swap_column_no_viewport_change_when_both_visible() {
let config = MutationConfig {
monitor_width: 1920,
monitor_height: 1080,
min_window_height_px: 100,
column_width: 960,
min_column_width_px: 480,
max_n: 1, abs_max_width: 1920, padding: Padding {
window_gap: 0,
up: 0,
down: 0,
},
columns_per_screen: 4,
};
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
],
0,
);
let result = swap_column(&layout, WindowId(1), Direction::Right, &config).expect("swap");
assert_eq!(
result.viewport_offset, 0,
"camera should not shift when both columns are visible (zero gap)"
);
}
#[test]
fn swap_window_right_swaps_with_neighbor_in_next_column() {
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_rows(
960,
vec![Row::new(WindowId(2), 0), Row::new(WindowId(3), 0)],
),
],
0,
);
let result =
swap_window(&layout, WindowId(1), Direction::Right, &test_config()).expect("swap");
assert_eq!(result.columns[0].rows, vec![Row::new(WindowId(2), 0)]);
assert_eq!(
result.columns[1].rows,
vec![Row::new(WindowId(1), 0), Row::new(WindowId(3), 0)]
);
}
#[test]
fn swap_window_left_swaps_with_neighbor_in_prev_column() {
let layout = VirtualLayout::with_columns(
vec![
Column::with_rows(
960,
vec![Row::new(WindowId(1), 0), Row::new(WindowId(2), 0)],
),
Column::with_row(960, Row::new(WindowId(3), 0)),
],
0,
);
let result =
swap_window(&layout, WindowId(3), Direction::Left, &test_config()).expect("swap");
assert_eq!(
result.columns[0].rows,
vec![Row::new(WindowId(3), 0), Row::new(WindowId(2), 0)]
);
assert_eq!(result.columns[1].rows, vec![Row::new(WindowId(1), 0)]);
}
#[test]
fn swap_window_down_same_column() {
let layout = VirtualLayout::with_columns(
vec![Column::with_rows(
1920,
vec![Row::new(WindowId(1), 0), Row::new(WindowId(2), 0)],
)],
0,
);
let result =
swap_window(&layout, WindowId(1), Direction::Down, &test_config()).expect("swap");
assert_eq!(
result.columns[0].rows,
vec![Row::new(WindowId(2), 0), Row::new(WindowId(1), 0)]
);
}
#[test]
fn swap_window_picks_closest_row_in_target_column() {
let layout = VirtualLayout::with_columns(
vec![
Column::with_rows(
960,
vec![Row::new(WindowId(1), 0), Row::new(WindowId(2), 0)],
),
Column::with_row(960, Row::new(WindowId(3), 0)),
],
0,
);
let result =
swap_window(&layout, WindowId(2), Direction::Right, &test_config()).expect("swap");
assert_eq!(
result.columns[0].rows,
vec![Row::new(WindowId(1), 0), Row::new(WindowId(3), 0)]
);
assert_eq!(result.columns[1].rows, vec![Row::new(WindowId(2), 0)]);
}
#[test]
fn swap_window_right_at_edge_returns_none() {
let layout = three_column_layout();
assert!(swap_window(&layout, WindowId(3), Direction::Right, &test_config()).is_none());
}
#[test]
fn swap_window_left_at_edge_returns_none() {
let layout = three_column_layout();
assert!(swap_window(&layout, WindowId(1), Direction::Left, &test_config()).is_none());
}
#[test]
fn swap_window_down_at_last_row_returns_none() {
let layout = VirtualLayout::with_columns(
vec![Column::with_rows(
1920,
vec![Row::new(WindowId(1), 0), Row::new(WindowId(2), 0)],
)],
0,
);
assert!(swap_window(&layout, WindowId(2), Direction::Down, &test_config()).is_none());
}
#[test]
fn swap_window_up_same_column() {
let layout = VirtualLayout::with_columns(
vec![Column::with_rows(
1920,
vec![Row::new(WindowId(1), 0), Row::new(WindowId(2), 0)],
)],
0,
);
let result =
swap_window(&layout, WindowId(2), Direction::Up, &test_config()).expect("swap");
assert_eq!(
result.columns[0].rows,
vec![Row::new(WindowId(2), 0), Row::new(WindowId(1), 0)]
);
}
#[test]
fn swap_window_up_at_first_row_returns_none() {
let layout = VirtualLayout::with_columns(
vec![Column::with_rows(
1920,
vec![Row::new(WindowId(1), 0), Row::new(WindowId(2), 0)],
)],
0,
);
assert!(swap_window(&layout, WindowId(1), Direction::Up, &test_config()).is_none());
}
#[test]
fn swap_window_shifts_viewport_when_target_offscreen() {
let config = test_config();
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
Column::with_row(960, Row::new(WindowId(3), 0)),
],
960, );
let result = swap_window(&layout, WindowId(2), Direction::Left, &config).expect("swap");
assert!(
result.viewport_offset < 960,
"camera should shift left to reveal col 0"
);
}
#[test]
fn swap_window_no_viewport_change_when_both_visible() {
let config = MutationConfig {
monitor_width: 1920,
monitor_height: 1080,
min_window_height_px: 100,
column_width: 960,
min_column_width_px: 480,
max_n: 1, abs_max_width: 1920, padding: Padding {
window_gap: 0,
up: 0,
down: 0,
},
columns_per_screen: 4,
};
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
],
0,
);
let result = swap_window(&layout, WindowId(1), Direction::Right, &config).expect("swap");
assert_eq!(result.viewport_offset, 0);
}
#[test]
fn swap_window_nonexistent_returns_none() {
let layout = three_column_layout();
assert!(swap_window(&layout, WindowId(99), Direction::Right, &test_config()).is_none());
}
#[test]
fn find_neighbor_right_returns_first_row_in_next_column() {
let layout = three_column_layout();
let neighbor = find_neighbor_window(&layout, WindowId(1), Direction::Right).expect("right");
assert_eq!(neighbor, NeighborLocation { col: 1, row: 0 });
assert_eq!(
layout.columns[neighbor.col].rows[neighbor.row].window_id,
WindowId(2)
);
}
#[test]
fn find_neighbor_left_returns_first_row_in_prev_column() {
let layout = three_column_layout();
let neighbor = find_neighbor_window(&layout, WindowId(2), Direction::Left).expect("left");
assert_eq!(neighbor, NeighborLocation { col: 0, row: 0 });
assert_eq!(
layout.columns[neighbor.col].rows[neighbor.row].window_id,
WindowId(1)
);
}
#[test]
fn find_neighbor_up_returns_prev_row_same_column() {
let layout = VirtualLayout::with_columns(
vec![Column::with_rows(
1920,
vec![Row::new(WindowId(1), 0), Row::new(WindowId(2), 0)],
)],
0,
);
let neighbor = find_neighbor_window(&layout, WindowId(2), Direction::Up).expect("up");
assert_eq!(neighbor, NeighborLocation { col: 0, row: 0 });
}
#[test]
fn find_neighbor_down_returns_next_row_same_column() {
let layout = VirtualLayout::with_columns(
vec![Column::with_rows(
1920,
vec![Row::new(WindowId(1), 0), Row::new(WindowId(2), 0)],
)],
0,
);
let neighbor = find_neighbor_window(&layout, WindowId(1), Direction::Down).expect("down");
assert_eq!(neighbor, NeighborLocation { col: 0, row: 1 });
}
#[test]
fn find_neighbor_clamps_row_to_target_column_size() {
let layout = VirtualLayout::with_columns(
vec![
Column::with_rows(
960,
vec![
Row::new(WindowId(1), 0),
Row::new(WindowId(2), 0),
Row::new(WindowId(3), 0),
],
),
Column::with_row(960, Row::new(WindowId(4), 0)),
],
0,
);
let neighbor = find_neighbor_window(&layout, WindowId(3), Direction::Right).expect("right");
assert_eq!(neighbor, NeighborLocation { col: 1, row: 0 });
}
#[test]
fn find_neighbor_right_at_edge_returns_none() {
let layout = three_column_layout();
assert!(find_neighbor_window(&layout, WindowId(3), Direction::Right).is_none());
}
#[test]
fn find_neighbor_left_at_edge_returns_none() {
let layout = three_column_layout();
assert!(find_neighbor_window(&layout, WindowId(1), Direction::Left).is_none());
}
#[test]
fn find_neighbor_up_at_first_row_returns_none() {
let layout = VirtualLayout::with_columns(
vec![Column::with_rows(
1920,
vec![Row::new(WindowId(1), 0), Row::new(WindowId(2), 0)],
)],
0,
);
assert!(find_neighbor_window(&layout, WindowId(1), Direction::Up).is_none());
}
#[test]
fn find_neighbor_down_at_last_row_returns_none() {
let layout = VirtualLayout::with_columns(
vec![Column::with_rows(
1920,
vec![Row::new(WindowId(1), 0), Row::new(WindowId(2), 0)],
)],
0,
);
assert!(find_neighbor_window(&layout, WindowId(2), Direction::Down).is_none());
}
#[test]
fn find_neighbor_nonexistent_window_returns_none() {
let layout = three_column_layout();
assert!(find_neighbor_window(&layout, WindowId(99), Direction::Right).is_none());
}
#[test]
fn find_neighbor_source_fewer_rows_picks_closest() {
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_rows(
960,
vec![
Row::new(WindowId(2), 0),
Row::new(WindowId(3), 0),
Row::new(WindowId(4), 0),
],
),
],
0,
);
let neighbor = find_neighbor_window(&layout, WindowId(1), Direction::Right).expect("right");
assert_eq!(neighbor, NeighborLocation { col: 1, row: 0 });
assert_eq!(
layout.columns[neighbor.col].rows[neighbor.row].window_id,
WindowId(2)
);
}
#[test]
fn find_neighbor_multirow_matching_row_in_both_columns() {
let layout = VirtualLayout::with_columns(
vec![
Column::with_rows(
960,
vec![
Row::new(WindowId(1), 0),
Row::new(WindowId(5), 0),
Row::new(WindowId(9), 0),
],
),
Column::with_rows(
960,
vec![
Row::new(WindowId(2), 0),
Row::new(WindowId(8), 0),
Row::new(WindowId(6), 0),
],
),
],
0,
);
let neighbor = find_neighbor_window(&layout, WindowId(5), Direction::Right).expect("right");
assert_eq!(neighbor, NeighborLocation { col: 1, row: 1 });
assert_eq!(
layout.columns[neighbor.col].rows[neighbor.row].window_id,
WindowId(8)
);
}
#[test]
fn set_column_width_sets_target_in_px() {
let layout = three_column_layout();
let result =
set_column_width(&layout, WindowId(1), 1440, &test_config()).expect("set width");
assert_eq!(result.columns[0].width_px, 1440);
}
#[test]
fn set_column_width_only_affects_target_column() {
let layout = three_column_layout();
let result =
set_column_width(&layout, WindowId(1), 1440, &test_config()).expect("set width");
assert_eq!(result.columns[0].width_px, 1440);
assert_eq!(result.columns[1].width_px, 960);
assert_eq!(result.columns[2].width_px, 960);
}
#[test]
fn set_column_width_returns_none_if_below_min() {
let layout = three_column_layout();
assert!(set_column_width(&layout, WindowId(1), 320, &test_config()).is_none());
}
#[test]
fn set_column_width_returns_none_if_above_max() {
let layout = three_column_layout();
assert!(set_column_width(&layout, WindowId(1), 2400, &test_config()).is_none());
}
#[test]
fn set_column_width_returns_none_if_same_as_current() {
let layout = three_column_layout();
assert!(set_column_width(&layout, WindowId(1), 960, &test_config()).is_none());
}
#[test]
fn set_column_width_ensures_column_visible() {
let config = test_config();
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
Column::with_row(960, Row::new(WindowId(3), 0)),
],
3000,
);
let result = set_column_width(&layout, WindowId(3), 1500, &config).expect("set width");
assert_eq!(result.columns[2].width_px, 1500);
assert!(
result.viewport_offset < 3000,
"viewport should shift left to reveal the resized column"
);
}
#[test]
fn resize_column_positive_delta_grows() {
let layout = three_column_layout();
let result = resize_column(&layout, WindowId(1), 480, &test_config()).expect("resize +480");
assert_eq!(result.columns[0].width_px, 1440);
}
#[test]
fn resize_column_negative_delta_shrinks() {
let layout = three_column_layout();
let result =
resize_column(&layout, WindowId(1), -480, &test_config()).expect("resize -480");
assert_eq!(result.columns[0].width_px, 480);
}
#[test]
fn resize_column_delta_out_of_bounds_returns_none() {
let layout = three_column_layout();
assert!(resize_column(&layout, WindowId(1), 2000, &test_config()).is_none());
}
#[test]
fn expand_column_from_default_width() {
let layout = three_column_layout(); let result = expand_column(&layout, WindowId(1), &test_config()).expect("expand");
assert_eq!(result.columns[0].width_px, 1912);
}
#[test]
fn expand_column_from_sub_boundary() {
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(480, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
],
0,
);
let result = expand_column(&layout, WindowId(1), &test_config()).expect("expand");
assert_eq!(result.columns[0].width_px, 960);
}
#[test]
fn expand_column_at_max_returns_none() {
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(1912, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
],
0,
);
assert!(expand_column(&layout, WindowId(1), &test_config()).is_none());
}
#[test]
fn expand_column_only_affects_target() {
let layout = three_column_layout();
let result = expand_column(&layout, WindowId(1), &test_config()).expect("expand");
assert_eq!(result.columns[0].width_px, 1912);
assert_eq!(result.columns[1].width_px, 960);
assert_eq!(result.columns[2].width_px, 960);
}
#[test]
fn shrink_column_from_max_width() {
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(1912, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
],
0,
);
let result = shrink_column(&layout, WindowId(1), &test_config()).expect("shrink");
assert_eq!(result.columns[0].width_px, 960);
}
#[test]
fn shrink_column_from_mid_boundary() {
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(1200, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
],
0,
);
let result = shrink_column(&layout, WindowId(1), &test_config()).expect("shrink");
assert_eq!(result.columns[0].width_px, 960);
}
#[test]
fn shrink_column_at_boundary_returns_none() {
let layout = three_column_layout();
assert!(shrink_column(&layout, WindowId(1), &test_config()).is_none());
}
#[test]
fn shrink_column_at_base_width_returns_none() {
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(480, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
],
0,
);
assert!(shrink_column(&layout, WindowId(1), &test_config()).is_none());
}
#[test]
fn shrink_column_only_affects_target() {
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(1912, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
],
0,
);
let result = shrink_column(&layout, WindowId(1), &test_config()).expect("shrink");
assert_eq!(result.columns[0].width_px, 960);
assert_eq!(result.columns[1].width_px, 960);
}
#[test]
fn toggle_monocle_expands_to_full() {
let layout = three_column_layout();
let (result, saved) =
toggle_monocle(&layout, WindowId(1), None, &test_config()).expect("monocle on");
assert_eq!(result.columns[0].width_px, 1912);
assert_eq!(saved, Some(960));
let (restored, saved2) =
toggle_monocle(&result, WindowId(1), saved, &test_config()).expect("monocle off");
assert_eq!(restored.columns[0].width_px, 960);
assert_eq!(saved2, None);
}
#[test]
fn add_window_appends_column() {
let layout = three_column_layout();
let result = add_window(&layout, WindowId(10), &test_config());
assert_eq!(result.columns.len(), 4);
assert_eq!(result.columns[3].rows[0].window_id, WindowId(10));
assert_eq!(result.columns[3].width_px, 960);
}
#[test]
fn insert_after_focused_inserts_between_columns() {
let layout = three_column_layout();
let config = test_config();
let result = insert_window_after_focused(&layout, Some(WindowId(1)), WindowId(10), &config);
assert_eq!(result.columns.len(), 4);
assert_eq!(result.columns[0].rows[0].window_id, WindowId(1)); assert_eq!(result.columns[1].rows[0].window_id, WindowId(10)); assert_eq!(result.columns[2].rows[0].window_id, WindowId(2)); assert_eq!(result.columns[3].rows[0].window_id, WindowId(3)); }
#[test]
fn insert_after_focused_middle_column() {
let layout = three_column_layout();
let config = test_config();
let result = insert_window_after_focused(&layout, Some(WindowId(2)), WindowId(10), &config);
assert_eq!(result.columns.len(), 4);
assert_eq!(result.columns[0].rows[0].window_id, WindowId(1)); assert_eq!(result.columns[1].rows[0].window_id, WindowId(2)); assert_eq!(result.columns[2].rows[0].window_id, WindowId(10)); assert_eq!(result.columns[3].rows[0].window_id, WindowId(3)); }
#[test]
fn insert_after_focused_last_column_appends() {
let layout = three_column_layout();
let config = test_config();
let result = insert_window_after_focused(&layout, Some(WindowId(3)), WindowId(10), &config);
assert_eq!(result.columns.len(), 4);
assert_eq!(result.columns[3].rows[0].window_id, WindowId(10));
}
#[test]
fn insert_after_focused_no_focus_appends_to_empty() {
let layout = VirtualLayout::new();
let config = test_config();
let result = insert_window_after_focused(&layout, None, WindowId(1), &config);
assert_eq!(result.columns.len(), 1);
assert_eq!(result.columns[0].rows[0].window_id, WindowId(1));
}
#[test]
fn insert_after_focused_stale_focus_appends() {
let layout = three_column_layout();
let config = test_config();
let result =
insert_window_after_focused(&layout, Some(WindowId(99)), WindowId(10), &config);
assert_eq!(result.columns.len(), 4);
assert_eq!(result.columns[3].rows[0].window_id, WindowId(10));
}
#[test]
fn insert_after_focused_brings_new_column_into_view() {
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
Column::with_row(960, Row::new(WindowId(3), 0)),
Column::with_row(960, Row::new(WindowId(4), 0)),
],
0,
);
let config = test_config();
let result = insert_window_after_focused(&layout, Some(WindowId(2)), WindowId(10), &config);
assert_eq!(result.columns.len(), 5);
assert_eq!(result.columns[2].rows[0].window_id, WindowId(10));
assert!(
result.viewport_offset > 0,
"viewport should scroll to reveal newly inserted column, got offset {}",
result.viewport_offset
);
}
#[test]
fn remove_window_from_column() {
let layout = VirtualLayout::with_columns(
vec![Column::with_rows(
1920,
vec![Row::new(WindowId(1), 0), Row::new(WindowId(2), 0)],
)],
0,
);
let result = remove_window(&layout, WindowId(1), &test_config());
assert_eq!(result.columns.len(), 1);
assert_eq!(result.columns[0].rows, vec![Row::new(WindowId(2), 1072)]);
}
#[test]
fn remove_last_window_in_column_removes_column() {
let layout = three_column_layout();
let result = remove_window(&layout, WindowId(2), &test_config());
assert_eq!(result.columns.len(), 2);
assert_eq!(result.columns[0].rows[0].window_id, WindowId(1));
assert_eq!(result.columns[1].rows[0].window_id, WindowId(3));
}
#[test]
fn remove_nonexistent_window_is_noop() {
let layout = three_column_layout();
let result = remove_window(&layout, WindowId(99), &test_config());
assert_eq!(result, layout);
}
#[test]
fn add_window_to_column_appends_row() {
let layout =
VirtualLayout::with_columns(vec![Column::with_row(1920, Row::new(WindowId(1), 0))], 0);
let result = add_window_to_column(&layout, 0, WindowId(2), &test_config());
assert_eq!(
result.columns[0].rows,
vec![Row::new(WindowId(1), 534), Row::new(WindowId(2), 534)]
);
}
#[test]
fn add_window_to_invalid_column_is_noop() {
let layout =
VirtualLayout::with_columns(vec![Column::with_row(1920, Row::new(WindowId(1), 0))], 0);
let result = add_window_to_column(&layout, 5, WindowId(2), &test_config());
assert_eq!(result.columns.len(), 1);
assert_eq!(result.columns[0].rows.len(), 1);
}
#[test]
fn scroll_right_at_max_offset_returns_none() {
let layout =
VirtualLayout::with_columns(vec![Column::with_row(1912, Row::new(WindowId(1), 0))], 0);
let config = test_config();
assert!(scroll_right(&layout, &config).is_none());
}
#[test]
fn focus_right_at_last_column_returns_none() {
let layout = three_column_layout();
assert!(focus(&layout, WindowId(3), Direction::Right, &test_config()).is_none());
}
#[test]
fn focus_up_at_first_row_returns_none() {
let layout = VirtualLayout::with_columns(
vec![Column::with_rows(
1920,
vec![Row::new(WindowId(1), 0), Row::new(WindowId(2), 0)],
)],
0,
);
assert!(focus(&layout, WindowId(1), Direction::Up, &test_config()).is_none());
}
#[test]
fn focus_on_nonexistent_window_returns_none() {
let layout = three_column_layout();
assert!(focus(&layout, WindowId(99), Direction::Right, &test_config()).is_none());
}
#[test]
fn shrink_at_minimum_width_returns_none() {
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(480, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
],
0,
);
assert!(shrink_column(&layout, WindowId(1), &test_config()).is_none());
}
#[test]
fn toggle_monocle_without_saved_width_uses_default() {
let layout =
VirtualLayout::with_columns(vec![Column::with_row(1912, Row::new(WindowId(1), 0))], 0);
let (result, saved) =
toggle_monocle(&layout, WindowId(1), None, &test_config()).expect("toggle");
assert_eq!(result.columns[0].width_px, 960); assert_eq!(saved, None);
}
#[test]
fn add_window_to_empty_layout() {
let layout = VirtualLayout::new();
let result = add_window(&layout, WindowId(1), &test_config());
assert_eq!(result.columns.len(), 1);
assert_eq!(result.columns[0].rows[0].window_id, WindowId(1));
assert_eq!(result.columns[0].width_px, 960); }
#[test]
fn remove_all_windows_yields_empty_layout() {
let layout = three_column_layout();
let cfg = test_config();
let r1 = remove_window(&layout, WindowId(1), &cfg);
let r2 = remove_window(&r1, WindowId(2), &cfg);
let r3 = remove_window(&r2, WindowId(3), &cfg);
assert!(r3.columns.is_empty());
}
#[test]
fn set_column_width_no_change_returns_none() {
let layout = three_column_layout();
assert!(set_column_width(&layout, WindowId(1), 960, &test_config()).is_none());
}
#[test]
fn swap_column_left_single_column_returns_none() {
let layout = VirtualLayout::with_columns(
vec![Column::with_rows(
1920,
vec![Row::new(WindowId(1), 0), Row::new(WindowId(2), 0)],
)],
0,
);
assert!(swap_column(&layout, WindowId(1), Direction::Left, &test_config()).is_none());
}
#[test]
fn swap_column_right_at_last_column_returns_none() {
let layout = three_column_layout();
assert!(swap_column(&layout, WindowId(3), Direction::Right, &test_config()).is_none());
}
#[test]
fn initialize_windows_empty_list() {
let layout = initialize_windows(&[], &test_config(), None, None);
assert!(layout.columns.is_empty());
assert_eq!(layout.viewport_offset, 0);
}
#[test]
fn initialize_windows_single_window() {
let layout = initialize_windows(&[WindowId(1)], &test_config(), None, None);
assert_eq!(layout.columns.len(), 1);
assert_eq!(layout.columns[0].rows, vec![Row::new(WindowId(1), 1072)]);
assert_eq!(layout.columns[0].width_px, 960); assert_eq!(layout.viewport_offset, -476, "fit case → canvas centered");
}
#[test]
fn initialize_windows_multiple_windows() {
let layout = initialize_windows(
&[WindowId(10), WindowId(20), WindowId(30)],
&test_config(),
None,
None,
);
assert_eq!(layout.columns.len(), 3);
assert_eq!(layout.columns[0].rows, vec![Row::new(WindowId(10), 1072)]);
assert_eq!(layout.columns[1].rows, vec![Row::new(WindowId(20), 1072)]);
assert_eq!(layout.columns[2].rows, vec![Row::new(WindowId(30), 1072)]);
assert_eq!(layout.columns[0].width_px, 960);
assert_eq!(layout.columns[1].width_px, 960);
assert_eq!(layout.columns[2].width_px, 960);
assert_eq!(layout.viewport_offset, 0);
}
#[test]
fn initialize_windows_with_focus_shows_all_when_within_columns_per_screen() {
let layout = initialize_windows(
&[WindowId(1), WindowId(2), WindowId(3)],
&test_config(),
Some(2),
None,
);
assert_eq!(layout.columns.len(), 3);
assert_eq!(
layout.viewport_offset, 976,
"3 cols overflow with focus=2 → ensure_column_visible offset"
);
}
#[test]
fn initialize_windows_without_focus_produces_zero_offset() {
let layout = initialize_windows(
&[WindowId(1), WindowId(2), WindowId(3)],
&test_config(),
None,
None,
);
assert_eq!(layout.viewport_offset, 0);
}
#[test]
fn initialize_windows_with_out_of_bounds_focus_uses_zero_offset() {
let layout = initialize_windows(
&[WindowId(1), WindowId(2), WindowId(3)],
&test_config(),
Some(99),
None,
);
assert_eq!(
layout.viewport_offset, 0,
"out-of-bounds focus index should produce zero offset"
);
}
#[test]
fn initialize_windows_with_focus_on_first_column() {
let layout = initialize_windows(
&[WindowId(1), WindowId(2), WindowId(3)],
&test_config(),
Some(0),
None,
);
assert_eq!(layout.viewport_offset, 0);
}
fn viewport_config(
monitor_width: i32,
column_width: u32,
gap: i32,
columns_per_screen: u32,
) -> MutationConfig {
let shift = column_width as i32 + gap;
let abs_max = monitor_width - 2 * gap;
let max_n = if shift > 0 && abs_max > column_width as i32 {
((abs_max - column_width as i32) / shift) as u32
} else {
0
};
MutationConfig {
monitor_width,
monitor_height: 1080,
min_window_height_px: 100,
column_width,
min_column_width_px: column_width / 2,
max_n,
abs_max_width: abs_max,
padding: Padding {
window_gap: gap,
up: 0,
down: 0,
},
columns_per_screen,
}
}
#[test]
fn initialize_windows_scroll_case_with_focus() {
let config = viewport_config(1920, 960, 4, 2);
let layout = initialize_windows(
&[WindowId(1), WindowId(2), WindowId(3), WindowId(4)],
&config,
Some(2),
None,
);
assert_eq!(layout.columns.len(), 4);
assert_ne!(
layout.viewport_offset, 0,
"4 cols > columns_per_screen=2 with focus=2 → non-zero offset"
);
}
#[test]
fn initialize_windows_scroll_case_focus_first_col() {
let config = viewport_config(1920, 960, 4, 2);
let layout = initialize_windows(
&[WindowId(1), WindowId(2), WindowId(3), WindowId(4)],
&config,
Some(0),
None,
);
assert_eq!(
layout.viewport_offset, 0,
"scroll case with focus=0 → offset 0 (first col visible from start)"
);
}
fn realistic_gap_config() -> MutationConfig {
MutationConfig {
monitor_width: 2560,
monitor_height: 1080,
min_window_height_px: 100,
column_width: 960,
min_column_width_px: 480,
max_n: 1, abs_max_width: 2528, padding: Padding {
window_gap: 16,
up: 0,
down: 0,
},
columns_per_screen: 2,
}
}
#[test]
fn expand_preserves_window_gap_in_ladder() {
let config = realistic_gap_config(); let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
],
0,
);
let result = expand_column(&layout, WindowId(1), &config).expect("expand from base");
assert_eq!(
result.columns[0].width_px, 1936,
"expand must include window_gap (960+976, not 960+960)"
);
}
#[test]
fn expand_two_step_abs_max_then_noop() {
let config = realistic_gap_config();
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(1936, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
],
0,
);
let result =
expand_column(&layout, WindowId(1), &config).expect("expand slot_max -> abs_max");
assert_eq!(result.columns[0].width_px, 2528);
assert!(expand_column(&result, WindowId(1), &config).is_none());
}
#[test]
fn shrink_reverse_ladder() {
let config = realistic_gap_config();
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(2528, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
],
0,
);
let r1 = shrink_column(&layout, WindowId(1), &config).expect("shrink abs_max -> slot_max");
assert_eq!(r1.columns[0].width_px, 1936);
let r2 = shrink_column(&r1, WindowId(1), &config).expect("shrink slot_max -> base");
assert_eq!(r2.columns[0].width_px, 960);
assert!(
shrink_column(&r2, WindowId(1), &config).is_none(),
"shrink at base -> None"
);
}
#[test]
fn expand_shrink_roundtrip_with_gap() {
let config = realistic_gap_config();
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
],
0,
);
let expanded = expand_column(&layout, WindowId(1), &config).expect("expand");
assert_eq!(expanded.columns[0].width_px, 1936);
let shrunk = shrink_column(&expanded, WindowId(1), &config).expect("shrink");
assert_eq!(shrunk.columns[0].width_px, 960);
}
fn slot_max_equals_abs_max_config() -> MutationConfig {
MutationConfig {
monitor_width: 1920,
monitor_height: 1080,
min_window_height_px: 100,
column_width: 960,
min_column_width_px: 480,
max_n: 1,
abs_max_width: 1920,
padding: Padding {
window_gap: 0,
up: 0,
down: 0,
},
columns_per_screen: 2,
}
}
#[test]
fn shrink_from_abs_max_when_slot_max_equals_abs_max() {
let config = slot_max_equals_abs_max_config(); let layout = VirtualLayout::with_columns(
vec![
Column::with_row(1920, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
],
0,
);
let result = shrink_column(&layout, WindowId(1), &config)
.expect("shrink from abs_max must succeed even when slot_max == abs_max");
assert_eq!(
result.columns[0].width_px, 960,
"shrink from abs_max lands one rung below when slot_max == abs_max"
);
}
fn multi_step_config() -> MutationConfig {
MutationConfig {
monitor_width: 3840,
monitor_height: 1080,
min_window_height_px: 100,
column_width: 960,
min_column_width_px: 480,
max_n: 2,
abs_max_width: 3808,
padding: Padding {
window_gap: 16,
up: 0,
down: 0,
},
columns_per_screen: 3,
}
}
#[test]
fn expand_multi_step_ladder_advances_one_rung_at_a_time() {
let config = multi_step_config();
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
],
0,
);
let r1 = expand_column(&layout, WindowId(1), &config).expect("expand base → n=1");
assert_eq!(
r1.columns[0].width_px, 1936,
"first rung: base + column_shift"
);
let r2 = expand_column(&r1, WindowId(1), &config).expect("expand n=1 → n=2");
assert_eq!(r2.columns[0].width_px, 2912, "second rung: slot_max");
let r3 = expand_column(&r2, WindowId(1), &config).expect("expand slot_max → abs_max");
assert_eq!(
r3.columns[0].width_px, 3808,
"two-step top: slot_max → abs_max"
);
assert!(
expand_column(&r3, WindowId(1), &config).is_none(),
"abs_max → None"
);
}
#[test]
fn shrink_multi_step_ladder_descends_one_rung_at_a_time() {
let config = multi_step_config();
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(3808, Row::new(WindowId(1), 0)), Column::with_row(960, Row::new(WindowId(2), 0)),
],
0,
);
let r1 = shrink_column(&layout, WindowId(1), &config).expect("shrink abs_max → slot_max");
assert_eq!(
r1.columns[0].width_px, 2912,
"reverse two-step: abs_max → slot_max"
);
let r2 = shrink_column(&r1, WindowId(1), &config).expect("shrink n=2 → n=1");
assert_eq!(r2.columns[0].width_px, 1936, "slot_max → n=1");
let r3 = shrink_column(&r2, WindowId(1), &config).expect("shrink n=1 → base");
assert_eq!(r3.columns[0].width_px, 960, "n=1 → base");
assert!(
shrink_column(&r3, WindowId(1), &config).is_none(),
"base → None"
);
}
#[test]
fn resize_column_small_delta_succeeds() {
let layout = three_column_layout();
let result = resize_column(&layout, WindowId(1), 100, &test_config()).expect("resize +100");
assert_eq!(
result.columns[0].width_px, 1060,
"small +100 delta must succeed as free-form"
);
}
#[test]
fn resize_column_negative_small_delta_succeeds() {
let layout = three_column_layout();
let result =
resize_column(&layout, WindowId(1), -100, &test_config()).expect("resize -100");
assert_eq!(
result.columns[0].width_px, 860,
"small -100 delta must succeed as free-form"
);
}
#[test]
fn resize_column_negative_delta_below_min_returns_none() {
let layout = three_column_layout();
assert!(
resize_column(&layout, WindowId(1), -600, &test_config()).is_none(),
"resize below min must return None"
);
}
#[test]
fn toggle_monocle_saves_and_restores_expanded_width() {
let config = realistic_gap_config(); let layout = VirtualLayout::with_columns(
vec![
Column::with_row(1936, Row::new(WindowId(1), 0)), Column::with_row(960, Row::new(WindowId(2), 0)),
],
0,
);
let (r1, saved) = toggle_monocle(&layout, WindowId(1), None, &config).expect("monocle on");
assert_eq!(r1.columns[0].width_px, 2528);
assert_eq!(
saved,
Some(1936),
"saved width should be the pre-monocle expanded width"
);
let (r2, saved2) = toggle_monocle(&r1, WindowId(1), saved, &config).expect("monocle off");
assert_eq!(
r2.columns[0].width_px, 1936,
"restored to slot_max, not base"
);
assert_eq!(saved2, None);
}
#[test]
fn mutation_config_column_shift_includes_gap() {
let config = realistic_gap_config(); assert_eq!(config.column_shift(), 976, "column_shift = 960 + 16");
}
#[test]
fn mutation_config_column_shift_and_slot_max_zero_gap() {
let config = MutationConfig {
monitor_width: 1920,
monitor_height: 1080,
min_window_height_px: 100,
column_width: 960,
min_column_width_px: 480,
max_n: 1,
abs_max_width: 1920,
padding: Padding {
window_gap: 0,
up: 0,
down: 0,
},
columns_per_screen: 4,
};
assert_eq!(
config.column_shift(),
960,
"column_shift = column_width when gap=0"
);
assert_eq!(config.slot_max(), 1920, "slot_max = 960 + 1×960 = 1920");
}
#[test]
fn mutation_config_slot_max_equals_base_when_max_n_zero() {
let config = test_config(); assert_eq!(
config.slot_max(),
960,
"slot_max = column_width when max_n=0"
);
}
#[test]
fn center_viewport_on_focused_lands_at_monitor_midpoint() {
let config = test_config();
let layout = three_column_layout();
let offset = center_viewport_on_focused(&layout, 1, &config);
let focus_center = 960 + 2 * 4 + 960 / 2;
assert_eq!(
focus_center - offset,
config.monitor_width / 2,
"focused column center must land at monitor midpoint"
);
}
#[test]
fn center_viewport_on_focused_all_fit_case() {
let config = viewport_config(1920, 480, 4, 4);
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(480, Row::new(WindowId(1), 0)),
Column::with_row(480, Row::new(WindowId(2), 0)),
],
0,
);
let offset = center_viewport_on_focused(&layout, 1, &config);
let focus_center = 480 + 2 * 4 + 480 / 2;
assert_eq!(
focus_center - offset,
config.monitor_width / 2,
"all-fit: focused col still lands at monitor midpoint"
);
}
#[test]
fn center_viewport_on_focused_variable_widths() {
let config = test_config();
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(480, Row::new(WindowId(1), 0)),
Column::with_row(720, Row::new(WindowId(2), 0)),
Column::with_row(960, Row::new(WindowId(3), 0)),
],
0,
);
let offset = center_viewport_on_focused(&layout, 2, &config);
let focus_center = 480 + 720 + 3 * 4 + 960 / 2;
assert_eq!(
focus_center - offset,
config.monitor_width / 2,
"variable widths: prefix-sum math correctly centers focused col"
);
}
#[test]
fn center_viewport_canvas_negative_when_canvas_smaller() {
let config = viewport_config(1920, 480, 4, 4);
let layout =
VirtualLayout::with_columns(vec![Column::with_row(480, Row::new(WindowId(1), 0))], 0);
let offset = center_viewport_canvas(&layout, &config);
assert_eq!(offset, -716, "negative when canvas < monitor");
}
#[test]
fn center_viewport_canvas_positive_when_canvas_larger() {
let config = test_config();
let layout = three_column_layout();
let offset = center_viewport_canvas(&layout, &config);
assert_eq!(offset, 488, "positive when canvas > monitor");
}
#[test]
fn center_viewport_canvas_zero_when_equal() {
let config = viewport_config(1920, 960, 0, 4);
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
],
0,
);
let offset = center_viewport_canvas(&layout, &config);
assert_eq!(offset, 0, "zero when canvas == monitor");
}
#[test]
fn quantize_to_ladder_at_base_returns_base() {
let config = realistic_gap_config(); assert_eq!(quantize_to_ladder(960, &config), 960);
}
#[test]
fn quantize_to_ladder_near_first_rung_snaps_up() {
let config = realistic_gap_config();
assert_eq!(quantize_to_ladder(1448, &config), 1936);
}
#[test]
fn quantize_to_ladder_near_first_rung_snaps_down() {
let config = realistic_gap_config();
assert_eq!(quantize_to_ladder(1447, &config), 960);
}
#[test]
fn quantize_to_ladder_at_slot_max_returns_slot_max() {
let config = realistic_gap_config();
assert_eq!(quantize_to_ladder(1936, &config), 1936);
}
#[test]
fn quantize_to_ladder_in_two_step_gap_snaps_to_abs_max() {
let config = realistic_gap_config();
assert_eq!(quantize_to_ladder(2300, &config), 2528);
}
#[test]
fn quantize_to_ladder_in_two_step_gap_snaps_to_slot_max() {
let config = realistic_gap_config();
assert_eq!(quantize_to_ladder(2200, &config), 1936);
}
#[test]
fn quantize_to_ladder_clamps_below_base() {
let config = realistic_gap_config();
assert_eq!(quantize_to_ladder(100, &config), 960);
}
#[test]
fn quantize_to_ladder_clamps_above_abs_max() {
let config = realistic_gap_config();
assert_eq!(quantize_to_ladder(5000, &config), 2528);
}
#[test]
fn init_with_widths_quantizes_and_clamps() {
let config = realistic_gap_config();
let layout = initialize_windows(
&[WindowId(1), WindowId(2), WindowId(3)],
&config,
None,
Some(&[500, 1450, 5000]),
);
assert_eq!(layout.columns[0].width_px, 960, "below base → clamped");
assert_eq!(layout.columns[1].width_px, 1936, "near rung → snapped");
assert_eq!(layout.columns[2].width_px, 2528, "above abs_max → clamped");
}
#[test]
fn init_fit_case_centers_canvas() {
let config = viewport_config(1920, 300, 4, 4);
let layout = initialize_windows(&[WindowId(1), WindowId(2)], &config, None, None);
assert_eq!(layout.viewport_offset, -654, "fit case → canvas center");
}
#[test]
fn init_overflow_with_focus_uses_ensure_column_visible() {
let layout = initialize_windows(
&[WindowId(1), WindowId(2), WindowId(3)],
&test_config(),
Some(2),
None,
);
assert_eq!(
layout.viewport_offset, 976,
"overflow with focus → ensure_column_visible"
);
}
#[test]
fn init_with_widths_none_preserves_old_behavior() {
let layout = initialize_windows(
&[WindowId(1), WindowId(2), WindowId(3)],
&test_config(),
Some(0),
None,
);
assert_eq!(layout.columns.len(), 3);
for col in &layout.columns {
assert_eq!(col.width_px, 960, "all cols at base width");
}
}
#[test]
fn center_viewport_on_focused_first_column_includes_leading_gap() {
let config = test_config();
let layout = three_column_layout();
let offset = center_viewport_on_focused(&layout, 0, &config);
let focus_center = 4 + 960 / 2; assert_eq!(
focus_center - offset,
config.monitor_width / 2,
"focus col 0 center must land at monitor midpoint"
);
assert_eq!(
offset, -476,
"canvas_x(0)=4 (leading gap), offset = 4 - 480"
);
}
#[test]
fn center_viewport_on_focused_last_column() {
let config = test_config();
let layout = three_column_layout();
let offset = center_viewport_on_focused(&layout, 2, &config);
let canvas_x_2 = 960 + 960 + 3 * 4;
let focus_center = canvas_x_2 + 960 / 2;
assert_eq!(
focus_center - offset,
config.monitor_width / 2,
"last focus col must land at monitor midpoint"
);
assert_eq!(offset, 1452);
}
#[test]
fn center_viewport_on_focused_single_column() {
let config = test_config();
let layout =
VirtualLayout::with_columns(vec![Column::with_row(960, Row::new(WindowId(1), 0))], 0);
let offset = center_viewport_on_focused(&layout, 0, &config);
assert_eq!(offset, -476, "single column centered at midpoint");
}
#[test]
fn insert_after_focused_with_width_sets_explicit_width_verbatim() {
let layout = three_column_layout();
let config = test_config();
let result = insert_window_after_focused_with_width(
&layout,
Some(WindowId(1)),
WindowId(10),
1500,
&config,
);
assert_eq!(result.columns.len(), 4);
assert_eq!(
result.columns[1].rows[0].window_id,
WindowId(10),
"new column at index 1"
);
assert_eq!(
result.columns[1].width_px, 1500,
"width must pass through verbatim (caller quantizes)"
);
assert_eq!(result.columns[0].width_px, 960);
assert_eq!(result.columns[2].width_px, 960);
assert_eq!(result.columns[3].width_px, 960);
}
#[test]
fn insert_after_focused_with_width_middle_column_inserts_at_next_index() {
let layout = three_column_layout();
let config = test_config();
let result = insert_window_after_focused_with_width(
&layout,
Some(WindowId(2)),
WindowId(10),
1200,
&config,
);
assert_eq!(result.columns.len(), 4);
assert_eq!(
result.columns[0].rows[0].window_id,
WindowId(1),
"unchanged"
);
assert_eq!(
result.columns[1].rows[0].window_id,
WindowId(2),
"unchanged"
);
assert_eq!(
result.columns[2].rows[0].window_id,
WindowId(10),
"new column"
);
assert_eq!(result.columns[2].width_px, 1200);
assert_eq!(
result.columns[3].rows[0].window_id,
WindowId(3),
"shifted right"
);
}
#[test]
fn insert_after_focused_with_width_last_column_appends() {
let layout = three_column_layout();
let config = test_config();
let result = insert_window_after_focused_with_width(
&layout,
Some(WindowId(3)),
WindowId(10),
1500,
&config,
);
assert_eq!(result.columns.len(), 4);
assert_eq!(result.columns[3].rows[0].window_id, WindowId(10));
assert_eq!(result.columns[3].width_px, 1500);
}
#[test]
fn insert_after_focused_with_width_stale_focus_appends() {
let layout = three_column_layout();
let config = test_config();
let result = insert_window_after_focused_with_width(
&layout,
Some(WindowId(99)),
WindowId(10),
1500,
&config,
);
assert_eq!(result.columns.len(), 4);
assert_eq!(result.columns[3].rows[0].window_id, WindowId(10));
assert_eq!(result.columns[3].width_px, 1500);
}
#[test]
fn insert_after_focused_with_width_no_focus_appends_to_empty_layout() {
let layout = VirtualLayout::new();
let config = test_config();
let result =
insert_window_after_focused_with_width(&layout, None, WindowId(1), 1500, &config);
assert_eq!(result.columns.len(), 1);
assert_eq!(result.columns[0].rows[0].window_id, WindowId(1));
assert_eq!(
result.columns[0].width_px, 1500,
"None-focus path must still honor the explicit width"
);
}
#[test]
fn insert_after_focused_with_width_clamps_via_caller_quantize() {
let layout = three_column_layout();
let config = test_config();
let quantized = quantize_to_ladder(1500, &config); let result = insert_window_after_focused_with_width(
&layout,
Some(WindowId(1)),
WindowId(10),
quantized,
&config,
);
assert_eq!(
result.columns[1].width_px, quantized,
"quantized width passes through insert unchanged"
);
}
#[test]
fn merge_column_right_into_single_row_column_removes_source() {
let layout = three_column_layout(); let config = test_config();
let result = merge_column(&layout, WindowId(1), Direction::Right, &config).unwrap();
assert_eq!(result.columns.len(), 2, "source column removed");
assert_eq!(
result.columns[0]
.rows
.iter()
.map(|r| r.window_id)
.collect::<Vec<_>>(),
vec![WindowId(2), WindowId(1)],
"W1 appended to bottom of dst"
);
assert_eq!(result.columns[0].rows[0].height, 534);
assert_eq!(result.columns[0].rows[1].height, 534);
}
#[test]
fn merge_column_left_into_single_row_column_removes_source() {
let layout = three_column_layout(); let config = test_config();
let result = merge_column(&layout, WindowId(2), Direction::Left, &config).unwrap();
assert_eq!(result.columns.len(), 2, "source column removed");
assert_eq!(
result.columns[0]
.rows
.iter()
.map(|r| r.window_id)
.collect::<Vec<_>>(),
vec![WindowId(1), WindowId(2)],
"W2 appended to bottom of dst"
);
assert_eq!(result.columns[0].rows[0].height, 534);
assert_eq!(result.columns[0].rows[1].height, 534);
}
#[test]
fn merge_column_into_multi_row_column_appends_at_bottom() {
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_rows(
960,
vec![Row::new(WindowId(2), 0), Row::new(WindowId(3), 0)],
),
],
0,
);
let config = test_config();
let result = merge_column(&layout, WindowId(1), Direction::Right, &config).unwrap();
assert_eq!(result.columns.len(), 1, "source column removed");
assert_eq!(
result.columns[0]
.rows
.iter()
.map(|r| r.window_id)
.collect::<Vec<_>>(),
vec![WindowId(2), WindowId(3), WindowId(1)],
"W1 appended to bottom of multi-row dst"
);
assert_eq!(result.columns[0].rows[0].height, 355);
assert_eq!(result.columns[0].rows[1].height, 355);
assert_eq!(result.columns[0].rows[2].height, 354);
}
#[test]
fn merge_column_source_keeps_remaining_rows_redistributed() {
let layout = VirtualLayout::with_columns(
vec![
Column::with_rows(
960,
vec![Row::new(WindowId(1), 0), Row::new(WindowId(2), 0)],
),
Column::with_row(960, Row::new(WindowId(3), 0)),
],
0,
);
let config = test_config();
let result = merge_column(&layout, WindowId(1), Direction::Right, &config).unwrap();
assert_eq!(result.columns.len(), 2, "source column kept");
assert_eq!(
result.columns[0]
.rows
.iter()
.map(|r| r.window_id)
.collect::<Vec<_>>(),
vec![WindowId(2)],
"src still has W2"
);
assert_eq!(result.columns[0].rows[0].height, 1072);
assert_eq!(
result.columns[1]
.rows
.iter()
.map(|r| r.window_id)
.collect::<Vec<_>>(),
vec![WindowId(3), WindowId(1)],
"dst has W3 then W1"
);
assert_eq!(result.columns[1].rows[0].height, 534);
assert_eq!(result.columns[1].rows[1].height, 534);
}
#[test]
fn merge_column_no_neighbor_left_returns_none() {
let layout = three_column_layout();
let config = test_config();
assert!(merge_column(&layout, WindowId(1), Direction::Left, &config).is_none());
}
#[test]
fn merge_column_no_neighbor_right_returns_none() {
let layout = three_column_layout();
let config = test_config();
assert!(merge_column(&layout, WindowId(3), Direction::Right, &config).is_none());
}
#[test]
fn merge_column_vertical_direction_rejected() {
let layout = three_column_layout();
let config = test_config();
assert!(merge_column(&layout, WindowId(2), Direction::Up, &config).is_none());
assert!(merge_column(&layout, WindowId(2), Direction::Down, &config).is_none());
}
#[test]
fn merge_column_unknown_focused_returns_none() {
let layout = three_column_layout();
let config = test_config();
assert!(merge_column(&layout, WindowId(99), Direction::Right, &config).is_none());
}
#[test]
fn merge_column_rejects_when_min_height_violated() {
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_rows(
960,
vec![Row::new(WindowId(2), 0), Row::new(WindowId(3), 0)],
),
],
0,
);
let config = MutationConfig {
min_window_height_px: 400,
..test_config()
};
assert!(merge_column(&layout, WindowId(1), Direction::Right, &config).is_none());
}
#[test]
fn merge_column_accepts_at_min_height_boundary() {
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_rows(
960,
vec![Row::new(WindowId(2), 0), Row::new(WindowId(3), 0)],
),
],
0,
);
let config = MutationConfig {
min_window_height_px: 354,
..test_config()
};
let result = merge_column(&layout, WindowId(1), Direction::Right, &config);
assert!(
result.is_some(),
"merge must succeed when min height exactly equals floor (354 == 354)"
);
let result = result.unwrap();
assert_eq!(result.columns[0].rows[0].height, 355);
assert_eq!(result.columns[0].rows[1].height, 355);
assert_eq!(result.columns[0].rows[2].height, 354);
}
#[test]
fn merge_column_rejects_borderline_min_height_violation() {
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_rows(
960,
vec![Row::new(WindowId(2), 0), Row::new(WindowId(3), 0)],
),
],
0,
);
let config = MutationConfig {
min_window_height_px: 355,
..test_config()
};
assert!(
merge_column(&layout, WindowId(1), Direction::Right, &config).is_none(),
"merge must fail when shortest row (354) is 1px below floor (355)"
);
}
#[test]
fn merge_column_left_with_multi_row_source_and_single_row_dst() {
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_rows(
960,
vec![Row::new(WindowId(2), 0), Row::new(WindowId(3), 0)],
),
],
0,
);
let config = test_config();
let result = merge_column(&layout, WindowId(3), Direction::Left, &config).unwrap();
assert_eq!(result.columns.len(), 2, "source column kept");
assert_eq!(
result.columns[0]
.rows
.iter()
.map(|r| r.window_id)
.collect::<Vec<_>>(),
vec![WindowId(1), WindowId(3)],
"W3 appended to bottom of dst"
);
assert_eq!(result.columns[0].rows[0].height, 534);
assert_eq!(result.columns[0].rows[1].height, 534);
assert_eq!(
result.columns[1]
.rows
.iter()
.map(|r| r.window_id)
.collect::<Vec<_>>(),
vec![WindowId(2)],
"src still has W2 after W3 was removed"
);
assert_eq!(result.columns[1].rows[0].height, 1072);
}
#[test]
fn promote_right_from_two_row_column_splits_into_two_columns() {
let layout = VirtualLayout::with_columns(
vec![Column::with_rows(
960,
vec![Row::new(WindowId(1), 0), Row::new(WindowId(2), 0)],
)],
0,
);
let config = test_config();
let result = promote_window(&layout, WindowId(1), Direction::Right, &config).unwrap();
assert_eq!(result.columns.len(), 2, "split into two columns");
assert_eq!(
result.columns[0]
.rows
.iter()
.map(|r| r.window_id)
.collect::<Vec<_>>(),
vec![WindowId(2)],
"src keeps the non-promoted window"
);
assert_eq!(result.columns[0].rows[0].height, 1072);
assert_eq!(
result.columns[1]
.rows
.iter()
.map(|r| r.window_id)
.collect::<Vec<_>>(),
vec![WindowId(1)],
"new column has the promoted window"
);
assert_eq!(result.columns[1].rows[0].height, 1072);
}
#[test]
fn promote_left_from_two_row_column_splits_into_two_columns() {
let layout = VirtualLayout::with_columns(
vec![Column::with_rows(
960,
vec![Row::new(WindowId(1), 0), Row::new(WindowId(2), 0)],
)],
0,
);
let config = test_config();
let result = promote_window(&layout, WindowId(2), Direction::Left, &config).unwrap();
assert_eq!(result.columns.len(), 2, "split into two columns");
assert_eq!(
result.columns[0]
.rows
.iter()
.map(|r| r.window_id)
.collect::<Vec<_>>(),
vec![WindowId(2)],
"promoted window is in the new leftmost column"
);
assert_eq!(
result.columns[1]
.rows
.iter()
.map(|r| r.window_id)
.collect::<Vec<_>>(),
vec![WindowId(1)],
"src column shifted right by one"
);
}
#[test]
fn promote_middle_row_redistributes_remaining() {
let layout = VirtualLayout::with_columns(
vec![Column::with_rows(
960,
vec![
Row::new(WindowId(1), 0),
Row::new(WindowId(2), 0),
Row::new(WindowId(3), 0),
],
)],
0,
);
let config = test_config();
let result = promote_window(&layout, WindowId(2), Direction::Right, &config).unwrap();
assert_eq!(result.columns.len(), 2, "split into two columns");
assert_eq!(
result.columns[0]
.rows
.iter()
.map(|r| r.window_id)
.collect::<Vec<_>>(),
vec![WindowId(1), WindowId(3)],
"W2 extracted; W1 and W3 remain in src in original order"
);
assert_eq!(result.columns[0].rows[0].height, 534);
assert_eq!(result.columns[0].rows[1].height, 534);
assert_eq!(
result.columns[1]
.rows
.iter()
.map(|r| r.window_id)
.collect::<Vec<_>>(),
vec![WindowId(2)],
"new column has the promoted window"
);
assert_eq!(result.columns[1].rows[0].height, 1072);
}
#[test]
fn promote_from_four_row_column_distributes_remainder_to_top() {
let layout = VirtualLayout::with_columns(
vec![Column::with_rows(
960,
vec![
Row::new(WindowId(1), 0),
Row::new(WindowId(2), 0),
Row::new(WindowId(3), 0),
Row::new(WindowId(4), 0),
],
)],
0,
);
let config = test_config();
let result = promote_window(&layout, WindowId(2), Direction::Right, &config).unwrap();
assert_eq!(result.columns.len(), 2, "split into two columns");
assert_eq!(
result.columns[0]
.rows
.iter()
.map(|r| r.window_id)
.collect::<Vec<_>>(),
vec![WindowId(1), WindowId(3), WindowId(4)],
"W2 extracted; remaining rows keep their relative order"
);
assert_eq!(result.columns[0].rows[0].height, 355);
assert_eq!(result.columns[0].rows[1].height, 355);
assert_eq!(result.columns[0].rows[2].height, 354);
assert_eq!(result.columns[1].rows[0].window_id, WindowId(2));
assert_eq!(result.columns[1].rows[0].height, 1072);
}
#[test]
fn promote_single_row_column_is_no_op() {
let layout =
VirtualLayout::with_columns(vec![Column::with_row(960, Row::new(WindowId(1), 0))], 0);
let config = test_config();
assert!(promote_window(&layout, WindowId(1), Direction::Right, &config).is_none());
assert!(promote_window(&layout, WindowId(1), Direction::Left, &config).is_none());
}
#[test]
fn promote_vertical_direction_rejected() {
let layout = VirtualLayout::with_columns(
vec![Column::with_rows(
960,
vec![Row::new(WindowId(1), 0), Row::new(WindowId(2), 0)],
)],
0,
);
let config = test_config();
assert!(promote_window(&layout, WindowId(1), Direction::Up, &config).is_none());
assert!(promote_window(&layout, WindowId(1), Direction::Down, &config).is_none());
}
#[test]
fn promote_unknown_focused_returns_none() {
let layout = three_column_layout();
let config = test_config();
assert!(promote_window(&layout, WindowId(99), Direction::Right, &config).is_none());
}
#[test]
fn promote_right_from_three_columns_inserts_after_src() {
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_rows(
960,
vec![Row::new(WindowId(2), 0), Row::new(WindowId(3), 0)],
),
Column::with_row(960, Row::new(WindowId(4), 0)),
],
0,
);
let config = test_config();
let result = promote_window(&layout, WindowId(2), Direction::Right, &config).unwrap();
assert_eq!(result.columns.len(), 4);
assert_eq!(result.columns[0].rows[0].window_id, WindowId(1));
assert_eq!(
result.columns[1]
.rows
.iter()
.map(|r| r.window_id)
.collect::<Vec<_>>(),
vec![WindowId(3)],
"src column now has only W3"
);
assert_eq!(
result.columns[2].rows[0].window_id,
WindowId(2),
"promoted window inserted as its own column right after src"
);
assert_eq!(result.columns[3].rows[0].window_id, WindowId(4));
}
}