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
@@ -13,6 +13,9 @@ impl Ferese {
|
||||
if self.overview_strip_at(position).is_some() {
|
||||
return None;
|
||||
}
|
||||
if self.tab_at(position).is_some() {
|
||||
return None;
|
||||
}
|
||||
self.layer_surface_under(position, &[Layer::Overlay, Layer::Top])
|
||||
.map(|(_, surface, origin)| (surface, origin))
|
||||
.or_else(|| self.window_surface_under(position))
|
||||
|
||||
@@ -86,7 +86,11 @@ impl Ferese {
|
||||
}
|
||||
|
||||
pub(super) fn workspace_layout_bounds(&self, bounds: Rect, layout: &WorkspaceLayout) -> Rect {
|
||||
let outer = if self.gap_config.smart && layout.window_ids().count() == 1 {
|
||||
let visible = match layout {
|
||||
WorkspaceLayout::Tree(tree) => tree.visible_window_count(),
|
||||
WorkspaceLayout::Scrolling(layout) => layout.visible_window_count(),
|
||||
};
|
||||
let outer = if self.gap_config.smart && visible == 1 {
|
||||
0.
|
||||
} else {
|
||||
self.gap_config.outer
|
||||
@@ -118,6 +122,12 @@ impl Ferese {
|
||||
|
||||
pub(super) fn unmap_invisible_windows(&mut self, visible: &HashSet<WindowId>) -> bool {
|
||||
let mut changed = false;
|
||||
let inactive_tabs: HashSet<_> = self
|
||||
.tab_bars
|
||||
.values()
|
||||
.flatten()
|
||||
.flat_map(|bar| bar.group.windows.iter().copied().filter(|id| *id != bar.group.active))
|
||||
.collect();
|
||||
let windows = self
|
||||
.windows
|
||||
.ids()
|
||||
@@ -136,6 +146,11 @@ impl Ferese {
|
||||
geometry.settle_presentation();
|
||||
}
|
||||
|
||||
if inactive_tabs.contains(&id) {
|
||||
// An inactive client must not hold its column's resize or viewport.
|
||||
self.windows.clear_transaction(&id);
|
||||
self.windows.update(id, |record| record.coupled_width = None);
|
||||
}
|
||||
self.space.unmap_elem(&window);
|
||||
}
|
||||
self.refresh_idle_inhibition();
|
||||
@@ -195,6 +210,7 @@ impl Ferese {
|
||||
self.refresh_input_capture_zones();
|
||||
let visible_workspaces = self.visible_workspace_ids();
|
||||
self.reconcile_workspaces(&visible_workspaces);
|
||||
self.tab_bars.retain(|id, _| visible_workspaces.contains(id));
|
||||
if self.session_lock.active() {
|
||||
self.configure_lock_surfaces();
|
||||
}
|
||||
@@ -228,11 +244,18 @@ impl Ferese {
|
||||
.iter()
|
||||
.find(|column| column.windows.contains(&id))
|
||||
.and_then(|column| {
|
||||
column.windows.iter().find_map(|sibling| {
|
||||
self.windows.transaction(sibling).and_then(|transaction| {
|
||||
transaction.column_width().map(|(source, _)| source)
|
||||
column
|
||||
.windows
|
||||
.iter()
|
||||
.filter(|sibling| {
|
||||
column.display != ferese_layout::ColumnDisplay::Tabbed
|
||||
|| **sibling == column.windows[column.active]
|
||||
})
|
||||
.find_map(|sibling| {
|
||||
self.windows.transaction(sibling).and_then(|transaction| {
|
||||
transaction.column_width().map(|(source, _)| source)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
.or_else(|| layout.allocated_column_width(id))?,
|
||||
@@ -255,9 +278,12 @@ impl Ferese {
|
||||
// to every row; sibling configure holds already release the
|
||||
// whole column together, including partial client commits.
|
||||
let mut rows = column.windows.iter().filter(|id| {
|
||||
self.windows
|
||||
.geometry(id)
|
||||
.is_some_and(|geometry| !geometry.is_zooming() && !geometry.is_fullscreen())
|
||||
(column.display != ferese_layout::ColumnDisplay::Tabbed
|
||||
|| **id == column.windows[column.active])
|
||||
&& self
|
||||
.windows
|
||||
.geometry(id)
|
||||
.is_some_and(|geometry| !geometry.is_zooming() && !geometry.is_fullscreen())
|
||||
});
|
||||
let source = rows
|
||||
.clone()
|
||||
@@ -345,6 +371,7 @@ impl Ferese {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
self.tab_bars.insert(workspace_id, layout.tab_bars);
|
||||
let viewport_target = workspace.layout.viewport_x();
|
||||
let viewport_motion = viewport_target.map(|target| {
|
||||
let viewport = self
|
||||
@@ -380,6 +407,10 @@ impl Ferese {
|
||||
self.windows.record(*id).is_some_and(|w| w.maximized) && workspace_fullscreen != Some(*id);
|
||||
let placement = self.workspaces.placement(*id);
|
||||
let is_floating = matches!(placement, Some(WindowPlacement::Floating { .. }));
|
||||
// Maximization cannot make an inactive tab visible again.
|
||||
if !is_floating && workspace_fullscreen != Some(*id) && !layout.geometry.contains_key(id) {
|
||||
continue;
|
||||
}
|
||||
let rect = if workspace_fullscreen == Some(*id) {
|
||||
fullscreen_bounds
|
||||
} else if is_maximized && (!is_scrolling_layout || is_floating) {
|
||||
|
||||
@@ -270,13 +270,30 @@ impl Ferese {
|
||||
}
|
||||
|
||||
pub fn consume_focused_window(&mut self) {
|
||||
if self.workspaces.active().fullscreen.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(window) = self.focused_window else {
|
||||
return;
|
||||
};
|
||||
let Some(bounds) = self.output_bounds() else {
|
||||
return;
|
||||
};
|
||||
if self.workspaces.active().layout.mode() != LayoutMode::Scrolling {
|
||||
if self.workspaces.active().layout.mode() == LayoutMode::Tree {
|
||||
if let Some(direction) = [Direction::Left, Direction::Right, Direction::Up, Direction::Down]
|
||||
.into_iter()
|
||||
.find(|direction| {
|
||||
self.workspaces
|
||||
.active()
|
||||
.layout
|
||||
.directional_neighbor(window, *direction, bounds)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
})
|
||||
{
|
||||
self.join_tab(direction);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let target = self
|
||||
@@ -306,6 +323,9 @@ impl Ferese {
|
||||
}
|
||||
|
||||
pub fn expel_focused_window(&mut self) {
|
||||
if self.workspaces.active().fullscreen.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(window) = self.focused_window else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
use super::*;
|
||||
|
||||
impl Ferese {
|
||||
pub(crate) fn join_tab(&mut self, direction: Direction) {
|
||||
let Some(window) = self.focused_window else {
|
||||
return;
|
||||
};
|
||||
let Some(bounds) = self.output_bounds() else {
|
||||
return;
|
||||
};
|
||||
let workspace = self.workspaces.active();
|
||||
if workspace.fullscreen.is_some() {
|
||||
return;
|
||||
}
|
||||
let Ok(Some(target)) = workspace.layout.directional_neighbor(window, direction, bounds) else {
|
||||
return;
|
||||
};
|
||||
if let WorkspaceLayout::Scrolling(layout) = &mut self.workspaces.active_mut().layout {
|
||||
// Joining rows already in the same column changes their display only.
|
||||
let same_column = layout
|
||||
.columns()
|
||||
.iter()
|
||||
.any(|c| c.windows.contains(&window) && c.windows.contains(&target));
|
||||
if same_column {
|
||||
let _ = layout.set_column_display(window, ferese_layout::ColumnDisplay::Tabbed);
|
||||
self.relayout();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Err(error) = self.workspaces.stack_window(window, target) {
|
||||
tracing::warn!(%error, "failed to join tab group");
|
||||
return;
|
||||
}
|
||||
if let WorkspaceLayout::Scrolling(layout) = &mut self.workspaces.active_mut().layout {
|
||||
let _ = layout.set_column_display(window, ferese_layout::ColumnDisplay::Tabbed);
|
||||
}
|
||||
self.relayout();
|
||||
self.restore_keyboard_focus();
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_column_tabbed(&mut self) {
|
||||
let Some(window) = self.focused_window else {
|
||||
return;
|
||||
};
|
||||
if self.workspaces.active().fullscreen.is_some() {
|
||||
return;
|
||||
}
|
||||
let WorkspaceLayout::Scrolling(layout) = &mut self.workspaces.active_mut().layout else {
|
||||
return;
|
||||
};
|
||||
if layout.toggle_column_tabbed(window).is_ok() {
|
||||
self.relayout();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn leave_tab(&mut self) {
|
||||
if self.workspaces.active().fullscreen.is_none()
|
||||
&& self
|
||||
.focused_window
|
||||
.and_then(|id| self.workspaces.active().layout.tab_group(id))
|
||||
.is_some()
|
||||
{
|
||||
self.expel_focused_window();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cycle_tab(&mut self, previous: bool) {
|
||||
let Some(window) = self.focused_window else {
|
||||
return;
|
||||
};
|
||||
if self.workspaces.active().fullscreen.is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(target) = self.workspaces.active().layout.adjacent_tab(window, previous) {
|
||||
self.activate_managed_window(target);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn visible_tab_bars(&self, workspace: WorkspaceId) -> impl Iterator<Item = &ferese_layout::TabBar> {
|
||||
self.tab_bars.get(&workspace).into_iter().flatten().filter(move |bar| {
|
||||
self.workspaces
|
||||
.workspace(workspace)
|
||||
.is_some_and(|ws| ws.fullscreen.is_none())
|
||||
&& self.windows.record(bar.group.active).is_some_and(|w| !w.maximized)
|
||||
&& self
|
||||
.windows
|
||||
.window(bar.group.active)
|
||||
.is_some_and(|w| self.space.element_location(w).is_some())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn tab_at(&self, position: Point<f64, Logical>) -> Option<WindowId> {
|
||||
if self.session_lock.active()
|
||||
|| self.overview.is_active()
|
||||
|| self.input_capture.active()
|
||||
|| self
|
||||
.layer_surface_under(position, &[Layer::Overlay, Layer::Top])
|
||||
.is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let workspace = self.workspace_under_pointer(position)?;
|
||||
// Floating windows and popups in front of the group keep their input.
|
||||
if self.window_surface_under(position).is_some() {
|
||||
return None;
|
||||
}
|
||||
self.visible_tab_bars(workspace).find_map(|bar| {
|
||||
let content = self.presented_window_rect(bar.group.active)?;
|
||||
let rect = Rect::new(content.x, content.y - bar.rect.height, content.width, bar.rect.height);
|
||||
tab_hit(&bar.group.windows, rect, position.x, position.y)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn tab_hit(windows: &[WindowId], rect: Rect, x: f64, y: f64) -> Option<WindowId> {
|
||||
if windows.is_empty()
|
||||
|| rect.width <= 0.0
|
||||
|| x < rect.x
|
||||
|| x >= rect.x + rect.width
|
||||
|| y < rect.y
|
||||
|| y >= rect.y + rect.height
|
||||
{
|
||||
return None;
|
||||
}
|
||||
windows
|
||||
.get(((x - rect.x) / rect.width * windows.len() as f64).floor() as usize)
|
||||
.copied()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn tab_hit_uses_half_open_rectangles_at_fractional_coordinates() {
|
||||
let windows = [WindowId(1), WindowId(2), WindowId(3)];
|
||||
let rect = Rect::new(10.5, 20.5, 301., 30.);
|
||||
assert_eq!(tab_hit(&windows, rect, 10.5, 20.5), Some(windows[0]));
|
||||
assert_eq!(tab_hit(&windows, rect, 111., 25.), Some(windows[1]));
|
||||
assert_eq!(tab_hit(&windows, rect, 311.49, 25.), Some(windows[2]));
|
||||
for (x, y) in [(10., 25.), (311.5, 25.), (20., 50.5)] {
|
||||
assert_eq!(tab_hit(&windows, rect, x, y), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -976,3 +976,100 @@ fn reloading_mode_bindings_cancels_the_modes_focus_preview() {
|
||||
key(&mut state, 64, false);
|
||||
assert!(state.intercepted_keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_tabs_join_switch_leave_and_hide_inactive_maximized_windows() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"state::window_lifecycle_tests::tree_tabs_join_switch_leave_and_hide_inactive_maximized_windows",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let (mut events, mut state, _output) = fixture();
|
||||
state.toggle_layout_mode();
|
||||
let (first, _first_wire) = window(&mut state, &mut events, 0xff112233);
|
||||
let (second, _second_wire) = window(&mut state, &mut events, 0xff445566);
|
||||
let a = state.windows.ids()[&first];
|
||||
let b = state.windows.ids()[&second];
|
||||
state.focused_window = Some(b);
|
||||
state.consume_focused_window();
|
||||
let workspace = state.workspaces.active_id();
|
||||
assert_eq!(state.tab_bars[&workspace].len(), 1);
|
||||
assert!(state.space.element_location(&first).is_none());
|
||||
assert!(state.space.element_location(&second).is_some());
|
||||
let bar = &state.tab_bars[&workspace][0];
|
||||
let header = bar.rect;
|
||||
let content = state.windows.geometry(&b).unwrap().visual.target;
|
||||
assert_eq!(content.y, header.y + header.height);
|
||||
let hit = Point::from((header.x + header.width * 0.25, header.y + header.height * 0.5));
|
||||
assert_eq!(state.tab_at(hit), Some(a));
|
||||
assert!(state.surface_under(hit).is_none());
|
||||
state.cycle_tab(false);
|
||||
assert_eq!(state.focused_window, Some(a));
|
||||
assert!(state.space.element_location(&first).is_some());
|
||||
assert!(state.space.element_location(&second).is_none());
|
||||
state.toggle_focused_maximized();
|
||||
assert_eq!(state.visible_tab_bars(workspace).count(), 0);
|
||||
state.cycle_tab(true);
|
||||
assert_eq!(state.focused_window, Some(b));
|
||||
assert!(
|
||||
state.space.element_location(&first).is_none(),
|
||||
"inactive maximized tab must remain hidden"
|
||||
);
|
||||
assert_eq!(state.visible_tab_bars(workspace).count(), 1);
|
||||
state.toggle_focused_fullscreen();
|
||||
let tree = state.workspaces.active().layout.clone();
|
||||
state.leave_tab();
|
||||
state.expel_focused_window();
|
||||
state.cycle_tab(false);
|
||||
assert_eq!(state.workspaces.active().layout, tree);
|
||||
assert_eq!(state.visible_tab_bars(workspace).count(), 0);
|
||||
state.toggle_focused_fullscreen();
|
||||
state.leave_tab();
|
||||
assert!(state.tab_bars[&workspace].is_empty());
|
||||
assert!(state.space.element_location(&first).is_some());
|
||||
assert!(state.space.element_location(&second).is_some());
|
||||
assert!(state.workspaces.active().layout.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolling_tabs_switch_with_directional_focus_and_drop_hidden_resize_holds() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"state::window_lifecycle_tests::scrolling_tabs_switch_with_directional_focus_and_drop_hidden_resize_holds",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let (mut events, mut state, _output) = fixture();
|
||||
let (first, _first_wire) = window(&mut state, &mut events, 0xff112233);
|
||||
state.toggle_column_tabbed();
|
||||
let (second, _second_wire) = window(&mut state, &mut events, 0xff445566);
|
||||
state.consume_focused_window();
|
||||
let (third, _third_wire) = window(&mut state, &mut events, 0xff778899);
|
||||
state.consume_focused_window();
|
||||
let a = state.windows.ids()[&first];
|
||||
let b = state.windows.ids()[&second];
|
||||
let c = state.windows.ids()[&third];
|
||||
let workspace = state.workspaces.active_id();
|
||||
assert_eq!(state.tab_bars[&workspace][0].group.windows, [a, b, c]);
|
||||
assert_eq!(state.space.elements().count(), 1);
|
||||
let position = state.workspaces.active().layout.viewport_x();
|
||||
state.windows.set_transaction(
|
||||
c,
|
||||
crate::resize_transaction::ResizeTransaction::new(SERIAL_COUNTER.next_serial(), state.presentation_now()),
|
||||
);
|
||||
state.focus_direction(Direction::Up);
|
||||
assert_eq!(state.focused_window, Some(b));
|
||||
assert!(state.windows.transaction(&c).is_none());
|
||||
assert!(state.space.element_location(&third).is_none());
|
||||
assert!(state.space.element_location(&second).is_some());
|
||||
assert_eq!(state.workspaces.active().layout.viewport_x(), position);
|
||||
state.focus_direction(Direction::Down);
|
||||
assert_eq!(state.focused_window, Some(c));
|
||||
state.toggle_column_tabbed();
|
||||
assert_eq!(state.space.elements().count(), 3);
|
||||
assert!(state.tab_bars[&workspace].is_empty());
|
||||
state.toggle_column_tabbed();
|
||||
assert_eq!(state.space.elements().count(), 1);
|
||||
state.leave_tab();
|
||||
assert_eq!(state.space.elements().count(), 2);
|
||||
assert_eq!(state.tab_bars[&workspace][0].group.windows, [a, b]);
|
||||
}
|
||||
Reference in new issue
Block a user