Files
feresaria/crates/ferese-layout/src/lib.rs
T
Samuel Onoja ad00e55fe4 Add mouse window controls and finer display scaling (#63)
Add configurable mouse and wheel bindings, window move and resize, tab drops, cross-output dragging, and device settings. Adjust display scaling in 0.05 increments.

Preserve opposite-edge positioning during scrolling resizes and show rows for edge drops into tabbed columns.
2026-10-25 14:14:35 +01:00

362 lines
13 KiB
Rust

//! Tree and scrolling layouts share window identities, constraints and geometry.
mod layout_tree;
mod scrolling;
pub use layout_tree::*;
pub use scrolling::{
Column, ColumnDisplay, ColumnWidth, ScrollingLayout, ViewportFocusStrategy, ViewportTarget,
};
/// The destination of an interactive tiled-window drop.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DropPlacement {
Left,
Right,
Above,
Below,
Tab,
}
#[cfg(test)]
mod pointer_tests {
use super::*;
#[test]
fn pointer_tree_drop_preserves_windows_and_is_atomic_for_unknown_targets() {
for placement in [
DropPlacement::Left,
DropPlacement::Right,
DropPlacement::Above,
DropPlacement::Below,
DropPlacement::Tab,
] {
let mut tree = LayoutTree::default();
for id in 1..=4 {
tree.insert(WindowId(id), None, Axis::Horizontal, 0.5)
.unwrap();
}
tree.place_window(WindowId(1), WindowId(3), placement)
.unwrap();
tree.validate().unwrap();
assert_eq!(tree.window_ids().count(), 4);
let before = tree.clone();
assert!(
tree.place_window(WindowId(1), WindowId(99), placement)
.is_err()
);
assert_eq!(tree, before);
tree.place_window(WindowId(1), WindowId(1), placement)
.unwrap();
assert_eq!(tree, before);
}
}
#[test]
fn pointer_scrolling_drop_preserves_window_and_row_invariants() {
for placement in [
DropPlacement::Left,
DropPlacement::Right,
DropPlacement::Above,
DropPlacement::Below,
DropPlacement::Tab,
] {
let mut layout = ScrollingLayout::default();
for id in 1..=4 {
layout.insert(WindowId(id), None).unwrap();
}
layout
.place_window(WindowId(1), WindowId(3), placement)
.unwrap();
layout.validate().unwrap();
assert_eq!(layout.window_ids().count(), 4);
let column = layout
.columns()
.iter()
.find(|column| column.windows.contains(&WindowId(1)))
.unwrap();
if placement == DropPlacement::Tab {
assert_eq!(column.display, ColumnDisplay::Tabbed);
assert_eq!(column.windows.len(), 2);
}
let before = layout.clone();
assert!(
layout
.place_window(WindowId(1), WindowId(99), placement)
.is_err()
);
assert_eq!(layout, before);
}
}
#[test]
fn pointer_tree_resize_moves_the_selected_boundary_in_both_directions() {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5)
.unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
let bounds = Rect::new(-1000.0, -500.0, 1000.0, 600.0);
let before = tree.geometry(bounds).unwrap();
assert!(
tree.resize_edge(WindowId(1), Direction::Right, 100.0, bounds)
.unwrap()
);
let after = tree.geometry(bounds).unwrap();
assert_eq!(
after[&WindowId(1)].width,
before[&WindowId(1)].width + 100.0
);
assert_eq!(
after[&WindowId(2)].width,
before[&WindowId(2)].width - 100.0
);
assert!(
tree.resize_edge(WindowId(2), Direction::Left, -100.0, bounds)
.unwrap()
);
assert_eq!(tree.geometry(bounds).unwrap(), before);
assert!(
!tree
.resize_edge(WindowId(1), Direction::Left, 100.0, bounds)
.unwrap()
);
assert!(
!tree
.resize_edge(WindowId(1), Direction::Right, f64::NAN, bounds)
.unwrap()
);
}
}
#[cfg(test)]
mod pointer_sequence_tests {
use super::*;
#[test]
fn repeated_pointer_drops_and_resizes_keep_tabbed_layouts_valid() {
let mut tree = LayoutTree::default();
let mut scrolling = ScrollingLayout::default();
for id in 1..=8 {
tree.insert(WindowId(id), None, Axis::Horizontal, 0.5)
.unwrap();
scrolling.insert(WindowId(id), None).unwrap();
}
let bounds = Rect::new(-1200.0, -300.0, 1200.0, 800.0);
let mut seed = 42_u64;
for _ in 0..1000 {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
let source = WindowId(1 + (seed >> 32) % 8);
let target = WindowId(1 + (seed >> 40) % 8);
let placement = [
DropPlacement::Left,
DropPlacement::Right,
DropPlacement::Above,
DropPlacement::Below,
DropPlacement::Tab,
][(seed >> 48) as usize % 5];
tree.place_window(source, target, placement).unwrap();
scrolling.place_window(source, target, placement).unwrap();
let edge = [
Direction::Left,
Direction::Right,
Direction::Up,
Direction::Down,
][(seed >> 52) as usize % 4];
let delta = (seed >> 16) as i16 as f64;
tree.resize_edge(source, edge, delta, bounds).unwrap();
scrolling.resize_edge(source, edge, delta, bounds).unwrap();
tree.validate().unwrap();
scrolling.validate().unwrap();
assert_eq!(tree.window_ids().count(), 8);
assert_eq!(scrolling.window_ids().count(), 8);
for id in 1..=8 {
assert!(tree.contains(WindowId(id)));
assert!(scrolling.contains(WindowId(id)));
}
}
}
}
#[cfg(test)]
mod pointer_regression_tests {
use super::*;
use std::collections::HashMap;
#[test]
fn edge_drop_on_tabbed_column_shows_rows_and_center_drop_keeps_tabs() {
for placement in [
DropPlacement::Above,
DropPlacement::Below,
DropPlacement::Tab,
] {
let mut layout = ScrollingLayout::default();
for id in 1..=3 {
layout.insert(WindowId(id), None).unwrap();
}
layout
.place_window(WindowId(1), WindowId(2), DropPlacement::Tab)
.unwrap();
layout
.place_window(WindowId(3), WindowId(2), placement)
.unwrap();
let result = layout
.resolve_geometry_with_constraints(
Rect::new(0.0, 0.0, 1200.0, 800.0),
GapConfig::default(),
&HashMap::new(),
Some(WindowId(3)),
)
.unwrap();
layout.validate().unwrap();
assert_eq!(layout.window_ids().count(), 3);
if placement == DropPlacement::Tab {
assert_eq!(result.geometry.len(), 1);
assert!(result.geometry.contains_key(&WindowId(3)));
assert_eq!(layout.tab_group(WindowId(3)).unwrap().windows.len(), 3);
} else {
assert_eq!(result.geometry.len(), 3);
let dropped = result.geometry[&WindowId(3)];
let target = result.geometry[&WindowId(2)];
assert_eq!(dropped.y < target.y, placement == DropPlacement::Above);
assert!(layout.tab_group(WindowId(3)).is_none());
}
}
}
#[test]
fn left_resize_should_move_left_edge_and_preserve_right_edge() {
let mut layout = ScrollingLayout::default();
layout.insert(WindowId(1), None).unwrap();
let bounds = Rect::new(0.0, 0.0, 1200.0, 800.0);
let before = layout
.resolve_geometry_with_constraints(
bounds,
GapConfig::default(),
&HashMap::new(),
Some(WindowId(1)),
)
.unwrap()
.geometry[&WindowId(1)];
layout
.resize_edge(WindowId(1), Direction::Left, 100.0, bounds)
.unwrap();
let after = layout
.resolve_geometry_with_constraints(
bounds,
GapConfig::default(),
&HashMap::new(),
Some(WindowId(1)),
)
.unwrap()
.geometry[&WindowId(1)];
assert_eq!(
after.x + after.width,
before.x + before.width,
"left-edge drag changed the right edge: {before:?} -> {after:?}"
);
}
#[test]
fn horizontal_resize_anchors_survive_limits_focus_policies_and_held_buffers() {
let bounds = Rect::new(-1200.0, -300.0, 1200.0, 800.0);
for strategy in [
ViewportFocusStrategy::Minimal,
ViewportFocusStrategy::Center,
ViewportFocusStrategy::Paged,
] {
for tabbed in [false, true] {
for constraint in [
SizeConstraints::default(),
SizeConstraints {
min_width: 700.0,
..Default::default()
},
SizeConstraints {
max_width: Some(250.0),
..Default::default()
},
] {
let mut base = ScrollingLayout::default();
base.set_focus_strategy(strategy);
for id in 1..=3 {
base.insert(WindowId(id), None).unwrap();
}
if tabbed {
base.place_window(WindowId(3), WindowId(2), DropPlacement::Tab)
.unwrap();
}
let window = WindowId(2);
let constraints =
HashMap::from([(if tabbed { WindowId(3) } else { window }, constraint)]);
let before = base
.resolve_geometry_with_constraints(
bounds,
GapConfig::default(),
&constraints,
Some(window),
)
.unwrap()
.geometry[&window];
let initial_viewport = base.viewport_x();
let initial_width = base.allocated_column_width(window).unwrap();
for direction in [Direction::Left, Direction::Right] {
for delta in [-1000.0, -100.0, 100.0, 1000.0] {
let mut layout = base.clone();
layout
.resize_edge(window, direction, delta, bounds)
.unwrap();
let after = layout
.resolve_geometry_with_constraints(
bounds,
GapConfig::default(),
&constraints,
Some(window),
)
.unwrap()
.geometry[&window];
let edge = |rect: Rect| {
if direction == Direction::Left {
rect.x + rect.width
} else {
rect.x
}
};
assert!(
(edge(after) - edge(before)).abs() < 0.001,
"{direction:?} {delta} {strategy:?} {constraint:?}: {before:?} -> {after:?}"
);
let target = layout.viewport_target().unwrap();
let held = target.with_held_widths(
&[(
window,
initial_width,
layout.allocated_column_width(window).unwrap(),
)],
None,
);
assert!(
(held - initial_viewport).abs() < 0.001,
"pending buffers must retain the old viewport"
);
let settled = layout.clone();
layout
.resolve_geometry_with_constraints(
bounds,
GapConfig::default(),
&constraints,
Some(window),
)
.unwrap();
assert_eq!(
layout, settled,
"an idle relayout must not reapply the resize"
);
layout.validate().unwrap();
}
}
}
}
}
}
}