use crate::common::Rect;
use crate::layout::types::{Column, VirtualLayout};
use super::mutations::MutationConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ResizeEdge {
#[default]
None,
Left,
Right,
}
impl ResizeEdge {
#[must_use]
pub fn neighbor_offset(self) -> i32 {
match self {
ResizeEdge::Right => 1,
ResizeEdge::Left => -1,
ResizeEdge::None => 0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum VerticalEdge {
#[default]
None,
Top,
Bottom,
}
impl VerticalEdge {
#[must_use]
pub fn neighbor_offset(self) -> i32 {
match self {
VerticalEdge::Bottom => 1,
VerticalEdge::Top => -1,
VerticalEdge::None => 0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DragKind {
None,
Translate,
Resize {
horizontal: ResizeEdge,
vertical: VerticalEdge,
},
}
#[must_use]
pub fn classify_drag(start: Rect, current: Rect) -> DragKind {
let dw = current.width - start.width;
let dh = current.height - start.height;
let dx_left = current.x - start.x;
let dx_right = current.right() - start.right();
if dw == 0 && dh == 0 {
if dx_left == 0 && dx_right == 0 {
return DragKind::None;
}
return DragKind::Translate;
}
let horizontal = if dw == 0 {
ResizeEdge::None
} else if dx_left != 0 && dx_right == 0 {
ResizeEdge::Left
} else if dx_right != 0 && dx_left == 0 {
ResizeEdge::Right
} else if dx_left.abs() >= dx_right.abs() {
ResizeEdge::Left
} else {
ResizeEdge::Right
};
let dy_top = current.y - start.y;
let dy_bottom = current.bottom() - start.bottom();
let vertical = if dh == 0 {
VerticalEdge::None
} else if dy_top != 0 && dy_bottom == 0 {
VerticalEdge::Top
} else if dy_bottom != 0 && dy_top == 0 {
VerticalEdge::Bottom
} else if dy_top.abs() >= dy_bottom.abs() {
VerticalEdge::Top
} else {
VerticalEdge::Bottom
};
DragKind::Resize {
horizontal,
vertical,
}
}
#[must_use]
pub fn resize_column_boundary_move(
layout: &VirtualLayout,
col: usize,
grip: ResizeEdge,
target_width: i32,
config: &MutationConfig,
) -> Option<VirtualLayout> {
let column = layout.columns.get(col)?;
let neighbor_offset = grip.neighbor_offset();
if neighbor_offset == 0 {
return None;
}
let min = config.min_column_width_px as i32;
let max = config.abs_max_width;
let clamped_target = target_width.clamp(min, max);
let old_width = column.width_px;
if clamped_target == old_width {
return None;
}
let delta = clamped_target - old_width;
let mut new_layout = layout.clone();
new_layout.columns[col].width_px = clamped_target;
let neighbor_idx = (col as i32 + neighbor_offset) as usize;
if neighbor_idx < new_layout.columns.len() {
let neighbor_old = new_layout.columns[neighbor_idx].width_px;
let neighbor_new = (neighbor_old - delta).clamp(min, max);
new_layout.columns[neighbor_idx].width_px = neighbor_new;
}
Some(new_layout)
}
#[must_use]
pub fn derived_max_row_height(column: &Column, config: &MutationConfig) -> i32 {
let n = column.rows.len() as i32;
let gap = config.padding.window_gap;
let min = config.min_row_height_px as i32;
let total_content = config.available_height() - (n + 1) * gap;
(total_content - (n - 1) * min).max(min)
}
#[must_use]
pub fn resize_row_boundary_move(
layout: &VirtualLayout,
col: usize,
row: usize,
grip: VerticalEdge,
target_height: i32,
config: &MutationConfig,
) -> Option<VirtualLayout> {
let column = layout.columns.get(col)?;
let neighbor_offset = grip.neighbor_offset();
if neighbor_offset == 0 {
return None;
}
if row >= column.rows.len() {
return None;
}
let neighbor_idx = row as i32 + neighbor_offset;
if neighbor_idx < 0 || (neighbor_idx as usize) >= column.rows.len() {
return None;
}
let neighbor_idx = neighbor_idx as usize;
let min = config.min_row_height_px as i32;
let max = derived_max_row_height(column, config);
let clamped_target = target_height.clamp(min, max);
let old_height = column.rows[row].height;
if clamped_target == old_height {
return None;
}
let delta = clamped_target - old_height;
let mut new_layout = layout.clone();
new_layout.columns[col].rows[row].height = clamped_target;
let neighbor_old = new_layout.columns[col].rows[neighbor_idx].height;
let neighbor_new = (neighbor_old - delta).clamp(min, max);
new_layout.columns[col].rows[neighbor_idx].height = neighbor_new;
Some(new_layout)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::common::WindowId;
use crate::layout::mutations::distribute_heights;
use crate::layout::types::{Column, Padding, Row};
fn test_config() -> MutationConfig {
MutationConfig {
monitor_width: 1920,
monitor_height: 1080,
min_window_height_px: 100,
min_row_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 two_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)),
],
0,
)
}
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,
)
}
fn rect(x: i32, y: i32, w: i32, h: i32) -> Rect {
Rect {
x,
y,
width: w,
height: h,
}
}
#[test]
fn classify_no_movement_is_none() {
let r = rect(10, 20, 960, 1080);
assert_eq!(classify_drag(r, r), DragKind::None);
}
#[test]
fn classify_position_only_is_translate() {
let start = rect(10, 20, 960, 1080);
let cur = rect(50, 80, 960, 1080);
assert_eq!(classify_drag(start, cur), DragKind::Translate);
}
#[test]
fn classify_right_edge_grip() {
let start = rect(10, 20, 960, 1080);
let cur = rect(10, 20, 1200, 1080);
assert_eq!(
classify_drag(start, cur),
DragKind::Resize {
horizontal: ResizeEdge::Right,
vertical: VerticalEdge::None,
}
);
}
#[test]
fn classify_left_edge_grip() {
let start = rect(10, 20, 960, 1080);
let cur = rect(-30, 20, 1000, 1080);
assert_eq!(
classify_drag(start, cur),
DragKind::Resize {
horizontal: ResizeEdge::Left,
vertical: VerticalEdge::None,
}
);
}
#[test]
fn classify_left_edge_shrink() {
let start = rect(10, 20, 960, 1080);
let cur = rect(100, 20, 870, 1080);
assert_eq!(
classify_drag(start, cur),
DragKind::Resize {
horizontal: ResizeEdge::Left,
vertical: VerticalEdge::None,
}
);
}
#[test]
fn classify_height_only_resize_is_vertical_bottom() {
let start = rect(10, 20, 960, 1080);
let cur = rect(10, 20, 960, 900);
assert_eq!(
classify_drag(start, cur),
DragKind::Resize {
horizontal: ResizeEdge::None,
vertical: VerticalEdge::Bottom,
}
);
}
#[test]
fn classify_top_edge_grip_is_vertical_top() {
let start = rect(10, 20, 960, 1080);
let cur = rect(10, 200, 960, 900);
assert_eq!(
classify_drag(start, cur),
DragKind::Resize {
horizontal: ResizeEdge::None,
vertical: VerticalEdge::Top,
}
);
}
#[test]
fn classify_corner_top_right() {
let start = rect(10, 20, 960, 1080);
let cur = rect(10, 50, 1200, 1050);
assert_eq!(
classify_drag(start, cur),
DragKind::Resize {
horizontal: ResizeEdge::Right,
vertical: VerticalEdge::Top,
}
);
}
#[test]
fn classify_corner_top_left() {
let start = rect(10, 20, 960, 1080);
let cur = rect(-30, 50, 1000, 1050);
assert_eq!(
classify_drag(start, cur),
DragKind::Resize {
horizontal: ResizeEdge::Left,
vertical: VerticalEdge::Top,
}
);
}
#[test]
fn classify_corner_bottom_right() {
let start = rect(10, 20, 960, 1080);
let cur = rect(10, 20, 1200, 1200);
assert_eq!(
classify_drag(start, cur),
DragKind::Resize {
horizontal: ResizeEdge::Right,
vertical: VerticalEdge::Bottom,
}
);
}
#[test]
fn classify_both_edges_moved_picks_larger_displacement() {
let start = rect(100, 20, 960, 1080);
let cur = rect(140, 20, 1020, 1080);
assert_eq!(
classify_drag(start, cur),
DragKind::Resize {
horizontal: ResizeEdge::Right,
vertical: VerticalEdge::None,
}
);
}
#[test]
fn right_grip_grows_column_and_shrinks_neighbor() {
let layout = two_column_layout();
let cfg = test_config();
let result = resize_column_boundary_move(&layout, 0, ResizeEdge::Right, 1200, &cfg)
.expect("some layout");
assert_eq!(result.columns[0].width_px, 1200);
assert_eq!(result.columns[1].width_px, 720);
}
#[test]
fn left_grip_grows_column_and_shrinks_left_neighbor() {
let layout = two_column_layout();
let cfg = test_config();
let result = resize_column_boundary_move(&layout, 1, ResizeEdge::Left, 1200, &cfg)
.expect("some layout");
assert_eq!(result.columns[1].width_px, 1200);
assert_eq!(result.columns[0].width_px, 720);
}
#[test]
fn grow_past_neighbor_min_grows_canvas() {
let layout = two_column_layout();
let cfg = test_config();
let result = resize_column_boundary_move(&layout, 0, ResizeEdge::Right, 1500, &cfg)
.expect("some layout");
assert_eq!(result.columns[0].width_px, 1500);
assert_eq!(result.columns[1].width_px, 480);
let gap = cfg.padding.window_gap;
let canvas_before = gap + layout.columns.iter().map(|c| c.width_px + gap).sum::<i32>();
let canvas_after = gap + result.columns.iter().map(|c| c.width_px + gap).sum::<i32>();
assert_eq!(canvas_after - canvas_before, 60);
}
#[test]
fn grow_past_neighbor_min_translates_other_columns() {
let layout = three_column_layout();
let cfg = test_config();
let result = resize_column_boundary_move(&layout, 0, ResizeEdge::Right, 1500, &cfg)
.expect("some layout");
let gap = cfg.padding.window_gap;
let col2_before = gap + (960 + gap) + (960 + gap);
let col2_after = gap + (1500 + gap) + (480 + gap);
assert_eq!(col2_after - col2_before, 60);
assert_eq!(result.columns[2].width_px, 960, "col 2 width unchanged");
}
#[test]
fn elastic_pin_clamps_column_at_abs_max() {
let layout = two_column_layout();
let cfg = test_config();
let result = resize_column_boundary_move(&layout, 0, ResizeEdge::Right, 2000, &cfg)
.expect("some layout");
assert_eq!(result.columns[0].width_px, 1912);
assert_eq!(result.columns[1].width_px, 480);
}
#[test]
fn shrink_column_grows_neighbor() {
let layout = two_column_layout();
let cfg = test_config();
let result = resize_column_boundary_move(&layout, 0, ResizeEdge::Right, 700, &cfg)
.expect("some layout");
assert_eq!(result.columns[0].width_px, 700);
assert_eq!(result.columns[1].width_px, 1220);
}
#[test]
fn shrink_grows_neighbor_clamped_at_abs_max() {
let layout = two_column_layout();
let cfg = test_config();
let result = resize_column_boundary_move(&layout, 0, ResizeEdge::Right, 480, &cfg)
.expect("some layout");
assert_eq!(result.columns[0].width_px, 480);
assert_eq!(result.columns[1].width_px, 1440);
}
#[test]
fn target_below_min_clamps_to_min() {
let layout = two_column_layout();
let cfg = test_config();
let result = resize_column_boundary_move(&layout, 0, ResizeEdge::Right, 200, &cfg)
.expect("some layout");
assert_eq!(result.columns[0].width_px, 480);
assert_eq!(result.columns[1].width_px, 1440);
}
#[test]
fn only_column_grows_freely() {
let layout =
VirtualLayout::with_columns(vec![Column::with_row(960, Row::new(WindowId(1), 0))], 0);
let cfg = test_config();
let result = resize_column_boundary_move(&layout, 0, ResizeEdge::Right, 1500, &cfg)
.expect("some layout");
assert_eq!(result.columns.len(), 1);
assert_eq!(result.columns[0].width_px, 1500);
}
#[test]
fn only_column_left_grip_no_neighbor_grows_freely() {
let layout =
VirtualLayout::with_columns(vec![Column::with_row(960, Row::new(WindowId(1), 0))], 0);
let cfg = test_config();
let result = resize_column_boundary_move(&layout, 0, ResizeEdge::Left, 1500, &cfg)
.expect("some layout");
assert_eq!(result.columns[0].width_px, 1500);
}
#[test]
fn no_change_returns_none() {
let layout = two_column_layout();
let cfg = test_config();
assert!(resize_column_boundary_move(&layout, 0, ResizeEdge::Right, 960, &cfg).is_none());
}
#[test]
fn clamped_target_equal_current_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,
);
let cfg = test_config();
assert!(resize_column_boundary_move(&layout, 0, ResizeEdge::Right, 2000, &cfg).is_none());
}
#[test]
fn invalid_col_returns_none() {
let layout = two_column_layout();
let cfg = test_config();
assert!(resize_column_boundary_move(&layout, 5, ResizeEdge::Right, 1200, &cfg).is_none());
}
#[test]
fn none_grip_returns_none() {
let layout = two_column_layout();
let cfg = test_config();
assert!(resize_column_boundary_move(&layout, 0, ResizeEdge::None, 1200, &cfg).is_none());
}
#[test]
fn viewport_offset_preserved() {
let layout = VirtualLayout::with_columns(
vec![
Column::with_row(960, Row::new(WindowId(1), 0)),
Column::with_row(960, Row::new(WindowId(2), 0)),
],
1234,
);
let cfg = test_config();
let result = resize_column_boundary_move(&layout, 0, ResizeEdge::Right, 1200, &cfg)
.expect("some layout");
assert_eq!(result.viewport_offset, 1234);
}
#[test]
fn neighbor_offset_maps_edges() {
assert_eq!(ResizeEdge::Right.neighbor_offset(), 1);
assert_eq!(ResizeEdge::Left.neighbor_offset(), -1);
assert_eq!(ResizeEdge::None.neighbor_offset(), 0);
}
#[test]
fn vertical_neighbor_offset_maps_edges() {
assert_eq!(VerticalEdge::Bottom.neighbor_offset(), 1);
assert_eq!(VerticalEdge::Top.neighbor_offset(), -1);
assert_eq!(VerticalEdge::None.neighbor_offset(), 0);
}
fn two_row_column() -> Column {
let h = distribute_heights(2, 1080, 4);
Column::with_rows(
960,
vec![Row::new(WindowId(1), h[0]), Row::new(WindowId(2), h[1])],
)
}
fn three_row_column() -> Column {
let h = distribute_heights(3, 1080, 4);
Column::with_rows(
960,
vec![
Row::new(WindowId(1), h[0]),
Row::new(WindowId(2), h[1]),
Row::new(WindowId(3), h[2]),
],
)
}
#[test]
fn derived_max_two_rows() {
let cfg = test_config();
assert_eq!(derived_max_row_height(&two_row_column(), &cfg), 968);
}
#[test]
fn derived_max_three_rows() {
let cfg = test_config();
assert_eq!(derived_max_row_height(&three_row_column(), &cfg), 864);
}
#[test]
fn derived_max_single_row_is_full_content() {
let col = Column::with_row(960, Row::new(WindowId(1), 1072));
let cfg = test_config();
assert_eq!(derived_max_row_height(&col, &cfg), 1072);
}
#[test]
fn derived_max_floors_at_min_for_overstuffed_column() {
let cfg = test_config();
let rows: Vec<Row> = (0..40).map(|i| Row::new(WindowId(i), 100)).collect();
let col = Column::with_rows(960, rows);
assert_eq!(derived_max_row_height(&col, &cfg), 100);
}
fn two_row_layout() -> VirtualLayout {
VirtualLayout::with_columns(vec![two_row_column()], 0)
}
fn three_row_layout() -> VirtualLayout {
VirtualLayout::with_columns(vec![three_row_column()], 0)
}
#[test]
fn bottom_grip_grows_row_and_shrinks_below_neighbor() {
let layout = two_row_layout();
let cfg = test_config();
let result = resize_row_boundary_move(&layout, 0, 0, VerticalEdge::Bottom, 700, &cfg)
.expect("some layout");
assert_eq!(result.columns[0].rows[0].height, 700);
assert_eq!(result.columns[0].rows[1].height, 368);
let before: i32 = layout.columns[0].rows.iter().map(|r| r.height).sum();
let after: i32 = result.columns[0].rows.iter().map(|r| r.height).sum();
assert_eq!(after, before);
}
#[test]
fn top_grip_grows_row_and_shrinks_above_neighbor() {
let layout = two_row_layout();
let cfg = test_config();
let result = resize_row_boundary_move(&layout, 0, 1, VerticalEdge::Top, 700, &cfg)
.expect("some layout");
assert_eq!(result.columns[0].rows[1].height, 700);
assert_eq!(result.columns[0].rows[0].height, 368);
}
#[test]
fn shrink_row_grows_neighbor() {
let layout = two_row_layout();
let cfg = test_config();
let result = resize_row_boundary_move(&layout, 0, 0, VerticalEdge::Bottom, 300, &cfg)
.expect("some layout");
assert_eq!(result.columns[0].rows[0].height, 300);
assert_eq!(result.columns[0].rows[1].height, 768);
}
#[test]
fn elastic_pin_clamps_row_at_derived_max() {
let layout = two_row_layout();
let cfg = test_config();
let result = resize_row_boundary_move(&layout, 0, 0, VerticalEdge::Bottom, 1500, &cfg)
.expect("some layout");
assert_eq!(result.columns[0].rows[0].height, 968);
assert_eq!(result.columns[0].rows[1].height, 100);
}
#[test]
fn elastic_pin_snap_back_is_no_op_at_ceiling() {
let col = Column::with_rows(
960,
vec![Row::new(WindowId(1), 968), Row::new(WindowId(2), 100)],
);
let layout = VirtualLayout::with_columns(vec![col], 0);
let cfg = test_config();
assert!(
resize_row_boundary_move(&layout, 0, 0, VerticalEdge::Bottom, 1500, &cfg).is_none()
);
}
#[test]
fn row_target_below_min_clamps_to_min() {
let layout = two_row_layout();
let cfg = test_config();
let result = resize_row_boundary_move(&layout, 0, 0, VerticalEdge::Bottom, 50, &cfg)
.expect("some layout");
assert_eq!(result.columns[0].rows[0].height, 100);
assert_eq!(result.columns[0].rows[1].height, 968);
}
#[test]
fn only_row_in_column_is_pinned() {
let layout = VirtualLayout::with_columns(
vec![Column::with_row(960, Row::new(WindowId(1), 1072))],
0,
);
let cfg = test_config();
assert!(resize_row_boundary_move(&layout, 0, 0, VerticalEdge::Bottom, 800, &cfg).is_none());
assert!(resize_row_boundary_move(&layout, 0, 0, VerticalEdge::Top, 800, &cfg).is_none());
}
#[test]
fn grip_facing_column_edge_is_pinned() {
let layout = two_row_layout();
let cfg = test_config();
assert!(resize_row_boundary_move(&layout, 0, 0, VerticalEdge::Top, 700, &cfg).is_none());
assert!(resize_row_boundary_move(&layout, 0, 1, VerticalEdge::Bottom, 700, &cfg).is_none());
}
#[test]
fn three_row_interior_boundary_only_moves_one_neighbor() {
let layout = three_row_layout();
let cfg = test_config();
let h_before = layout.columns[0].rows[2].height;
let result = resize_row_boundary_move(&layout, 0, 0, VerticalEdge::Bottom, 600, &cfg)
.expect("some layout");
assert!(result.columns[0].rows[0].height > 354);
assert!(result.columns[0].rows[1].height < 354);
assert_eq!(
result.columns[0].rows[2].height, h_before,
"row 2 untouched"
);
}
#[test]
fn row_resize_preserves_viewport_offset() {
let layout = VirtualLayout::with_columns(vec![two_row_column()], 999);
let cfg = test_config();
let result = resize_row_boundary_move(&layout, 0, 0, VerticalEdge::Bottom, 700, &cfg)
.expect("some layout");
assert_eq!(result.viewport_offset, 999);
}
#[test]
fn row_resize_invalid_indices_return_none() {
let layout = two_row_layout();
let cfg = test_config();
assert!(resize_row_boundary_move(&layout, 5, 0, VerticalEdge::Bottom, 700, &cfg).is_none());
assert!(resize_row_boundary_move(&layout, 0, 9, VerticalEdge::Bottom, 700, &cfg).is_none());
assert!(resize_row_boundary_move(&layout, 0, 0, VerticalEdge::None, 700, &cfg).is_none());
}
#[test]
fn row_resize_no_change_returns_none() {
let layout = two_row_layout();
let cfg = test_config();
let cur = layout.columns[0].rows[0].height;
assert!(resize_row_boundary_move(&layout, 0, 0, VerticalEdge::Bottom, cur, &cfg).is_none());
}
#[test]
fn corner_compose_applies_both_axes_independently() {
let layout = VirtualLayout::with_columns(
vec![
two_row_column(),
Column::with_row(960, Row::new(WindowId(3), 0)),
],
0,
);
let cfg = test_config();
let after_h = resize_column_boundary_move(&layout, 0, ResizeEdge::Right, 1200, &cfg)
.expect("horizontal resize");
assert_eq!(after_h.columns[0].width_px, 1200);
assert_eq!(after_h.columns[1].width_px, 720);
assert_eq!(after_h.columns[0].rows[0].height, 534);
let after_v = resize_row_boundary_move(&after_h, 0, 0, VerticalEdge::Bottom, 700, &cfg)
.expect("vertical resize");
assert_eq!(after_v.columns[0].width_px, 1200, "horizontal width kept");
assert_eq!(after_v.columns[1].width_px, 720, "neighbor width kept");
assert_eq!(
after_v.columns[0].rows[0].height, 700,
"vertical grew row 0"
);
assert_eq!(
after_v.columns[0].rows[1].height, 368,
"vertical shrank row 1"
);
}
#[test]
fn corner_compose_order_does_not_matter() {
let layout = VirtualLayout::with_columns(
vec![
two_row_column(),
Column::with_row(960, Row::new(WindowId(3), 0)),
],
0,
);
let cfg = test_config();
let hv = resize_column_boundary_move(&layout, 0, ResizeEdge::Right, 1200, &cfg)
.and_then(|l| resize_row_boundary_move(&l, 0, 0, VerticalEdge::Bottom, 700, &cfg))
.expect("hv");
let vh = resize_row_boundary_move(&layout, 0, 0, VerticalEdge::Bottom, 700, &cfg)
.and_then(|l| resize_column_boundary_move(&l, 0, ResizeEdge::Right, 1200, &cfg))
.expect("vh");
assert_eq!(hv, vh);
}
}