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:
Samuel Onoja authored and GitHub committed 2026-10-25 14:14:35 +01:00
1 parent ab11269246
commit ad00e55fe4
35 files changed
+3454 -512

No files matched your search

+25
View File
@@ -123,6 +123,31 @@ impl WorkspaceLayout {
}
}
pub fn place_window(
&mut self,
window: WindowId,
target: WindowId,
placement: ferese_layout::DropPlacement,
) -> Result<(), LayoutError> {
match self {
Self::Tree(layout) => layout.place_window(window, target, placement),
Self::Scrolling(layout) => layout.place_window(window, target, placement),
}
}
pub fn resize_edge(
&mut self,
window: WindowId,
edge: Direction,
delta: f64,
bounds: Rect,
) -> Result<bool, LayoutError> {
match self {
Self::Tree(layout) => layout.resize_edge(window, edge, delta, bounds),
Self::Scrolling(layout) => layout.resize_edge(window, edge, delta, bounds),
}
}
pub fn remove(&mut self, window: WindowId) -> Result<(), LayoutError> {
match self {
Self::Scrolling(layout) => layout.remove(window),
+128
View File
@@ -279,6 +279,134 @@ impl LayoutTree {
Ok(window_node)
}
/// Reinsert a window beside a target, or into its tab group.
pub fn place_window(
&mut self,
window: WindowId,
target: WindowId,
placement: crate::DropPlacement,
) -> Result<(), LayoutError> {
use crate::DropPlacement::*;
if !self.contains(window) {
return Err(LayoutError::UnknownWindow(window));
}
if !self.contains(target) {
return Err(LayoutError::UnknownWindow(target));
}
if window == target {
return Ok(());
}
if placement == Tab {
return self.stack_window(window, target);
}
let mut next = self.clone();
next.remove(window)?;
let axis = if matches!(placement, Left | Right) {
Axis::Horizontal
} else {
Axis::Vertical
};
let leaf = next.insert(window, Some(target), axis, 0.5)?;
if matches!(placement, Left | Above) {
let parent = next.parents[&leaf];
if let Some(Node::Split { first, second, .. }) = next.nodes.get_mut(&parent) {
std::mem::swap(first, second);
}
}
next.validate()?;
*self = next;
Ok(())
}
/// Move a selected split edge by a signed logical-pixel distance.
pub fn resize_edge(
&mut self,
window: WindowId,
edge: Direction,
delta: f64,
bounds: Rect,
) -> Result<bool, LayoutError> {
let mut child = *self
.windows
.get(&window)
.ok_or(LayoutError::UnknownWindow(window))?;
if !delta.is_finite() {
return Ok(false);
}
while let Some(parent) = self.parents.get(&child).copied() {
let matches = match self.nodes.get(&parent) {
Some(Node::Split {
axis: Axis::Horizontal,
first,
second,
..
}) => {
(edge == Direction::Right && *first == child)
|| (edge == Direction::Left && *second == child)
}
Some(Node::Split {
axis: Axis::Vertical,
first,
second,
..
}) => {
(edge == Direction::Down && *first == child)
|| (edge == Direction::Up && *second == child)
}
_ => false,
};
if matches {
let mut path = Vec::new();
let mut node = parent;
while let Some(ancestor) = self.parents.get(&node).copied() {
path.push((ancestor, node));
node = ancestor;
}
let mut area = bounds;
for (ancestor, descendant) in path.into_iter().rev() {
if let Some(Node::Split {
axis, ratio, first, ..
}) = self.nodes.get(&ancestor)
{
match axis {
Axis::Horizontal => {
let part = area.width * ratio;
if *first == descendant {
area.width = part;
} else {
area.x += part;
area.width -= part;
}
}
Axis::Vertical => {
let part = area.height * ratio;
if *first == descendant {
area.height = part;
} else {
area.y += part;
area.height -= part;
}
}
}
}
}
if let Some(Node::Split { ratio, .. }) = self.nodes.get_mut(&parent) {
let extent = if matches!(edge, Direction::Left | Direction::Right) {
area.width
} else {
area.height
};
let next = normalized_ratio(*ratio + delta / extent.max(1.0));
let changed = next != *ratio;
*ratio = next;
return Ok(changed);
}
}
child = parent;
}
Ok(false)
}
pub fn remove(&mut self, window: WindowId) -> Result<(), LayoutError> {
let leaf = self
.windows
+353
View File
@@ -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();
}
}
}
}
}
}
}
+251 -18
View File
@@ -121,6 +121,12 @@ enum ViewportRequest {
WidthCycle(usize),
Slide(usize, usize),
Center(usize, Option<f64>),
Resize {
column: usize,
anchor: f64,
anchor_right: bool,
maximum: Option<f64>,
},
}
impl ViewportTarget {
@@ -162,6 +168,18 @@ impl ViewportTarget {
});
layout.viewport_x = positions[active].0 + width / 2.0 - self.viewport_width / 2.0
}
ViewportRequest::Resize {
column,
anchor,
anchor_right,
maximum,
} => {
let width = maximum.map_or(positions[column].1, |maximum| {
positions[column].1.min(maximum)
});
layout.viewport_x =
positions[column].0 + if anchor_right { width } else { 0.0 } - anchor;
}
}
layout.viewport_x
}
@@ -204,6 +222,14 @@ enum PendingViewportRequest {
// Width cycling takes precedence. Keep the focus request only in case a
// subsequent no-op cycle cancels the width request before layout resolves.
WidthCycle(Option<PendingFocusRequest>),
// Resolve the opposite edge from the previous strip position and allocation
// after applying client limits. Negative offsets keep the first column anchored.
Resize {
window: WindowId,
origin: f64,
width: f64,
anchor_right: bool,
},
}
#[derive(Clone, Debug, PartialEq)]
@@ -242,11 +268,18 @@ impl ScrollingLayout {
match self.pending_viewport {
Some(PendingViewportRequest::Focus(request)) => Some(request),
Some(PendingViewportRequest::WidthCycle(request)) => request,
None => None,
None | Some(PendingViewportRequest::Resize { .. }) => None,
}
}
fn request_focus(&mut self, request: Option<PendingFocusRequest>) {
// Direct manipulation keeps its edge anchor through the next solve.
if matches!(
self.pending_viewport,
Some(PendingViewportRequest::Resize { .. })
) {
return;
}
self.pending_viewport = if matches!(
self.pending_viewport,
Some(PendingViewportRequest::WidthCycle(_))
@@ -371,6 +404,37 @@ impl ScrollingLayout {
self.viewport_x
}
/// Pan without changing focus, keeping the viewport inside the column strip.
pub fn scroll_viewport(&mut self, delta: f64) -> bool {
let Some(sizing) = self.last_column_sizing else {
return false;
};
if !delta.is_finite() || delta == 0.0 {
return false;
}
let width = self
.columns
.iter()
.map(|column| {
column
.windows
.first()
.and_then(|id| self.allocated_widths.get(id))
.copied()
.unwrap_or_else(|| sizing.resolve(column.width))
})
.sum::<f64>()
+ sizing.inner_gap * self.columns.len().saturating_sub(1) as f64;
let next = (self.viewport_x + delta).clamp(0.0, (width - sizing.viewport_width).max(0.0));
if next == self.viewport_x {
return false;
}
self.viewport_x = next;
self.pending_viewport = None;
self.viewport_basis = None;
true
}
#[must_use]
pub fn allocated_column_width(&self, window: WindowId) -> Option<f64> {
self.allocated_widths.get(&window).copied()
@@ -450,6 +514,133 @@ impl ScrollingLayout {
Ok(())
}
pub fn place_window(
&mut self,
window: WindowId,
target: WindowId,
placement: crate::DropPlacement,
) -> Result<(), LayoutError> {
use crate::DropPlacement::*;
let (source, _) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
if !self.contains(target) {
return Err(LayoutError::UnknownWindow(target));
}
if window == target {
return Ok(());
}
let width = self.columns[source].width;
self.remove(window)?;
let (column, row) = self
.window_location(target)
.expect("target survives removing another window");
match placement {
Left | Right => {
let index = column + usize::from(placement == Right);
let mut entry = Column::new(window, width);
entry.display = self.default_display;
self.columns.insert(index, entry);
self.active_column = Some(index);
}
Above | Below | Tab => {
let entry = &mut self.columns[column];
let index = row + usize::from(placement != Above);
entry.windows.insert(index, window);
// Split the destination row's weight rather than collapsing it.
let weight = entry.heights[row] / 2.0;
entry.heights[row] = weight;
entry.heights.insert(index, weight);
entry.normalize_heights();
entry.display = if placement == Tab {
ColumnDisplay::Tabbed
} else {
ColumnDisplay::Stacked
};
entry.active = index;
self.active_column = Some(column);
}
}
self.request_reveal(Some(window));
debug_assert!(self.validate().is_ok());
Ok(())
}
pub fn resize_edge(
&mut self,
window: WindowId,
edge: Direction,
delta: f64,
bounds: Rect,
) -> Result<bool, LayoutError> {
let (column, row) = self
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
if !delta.is_finite() {
return Ok(false);
}
if matches!(edge, Direction::Left | Direction::Right) {
let size = self.last_column_sizing.unwrap_or(ColumnSizing {
viewport_width: bounds.width,
inner_gap: 0.0,
});
let width = self
.allocated_column_width(window)
.unwrap_or_else(|| size.resolve(self.columns[column].width));
let origin = self.columns[..column]
.iter()
.map(|column| {
self.allocated_column_width(column.windows[0])
.unwrap_or_else(|| size.resolve(column.width))
+ size.inner_gap
})
.sum::<f64>()
- self.viewport_x;
let change = if edge == Direction::Left {
-delta
} else {
delta
};
let next = (width + change).clamp(
size.resolve(ColumnWidth::Proportion(MIN_COLUMN_WIDTH))
.max(1.0),
size.resolve(ColumnWidth::Proportion(MAX_COLUMN_WIDTH))
.max(1.0),
);
if next == width {
return Ok(false);
}
self.columns[column].width = ColumnWidth::Fixed(next);
self.pending_viewport = Some(PendingViewportRequest::Resize {
window,
origin,
width,
anchor_right: edge == Direction::Left,
});
return Ok(true);
}
let entry = &mut self.columns[column];
if entry.display == ColumnDisplay::Tabbed {
return Ok(false);
}
let neighbor = match edge {
Direction::Up => row.checked_sub(1),
Direction::Down => (row + 1 < entry.windows.len()).then_some(row + 1),
_ => None,
};
let Some(neighbor) = neighbor else {
return Ok(false);
};
let pair = entry.heights[row] + entry.heights[neighbor];
let change = (if edge == Direction::Up { -delta } else { delta }) / bounds.height.max(1.0);
let minimum = MIN_ROW_HEIGHT.min(pair / 2.0);
let height = (entry.heights[row] + change).clamp(minimum, pair - minimum);
let changed = height != entry.heights[row];
entry.heights[row] = height;
entry.heights[neighbor] = pair - height;
Ok(changed)
}
pub fn remove(&mut self, window: WindowId) -> Result<(), LayoutError> {
let (column_index, window_index) = self
.window_location(window)
@@ -897,6 +1088,42 @@ impl ScrollingLayout {
self.last_column_sizing.map(|sizing| sizing.viewport_width) != Some(viewport_width);
self.last_column_sizing = Some(sizing);
match self.pending_viewport.take() {
Some(PendingViewportRequest::Resize {
window,
origin,
width,
anchor_right,
}) => {
if let Some((column, _)) = self.window_location(window) {
let constraint = if self.columns[column].display == ColumnDisplay::Tabbed {
tab_constraints(&self.columns[column], constraints)
} else {
layout_tree::normalized_constraints(
constraints.get(&window).copied().unwrap_or_default(),
)
};
let maximum = constraint.max_width;
let previous_width = maximum.map_or(width, |maximum| width.min(maximum));
let anchor = origin + if anchor_right { previous_width } else { 0.0 };
let next_width = maximum.map_or(column_positions[column].1, |maximum| {
column_positions[column].1.min(maximum)
});
self.record_viewport_basis(
&column_positions,
inner,
viewport_width,
ViewportRequest::Resize {
column,
anchor,
anchor_right,
maximum,
},
);
self.viewport_x = column_positions[column].0
+ if anchor_right { next_width } else { 0.0 }
- anchor;
}
}
Some(PendingViewportRequest::WidthCycle(_)) => {
if let Some(active) = self.active_column {
self.record_viewport_basis(
@@ -970,23 +1197,7 @@ impl ScrollingLayout {
);
// All tabs use one size, even when their client constraints differ.
// Keep the column allocation stable and honor compatible maximums.
let mut common = SizeConstraints::default();
for id in &column.windows {
let c = layout_tree::normalized_constraints(
constraints.get(id).copied().unwrap_or_default(),
);
common.min_width = common.min_width.max(c.min_width);
common.min_height = common.min_height.max(c.min_height);
common.max_width = match (common.max_width, c.max_width) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
};
common.max_height = match (common.max_height, c.max_height) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
};
}
let common = layout_tree::normalized_constraints(common);
let common = tab_constraints(column, constraints);
layout_tree::record_minimum_warnings(window, rect, common, &mut result.warnings);
apply_maximums(window, &mut rect, common, &mut result.warnings);
if column.windows.len() > 1 {
@@ -1331,6 +1542,28 @@ fn finite_nonnegative(value: f64) -> f64 {
}
}
fn tab_constraints(
column: &Column,
constraints: &HashMap<WindowId, SizeConstraints>,
) -> SizeConstraints {
let mut common = SizeConstraints::default();
for id in &column.windows {
let c =
layout_tree::normalized_constraints(constraints.get(id).copied().unwrap_or_default());
common.min_width = common.min_width.max(c.min_width);
common.min_height = common.min_height.max(c.min_height);
common.max_width = match (common.max_width, c.max_width) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
};
common.max_height = match (common.max_height, c.max_height) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
};
}
layout_tree::normalized_constraints(common)
}
fn apply_maximums(
window: WindowId,
rect: &mut Rect,