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

+161 -3
View File
@@ -50,6 +50,13 @@ impl WorkspaceLayout {
}
}
fn with_column_display(mut self, display: ferese_layout::ColumnDisplay) -> Self {
if let Self::Scrolling(layout) = &mut self {
layout.set_default_display(display);
}
self
}
pub fn mode(&self) -> LayoutMode {
match self {
Self::Scrolling(_) => LayoutMode::Scrolling,
@@ -111,6 +118,20 @@ impl WorkspaceLayout {
}
}
pub fn tab_group(&self, window: WindowId) -> Option<ferese_layout::TabGroup> {
match self {
Self::Tree(tree) => tree.tab_group(window),
Self::Scrolling(layout) => layout.tab_group(window),
}
}
pub fn adjacent_tab(&self, window: WindowId, previous: bool) -> Option<WindowId> {
match self {
Self::Tree(tree) => tree.adjacent_tab(window, previous),
Self::Scrolling(layout) => layout.adjacent_tab(window, previous),
}
}
pub fn stack_window(&mut self, window: WindowId, target: WindowId) -> Result<(), LayoutError> {
match self {
Self::Scrolling(layout) => layout.move_into_column(window, target),
@@ -158,7 +179,32 @@ impl WorkspaceLayout {
constraints,
focused.filter(|window| layout.contains(*window)),
),
Self::Tree(layout) => layout.geometry_with_constraints(bounds, gaps, constraints, focused),
Self::Tree(layout) => {
let groups = layout.tab_groups();
if groups.is_empty() {
return layout.geometry_with_constraints(bounds, gaps, constraints, focused);
}
const TAB_HEIGHT: f64 = ferese_layout::TAB_BAR_HEIGHT;
let mut constraints = constraints.clone();
for group in &groups {
for id in &group.windows {
let constraint = constraints.entry(*id).or_default();
constraint.min_height += TAB_HEIGHT;
constraint.max_height = constraint.max_height.map(|height| height + TAB_HEIGHT);
}
}
let mut result = layout.geometry_with_constraints(bounds, gaps, &constraints, focused)?;
for group in groups {
if let Some(rect) = result.geometry.get_mut(&group.active) {
let height = TAB_HEIGHT.min((rect.height - 1.0).max(0.0));
let header = Rect::new(rect.x, rect.y, rect.width, height);
rect.y += height;
rect.height -= height;
result.tab_bars.push(ferese_layout::TabBar { group, rect: header });
}
}
Ok(result)
}
}
}
@@ -206,7 +252,7 @@ impl WorkspaceLayout {
layout.extract_to_column(window)?;
Ok(grouped)
}
Self::Tree(_) => Ok(false),
Self::Tree(layout) => layout.leave_tab_group(window),
}
}
@@ -345,6 +391,7 @@ pub struct WorkspaceSet {
default_layout_mode: LayoutMode,
default_column_width: ColumnWidth,
scrolling_focus_strategy: ViewportFocusStrategy,
default_column_display: ferese_layout::ColumnDisplay,
next_id: u64,
}
@@ -382,10 +429,21 @@ impl WorkspaceSet {
default_layout_mode,
default_column_width,
scrolling_focus_strategy,
default_column_display: ferese_layout::ColumnDisplay::Stacked,
next_id: 2,
}
}
/// Applies to future columns, including those in future workspaces.
pub fn set_default_column_display(&mut self, display: ferese_layout::ColumnDisplay) {
self.default_column_display = display;
for workspace in self.workspaces.values_mut() {
if let WorkspaceLayout::Scrolling(layout) = &mut workspace.layout {
layout.set_default_display(display);
}
}
}
pub fn reconfigure_live(
&mut self,
mode: LayoutMode,
@@ -427,6 +485,7 @@ impl WorkspaceSet {
}
}
scrolling.set_default_width(width);
scrolling.set_default_display(self.default_column_display);
if strategy != self.scrolling_focus_strategy {
scrolling.set_focus_strategy(strategy);
}
@@ -508,11 +567,15 @@ impl WorkspaceSet {
pub fn set_active_layout_mode(&mut self, mode: LayoutMode, bounds: Rect) -> Result<bool, WorkspaceError> {
let default_column_width = self.default_column_width;
let focus_strategy = self.scrolling_focus_strategy;
let display = self.default_column_display;
let workspace = self.active_mut();
let focused = tiled_focus(workspace);
let changed = workspace
.layout
.set_mode(mode, bounds, focused, default_column_width, focus_strategy)?;
if let WorkspaceLayout::Scrolling(layout) = &mut workspace.layout {
layout.set_default_display(display);
}
debug_assert!(self.validate().is_ok());
Ok(changed)
@@ -689,7 +752,8 @@ impl WorkspaceSet {
self.default_layout_mode,
self.default_column_width,
self.scrolling_focus_strategy,
),
)
.with_column_display(self.default_column_display),
floating: Vec::new(),
last_focused: None,
fullscreen: None,
@@ -1690,3 +1754,97 @@ mod tests {
)
}
}
#[cfg(test)]
mod tab_geometry_tests {
use super::*;
#[test]
fn live_layout_conversion_preserves_default_column_display() {
use ferese_layout::ColumnDisplay;
let mut workspaces = WorkspaceSet::default();
workspaces.set_default_column_display(ColumnDisplay::Tabbed);
for mode in [LayoutMode::Tree, LayoutMode::Scrolling] {
workspaces
.reconfigure_live(
mode,
workspaces.default_column_width,
workspaces.scrolling_focus_strategy,
&HashMap::new(),
)
.unwrap();
}
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
let WorkspaceLayout::Scrolling(layout) = &workspaces.active().layout else {
panic!()
};
assert_eq!(layout.columns()[0].display, ColumnDisplay::Tabbed);
}
#[test]
fn default_column_display_only_changes_future_columns_and_reaches_new_workspaces() {
use ferese_layout::ColumnDisplay;
let mut workspaces = WorkspaceSet::default();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
workspaces.set_default_column_display(ColumnDisplay::Tabbed);
workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap();
let WorkspaceLayout::Scrolling(layout) = &workspaces.active().layout else {
panic!()
};
assert_eq!(layout.columns()[0].display, ColumnDisplay::Stacked);
assert_eq!(layout.columns()[1].display, ColumnDisplay::Tabbed);
let next = workspaces.create_workspace();
workspaces.activate(next).unwrap();
workspaces.insert_window(WindowId(3), Axis::Horizontal, 0.5).unwrap();
let WorkspaceLayout::Scrolling(layout) = &workspaces.active().layout else {
panic!()
};
assert_eq!(layout.columns()[0].display, ColumnDisplay::Tabbed);
workspaces.set_default_column_display(ColumnDisplay::Stacked);
workspaces.insert_window(WindowId(4), Axis::Horizontal, 0.5).unwrap();
let WorkspaceLayout::Scrolling(layout) = &workspaces.active().layout else {
panic!()
};
assert_eq!(layout.columns()[0].display, ColumnDisplay::Tabbed);
assert_eq!(layout.columns()[1].display, ColumnDisplay::Stacked);
}
#[test]
fn tab_header_reserves_space_and_disappears_after_leaving() {
let mut layout = WorkspaceLayout::Tree(LayoutTree::default());
layout.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
layout
.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
layout.stack_window(WindowId(2), WindowId(1)).unwrap();
for height in [12., 200., 600.] {
let bounds = Rect::new(0., 0., 800., height);
let gaps = GapConfig {
inner: 0.,
outer: 0.,
smart: false,
};
let result = layout
.resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(WindowId(2)))
.unwrap();
assert_eq!(result.tab_bars.len(), 1);
let header = result.tab_bars[0].rect;
let content = result.geometry[&WindowId(2)];
assert_eq!(header.y + header.height, content.y);
assert_eq!(content.height + header.height, height);
assert!(content.height > 0.0);
assert!(!result.geometry.contains_key(&WindowId(1)));
}
layout.extract_window(WindowId(2)).unwrap();
let result = layout
.resolve_geometry_with_constraints(
Rect::new(0., 0., 800., 600.),
GapConfig::default(),
&HashMap::new(),
None,
)
.unwrap();
assert!(result.tab_bars.is_empty());
assert_eq!(result.geometry.len(), 2);
}
}
+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);
}
}
}