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.
362 lines
13 KiB
Rust
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();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|