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.
This commit is contained in:
1 parent
ab11269246
commit
ad00e55fe4
35 files changed
+3454
-512
No files matched your search
@@ -6,3 +6,356 @@ 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user