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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user