feat(layout): add tabbed window groups and scrolling columns
This commit is contained in:
1 parent
7d58eeb65d
commit
bfa78f62f2
27 files changed
+1480
-47
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user