feat(layout): add tabbed window groups and scrolling columns

This commit is contained in:
Samuel Onoja committed 2026-10-12 23:26:00 +01:00
1 parent 7d58eeb65d
commit bfa78f62f2
27 files changed
+1480 -47

No files matched your search

+183 -1
View File
@@ -4,7 +4,9 @@ use std::fmt;
mod scrolling;
pub use scrolling::{Column, ColumnWidth, ScrollingLayout, ViewportFocusStrategy, ViewportTarget};
pub use scrolling::{Column, ColumnDisplay, ColumnWidth, ScrollingLayout, ViewportFocusStrategy, ViewportTarget};
pub const TAB_BAR_HEIGHT: f64 = 30.0;
const MIN_SPLIT_RATIO: f64 = 0.05;
const MAX_SPLIT_RATIO: f64 = 0.95;
@@ -15,6 +17,18 @@ pub struct WindowId(pub u64);
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct NodeId(u64);
#[derive(Clone, Debug, PartialEq)]
pub struct TabGroup {
pub windows: Vec<WindowId>,
pub active: WindowId,
}
#[derive(Clone, Debug, PartialEq)]
pub struct TabBar {
pub group: TabGroup,
pub rect: Rect,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Axis {
Horizontal,
@@ -108,6 +122,7 @@ pub struct ConstraintWarning {
pub struct LayoutResult {
pub geometry: HashMap<WindowId, Rect>,
pub warnings: Vec<ConstraintWarning>,
pub tab_bars: Vec<TabBar>,
}
impl Default for GapConfig {
@@ -165,6 +180,18 @@ impl LayoutTree {
self.windows.keys().copied()
}
pub fn visible_window_count(&self) -> usize {
fn count(tree: &LayoutTree, node: NodeId) -> usize {
match tree.nodes.get(&node) {
Some(Node::Window(_)) => 1,
Some(Node::Split { first, second, .. }) => count(tree, *first) + count(tree, *second),
Some(Node::Stack { children, active }) => children.get(*active).map_or(0, |child| count(tree, *child)),
None => 0,
}
}
self.root.map_or(0, |root| count(self, root))
}
pub fn preferred_window(&self) -> Option<WindowId> {
let leaf = self.first_window(self.root?).ok()?;
match self.nodes.get(&leaf)? {
@@ -211,6 +238,8 @@ impl LayoutTree {
.ok_or(LayoutError::UnknownWindow(focused))?,
None => self.first_window(root)?,
};
// A new split belongs beside the whole tab group, not inside one tab.
let target = self.tab_parent(target).unwrap_or(target);
let old_parent = self.parents.get(&target).copied();
let window_node = self.insert_node(Node::Window(window));
self.windows.insert(window, window_node);
@@ -307,6 +336,13 @@ impl LayoutTree {
return Err(LayoutError::UnknownWindow(window));
}
if self
.tab_group(window)
.is_some_and(|group| group.windows.contains(&target))
{
return self.activate_window(window);
}
self.remove(window)?;
let target_node = self
@@ -316,6 +352,17 @@ impl LayoutTree {
.ok_or(LayoutError::UnknownWindow(target))?;
let old_parent = self.parents.get(&target_node).copied();
let window_node = self.insert_node(Node::Window(window));
if let Some(parent) = self.tab_parent(target_node) {
let Some(Node::Stack { children, active }) = self.nodes.get_mut(&parent) else {
unreachable!()
};
children.push(window_node);
*active = children.len() - 1;
self.windows.insert(window, window_node);
self.parents.insert(window_node, parent);
debug_assert!(self.validate().is_ok());
return Ok(());
}
let stack = self.insert_node(Node::Stack {
children: vec![target_node, window_node],
active: 1,
@@ -336,6 +383,73 @@ impl LayoutTree {
Ok(())
}
fn tab_parent(&self, leaf: NodeId) -> Option<NodeId> {
let parent = *self.parents.get(&leaf)?;
match self.nodes.get(&parent)? {
Node::Stack { children, .. }
if children
.iter()
.all(|id| matches!(self.nodes.get(id), Some(Node::Window(_)))) =>
{
Some(parent)
}
_ => None,
}
}
fn group_at(&self, node: NodeId) -> Option<TabGroup> {
let Node::Stack { children, active } = self.nodes.get(&node)? else {
return None;
};
let windows: Option<Vec<_>> = children
.iter()
.map(|id| match self.nodes.get(id) {
Some(Node::Window(window)) => Some(*window),
_ => None,
})
.collect();
let windows = windows?;
Some(TabGroup {
active: *windows.get(*active)?,
windows,
})
}
pub fn tab_group(&self, window: WindowId) -> Option<TabGroup> {
self.group_at(*self.parents.get(self.windows.get(&window)?)?)
}
pub fn tab_groups(&self) -> Vec<TabGroup> {
let mut groups: Vec<_> = self.nodes.keys().filter_map(|id| self.group_at(*id)).collect();
groups.sort_by_key(|group| group.windows[0].0);
groups
}
pub fn adjacent_tab(&self, window: WindowId, previous: bool) -> Option<WindowId> {
let group = self.tab_group(window)?;
let index = group.windows.iter().position(|id| *id == window)?;
let step = if previous { group.windows.len() - 1 } else { 1 };
Some(group.windows[(index + step) % group.windows.len()])
}
/// Leave the group in place and split the departing window to its right.
pub fn leave_tab_group(&mut self, window: WindowId) -> Result<bool, LayoutError> {
if !self.contains(window) {
return Err(LayoutError::UnknownWindow(window));
}
let Some(group) = self.tab_group(window) else {
return Ok(false);
};
let target = group
.windows
.into_iter()
.find(|id| *id != window)
.expect("group has at least two tabs");
self.remove(window)?;
self.insert(window, Some(target), Axis::Horizontal, 0.5)?;
Ok(true)
}
pub fn activate_window(&mut self, window: WindowId) -> Result<(), LayoutError> {
let mut child = self
.windows
@@ -1576,3 +1690,71 @@ mod tests {
}
}
}
#[cfg(test)]
mod tab_tests {
use super::*;
fn nested() -> LayoutTree {
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.6)
.unwrap();
tree.insert(WindowId(3), Some(WindowId(1)), Axis::Vertical, 0.4)
.unwrap();
tree.insert(WindowId(4), Some(WindowId(3)), Axis::Horizontal, 0.5)
.unwrap();
tree
}
#[test]
fn joining_and_leaving_tabs_preserve_neighboring_splits() {
let mut tree = nested();
let bounds = Rect::new(0., 0., 1000., 800.);
let neighbor = tree.geometry(bounds).unwrap()[&WindowId(2)];
tree.stack_window(WindowId(4), WindowId(3)).unwrap();
tree.stack_window(WindowId(1), WindowId(3)).unwrap();
assert_eq!(
tree.tab_group(WindowId(1)).unwrap().windows,
[WindowId(3), WindowId(4), WindowId(1)]
);
assert_eq!(tree.geometry(bounds).unwrap()[&WindowId(2)], neighbor);
assert_eq!(tree.visible_window_count(), 2);
let before = tree.clone();
tree.stack_window(WindowId(1), WindowId(4)).unwrap();
assert_eq!(tree, before, "joining the same group must not reorder it");
assert_eq!(tree.adjacent_tab(WindowId(1), false), Some(WindowId(3)));
assert_eq!(tree.adjacent_tab(WindowId(3), true), Some(WindowId(1)));
assert!(tree.leave_tab_group(WindowId(1)).unwrap());
assert_eq!(tree.geometry(bounds).unwrap()[&WindowId(2)], neighbor);
assert_eq!(tree.tab_group(WindowId(4)).unwrap().windows, [WindowId(3), WindowId(4)]);
assert!(tree.tab_group(WindowId(1)).is_none());
assert!(!tree.leave_tab_group(WindowId(1)).unwrap());
tree.remove(WindowId(4)).unwrap();
assert!(tree.tab_groups().is_empty());
assert!(tree.validate().is_ok());
}
#[test]
fn opening_beside_a_group_and_removing_active_tabs_keep_all_members_reachable() {
let mut tree = nested();
tree.stack_window(WindowId(4), WindowId(3)).unwrap();
tree.stack_window(WindowId(1), WindowId(3)).unwrap();
tree.insert(WindowId(5), Some(WindowId(1)), Axis::Vertical, 0.3)
.unwrap();
assert_eq!(tree.tab_groups().len(), 1);
let bounds = Rect::new(0., 0., 1000., 800.);
for id in [WindowId(3), WindowId(4), WindowId(1)] {
tree.activate_window(id).unwrap();
let geometry = tree.geometry(bounds).unwrap();
assert!(geometry.contains_key(&WindowId(5)));
assert!(geometry.contains_key(&id));
assert_eq!(geometry.len(), 3);
}
tree.remove(WindowId(1)).unwrap();
assert_eq!(tree.tab_group(WindowId(3)).unwrap().active, WindowId(4));
tree.remove(WindowId(3)).unwrap();
assert!(tree.tab_groups().is_empty());
assert!(tree.validate().is_ok());
}
}
+280 -4
View File
@@ -11,6 +11,13 @@ const MAX_COLUMN_WIDTH: f64 = 2.0;
const MIN_ROW_HEIGHT: f64 = 0.05;
const REVEAL_EPSILON: f64 = 0.5;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum ColumnDisplay {
#[default]
Stacked,
Tabbed,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ColumnWidth {
Proportion(f64),
@@ -60,6 +67,7 @@ pub struct Column {
pub active: usize,
pub width: ColumnWidth,
pub heights: Vec<f64>,
pub display: ColumnDisplay,
}
impl Column {
@@ -69,6 +77,7 @@ impl Column {
active: 0,
width: normalized_width(width),
heights: vec![1.0],
display: ColumnDisplay::Stacked,
}
}
@@ -191,6 +200,7 @@ pub struct ScrollingLayout {
active_column: Option<usize>,
viewport_x: f64,
default_width: ColumnWidth,
default_display: ColumnDisplay,
focus_strategy: ViewportFocusStrategy,
pending_viewport: Option<PendingViewportRequest>,
last_column_sizing: Option<ColumnSizing>,
@@ -205,6 +215,7 @@ impl Default for ScrollingLayout {
active_column: None,
viewport_x: 0.0,
default_width: ColumnWidth::default(),
default_display: ColumnDisplay::default(),
focus_strategy: ViewportFocusStrategy::Minimal,
pending_viewport: None,
last_column_sizing: None,
@@ -237,6 +248,55 @@ impl ScrollingLayout {
}
}
pub fn set_default_display(&mut self, display: ColumnDisplay) {
self.default_display = display;
}
pub fn set_column_display(&mut self, window: WindowId, display: ColumnDisplay) -> Result<(), LayoutError> {
let (index, _) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
self.columns[index].display = display;
Ok(())
}
pub fn toggle_column_tabbed(&mut self, window: WindowId) -> Result<(), LayoutError> {
let (index, _) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
let column = &mut self.columns[index];
column.display = match column.display {
ColumnDisplay::Stacked => ColumnDisplay::Tabbed,
ColumnDisplay::Tabbed => ColumnDisplay::Stacked,
};
Ok(())
}
pub fn tab_group(&self, window: WindowId) -> Option<crate::TabGroup> {
let (index, _) = self.window_location(window)?;
let column = &self.columns[index];
(column.display == ColumnDisplay::Tabbed && column.windows.len() > 1).then(|| crate::TabGroup {
windows: column.windows.clone(),
active: column.windows[column.active],
})
}
pub fn adjacent_tab(&self, window: WindowId, previous: bool) -> Option<WindowId> {
let group = self.tab_group(window)?;
let index = group.windows.iter().position(|id| *id == window)?;
let step = if previous { group.windows.len() - 1 } else { 1 };
Some(group.windows[(index + step) % group.windows.len()])
}
pub fn visible_window_count(&self) -> usize {
self.columns
.iter()
.map(|c| {
if c.display == ColumnDisplay::Tabbed {
1
} else {
c.windows.len()
}
})
.sum()
}
pub fn set_default_width(&mut self, width: ColumnWidth) {
self.default_width = normalized_width(width);
}
@@ -333,7 +393,9 @@ impl ScrollingLayout {
.and_then(|focused| self.window_location(focused))
.map(|(column, _)| column + 1)
.unwrap_or(self.columns.len());
self.columns.insert(index, Column::new(window, self.default_width));
let mut column = Column::new(window, self.default_width);
column.display = self.default_display;
self.columns.insert(index, column);
self.active_column = Some(index);
self.request_reveal((self.columns.len() > 1).then_some(window));
@@ -399,7 +461,14 @@ impl ScrollingLayout {
/// An explicit selection must reveal even a window already focused by hover.
pub fn focus_and_reveal(&mut self, window: WindowId) -> Result<(), LayoutError> {
let (column, row) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
let switching_tab = self.active_column == Some(column)
&& self.columns[column].display == ColumnDisplay::Tabbed
&& self.columns[column].active != row;
self.focus_without_reveal(window)?;
if switching_tab {
return Ok(());
}
self.request_focus(Some(PendingFocusRequest::Reveal(window)));
Ok(())
}
@@ -407,6 +476,12 @@ impl ScrollingLayout {
pub fn slide_focus_from(&mut self, previous: WindowId, focused: WindowId) -> Result<(), LayoutError> {
self.window_location(previous)
.ok_or(LayoutError::UnknownWindow(previous))?;
if self
.tab_group(previous)
.is_some_and(|group| group.windows.contains(&focused))
{
return self.focus_without_reveal(focused);
}
if self.focus_strategy == ViewportFocusStrategy::Minimal {
return self.focus_and_reveal(focused);
}
@@ -450,7 +525,9 @@ impl ScrollingLayout {
let width = self.columns[source].width;
self.remove(window)?;
let insertion = (source + 1).min(self.columns.len());
self.columns.insert(insertion, Column::new(window, width));
let mut column = Column::new(window, width);
column.display = self.default_display;
self.columns.insert(insertion, column);
self.active_column = Some(insertion);
self.request_reveal(self.active_window());
@@ -598,6 +675,10 @@ impl ScrollingLayout {
return Ok(false);
}
if self.columns[column].display == ColumnDisplay::Tabbed && matches!(direction, Direction::Up | Direction::Down)
{
return Ok(false);
}
let changed = match direction {
Direction::Left | Direction::Right => {
let current = match normalized_width(self.columns[column].width) {
@@ -646,7 +727,7 @@ impl ScrollingLayout {
return Ok(LayoutResult::default());
}
let window_count = self.window_ids().count();
let window_count = self.visible_window_count();
let inner = finite_nonnegative(gaps.inner);
let outer = if gaps.smart && window_count == 1 {
0.0
@@ -751,6 +832,51 @@ impl ScrollingLayout {
let mut result = LayoutResult::default();
for (column_index, column) in self.columns.iter().enumerate() {
let (column_x, column_width) = column_positions[column_index];
if column.display == ColumnDisplay::Tabbed {
let window = column.windows[column.active];
let header = if column.windows.len() > 1 {
crate::TAB_BAR_HEIGHT.min((viewport_height - 1.0).max(0.0))
} else {
0.0
};
let mut rect = Rect::new(
bounds.x + outer + column_x - self.viewport_x,
bounds.y + outer + header,
column_width,
viewport_height - header,
);
// 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 = 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 = normalized_constraints(common);
record_minimum_warnings(window, rect, common, &mut result.warnings);
apply_maximums(window, &mut rect, common, &mut result.warnings);
if header > 0.0 {
result.tab_bars.push(crate::TabBar {
group: crate::TabGroup {
windows: column.windows.clone(),
active: window,
},
rect: Rect::new(rect.x, rect.y - header, rect.width, header),
});
}
result.geometry.insert(window, rect);
continue;
}
let available_height = (viewport_height - inner * (column.windows.len().saturating_sub(1) as f64)).max(0.0);
let row_constraints = column
.windows
@@ -875,7 +1001,9 @@ impl ScrollingLayout {
let width = self.columns[source].width;
self.remove(window)?;
let insertion = insertion.min(self.columns.len());
self.columns.insert(insertion, Column::new(window, width));
let mut column = Column::new(window, width);
column.display = self.default_display;
self.columns.insert(insertion, column);
self.active_column = Some(insertion);
self.request_reveal(self.active_window());
Ok(())
@@ -2522,3 +2650,151 @@ mod tests {
}
}
}
#[cfg(test)]
mod tabbed_tests {
use super::*;
fn fixture() -> ScrollingLayout {
let mut layout = ScrollingLayout::default();
for id in 1..=4 {
layout.insert(WindowId(id), layout.active_window()).unwrap();
}
layout.move_into_column(WindowId(3), WindowId(2)).unwrap();
layout.move_into_column(WindowId(4), WindowId(3)).unwrap();
layout
}
#[test]
fn toggling_tabs_restores_weights_and_keeps_viewport_and_column_widths() {
for strategy in [
ViewportFocusStrategy::Minimal,
ViewportFocusStrategy::Center,
ViewportFocusStrategy::Paged,
] {
let mut layout = fixture();
layout.set_focus_strategy(strategy);
layout.resize_window(WindowId(3), Direction::Up, 0.1).unwrap();
let bounds = Rect::new(0., 0., 1000., 800.);
let gaps = GapConfig::default();
let before = layout
.resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(WindowId(4)))
.unwrap();
let widths = layout.allocated_widths.clone();
let weights = layout.columns[1].heights.clone();
layout.toggle_column_tabbed(WindowId(4)).unwrap();
let tabbed = layout
.resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(WindowId(4)))
.unwrap();
let viewport = layout.viewport_x();
assert_eq!(tabbed.geometry.len(), 2);
assert_eq!(tabbed.tab_bars.len(), 1);
assert_eq!(layout.allocated_widths, widths);
let rect = tabbed.geometry[&WindowId(4)];
for id in [WindowId(2), WindowId(3), WindowId(4)] {
layout.focus_and_reveal(id).unwrap();
let result = layout
.resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(id))
.unwrap();
assert_eq!(result.geometry[&id], rect);
assert_eq!(layout.viewport_x(), viewport);
assert_eq!(layout.allocated_widths, widths);
}
assert!(!layout.resize_window(WindowId(3), Direction::Up, 0.1).unwrap());
layout.toggle_column_tabbed(WindowId(4)).unwrap();
let after = layout
.resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(WindowId(4)))
.unwrap();
assert_eq!(layout.columns[1].heights, weights);
assert_eq!(before.geometry, after.geometry);
assert!(after.tab_bars.is_empty());
}
}
#[test]
fn tab_navigation_join_leave_and_single_column_policy_remain_consistent() {
let mut layout = fixture();
layout.set_default_display(ColumnDisplay::Tabbed);
layout.toggle_column_tabbed(WindowId(4)).unwrap();
assert_eq!(
layout.directional_neighbor(WindowId(3), Direction::Up).unwrap(),
Some(WindowId(2))
);
assert_eq!(layout.adjacent_tab(WindowId(4), false), Some(WindowId(2)));
assert_eq!(layout.adjacent_tab(WindowId(2), true), Some(WindowId(4)));
layout.move_into_column(WindowId(1), WindowId(2)).unwrap();
assert_eq!(layout.visible_window_count(), 1);
assert_eq!(layout.columns[0].display, ColumnDisplay::Tabbed);
layout.extract_to_column(WindowId(1)).unwrap();
assert_eq!(layout.columns.len(), 2);
assert!(layout.columns.iter().all(|c| c.display == ColumnDisplay::Tabbed));
assert!(layout.tab_group(WindowId(1)).is_none());
layout.remove(WindowId(4)).unwrap();
layout.remove(WindowId(3)).unwrap();
assert!(layout.tab_group(WindowId(2)).is_none());
layout.insert(WindowId(5), Some(WindowId(2))).unwrap();
assert_eq!(layout.columns[1].display, ColumnDisplay::Tabbed);
assert!(layout.validate().is_ok());
}
#[test]
fn tab_swipe_does_not_recenter_a_manually_offset_viewport() {
let mut layout = fixture();
layout.set_focus_strategy(ViewportFocusStrategy::Center);
layout.toggle_column_tabbed(WindowId(4)).unwrap();
let bounds = Rect::new(0., 0., 1000., 800.);
layout
.resolve_geometry_with_constraints(bounds, GapConfig::default(), &HashMap::new(), Some(WindowId(4)))
.unwrap();
layout.viewport_x += 33.0;
let position = layout.viewport_x;
layout.focus_and_reveal(WindowId(3)).unwrap();
layout.slide_focus_from(WindowId(4), WindowId(3)).unwrap();
layout
.resolve_geometry_with_constraints(bounds, GapConfig::default(), &HashMap::new(), Some(WindowId(3)))
.unwrap();
assert_eq!(layout.viewport_x, position);
}
#[test]
fn constraints_do_not_change_tab_geometry_on_selection() {
let mut layout = fixture();
layout.toggle_column_tabbed(WindowId(4)).unwrap();
let constraints = HashMap::from([
(
WindowId(2),
SizeConstraints {
min_width: 400.,
max_height: Some(300.),
..Default::default()
},
),
(
WindowId(3),
SizeConstraints {
max_width: Some(450.),
min_height: 200.,
..Default::default()
},
),
]);
let mut previous = None;
for id in [WindowId(2), WindowId(3), WindowId(4)] {
let result = layout
.resolve_geometry_with_constraints(
Rect::new(0., 0., 1000., 800.),
GapConfig::default(),
&constraints,
Some(id),
)
.unwrap();
let rect = result.geometry[&id];
assert_eq!(rect.width, 450.);
assert_eq!(rect.height, 300.);
if let Some(previous) = previous {
assert_eq!(rect, previous);
}
previous = Some(rect);
}
}
}