From bfa78f62f2c4342810bad8b36086ab4d3a98ea12 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Mon, 12 Oct 2026 23:25:49 +0100 Subject: [PATCH] feat(layout): add tabbed window groups and scrolling columns --- compositor/src/config.rs | 51 +++- compositor/src/config/bindings.rs | 32 +- compositor/src/config/layout.rs | 11 + compositor/src/input.rs | 21 ++ compositor/src/ipc.rs | 5 + compositor/src/overview.rs | 51 +++- compositor/src/render/mod.rs | 3 + compositor/src/render/overview.rs | 4 +- compositor/src/render/resources.rs | 5 + compositor/src/render/scene.rs | 1 + compositor/src/render/tabs.rs | 123 ++++++++ compositor/src/state.rs | 11 + compositor/src/state/hit_testing.rs | 3 + compositor/src/state/layout.rs | 47 ++- compositor/src/state/navigation.rs | 22 +- compositor/src/state/tabs.rs | 144 +++++++++ .../src/state/window_lifecycle_tests.rs | 97 ++++++ crates/ferese-core/src/workspace.rs | 164 +++++++++- crates/ferese-layout/src/lib.rs | 184 +++++++++++- crates/ferese-layout/src/scrolling.rs | 284 +++++++++++++++++- docs/configuration.md | 47 ++- docs/development.md | 6 +- docs/ipc.md | 6 + docs/shortcuts.md | 15 +- feresectl/src/cli.rs | 19 +- feresectl/src/main.rs | 16 + scripts/tests/test_tree_tabs_isolated.py | 155 ++++++++++ 27 files changed, 1480 insertions(+), 47 deletions(-) create mode 100644 compositor/src/render/tabs.rs create mode 100644 compositor/src/state/tabs.rs create mode 100644 scripts/tests/test_tree_tabs_isolated.py diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 8a01219..588da5b 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -166,6 +166,7 @@ impl Config { .and_then(|panel| panel.background_opacity), focus_effect: self.focus_effect_settings()?, default_column_width: self.default_column_width()?, + default_column_display: self.default_column_display(), scrolling_focus_strategy: self.scrolling_focus_strategy(), column_width_presets: self.width_presets()?, animations_enabled: self.animations_enabled(), @@ -229,12 +230,20 @@ enum LayoutModeValue { #[derive(Debug, Default, Deserialize)] struct ScrollingConfig { + default_column_display: Option, default_column_width: Option, focus_strategy: Option, #[serde(default)] width_presets: Vec, } +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +enum ColumnDisplayValue { + Stacked, + Tabbed, +} + #[derive(Clone, Copy, Debug, Deserialize)] #[serde(rename_all = "snake_case")] enum FocusStrategyValue { @@ -1159,13 +1168,45 @@ mod tests { } } + #[test] + fn scrolling_column_display_default_is_validated() { + assert_eq!( + parse("").default_column_display(), + ferese_layout::ColumnDisplay::Stacked + ); + assert_eq!( + parse("scrolling { default-column-display \"tabbed\"; }\n").default_column_display(), + ferese_layout::ColumnDisplay::Tabbed + ); + assert!(Config::parse_source("scrolling { default-column-display \"unknown\"; }\n").is_err()); + } + + #[test] + fn tab_actions_validate_arguments() { + assert_eq!( + parse_action("join-tab", Some("left"), &HashMap::new()).unwrap(), + BindingAction::JoinTab(ferese_layout::Direction::Left) + ); + assert!(parse_action("join-tab", None, &HashMap::new()).is_err()); + assert!(parse_action("join-tab", Some("diagonal"), &HashMap::new()).is_err()); + for (name, action) in [ + ("toggle-column-tabbed", BindingAction::ToggleColumnTabbed), + ("leave-tab", BindingAction::LeaveTab), + ("next-tab", BindingAction::CycleTab(false)), + ("previous-tab", BindingAction::CycleTab(true)), + ] { + assert_eq!(parse_action(name, None, &HashMap::new()).unwrap(), action); + assert!(parse_action(name, Some("extra"), &HashMap::new()).is_err()); + } + } + #[test] fn supplies_complete_v0_bindings_and_terminal_command() { let config = parse(""); let input = config.input_settings().unwrap(); let bindings = config.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 59); + assert_eq!(bindings.len(), 62); for (shift, action) in [ (false, BindingAction::ToggleMaximized), (true, BindingAction::ToggleFullscreen), @@ -1176,6 +1217,10 @@ mod tests { && binding.action == action)); } assert!(bindings.iter().any(|binding| binding.action == BindingAction::Exit)); + assert!(bindings.iter().any(|binding| binding.modifiers.logo + && binding.modifiers.shift + && binding.trigger == BindingTrigger::Keysym(keysyms::KEY_t) + && binding.action == BindingAction::ToggleColumnTabbed)); assert!(bindings.iter().any(|binding| { binding.modifiers.logo && binding.trigger == BindingTrigger::Keysym(keysyms::KEY_Return) @@ -1209,7 +1254,7 @@ mod tests { let input = replaced.input_settings().unwrap(); let bindings = replaced.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 59); + assert_eq!(bindings.len(), 62); assert!(bindings.iter().any(|binding| { binding.action == BindingAction::Spawn(vec!["foot".to_owned(), "--app-id".to_owned(), "work".to_owned()].into()) @@ -1217,7 +1262,7 @@ mod tests { let input = unbound.input_settings().unwrap(); let bindings = unbound.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 58); + assert_eq!(bindings.len(), 61); assert!(!bindings.iter().any(|binding| binding.action == BindingAction::Close)); } diff --git a/compositor/src/config/bindings.rs b/compositor/src/config/bindings.rs index 3e4298f..755d970 100644 --- a/compositor/src/config/bindings.rs +++ b/compositor/src/config/bindings.rs @@ -333,6 +333,23 @@ pub(super) fn parse_action( no_argument()?; Ok(BindingAction::CenterColumn) } + "toggle-column-tabbed" => { + no_argument()?; + Ok(BindingAction::ToggleColumnTabbed) + } + "join-tab" => Ok(BindingAction::JoinTab(parse_direction(required_argument()?)?)), + "leave-tab" => { + no_argument()?; + Ok(BindingAction::LeaveTab) + } + "next-tab" => { + no_argument()?; + Ok(BindingAction::CycleTab(false)) + } + "previous-tab" => { + no_argument()?; + Ok(BindingAction::CycleTab(true)) + } "consume" => { no_argument()?; Ok(BindingAction::Consume) @@ -403,6 +420,9 @@ pub(super) fn default_bindings() -> Vec { binding("Super+C", "center-column", None), binding("Super+[", "consume", None), binding("Super+]", "expel", None), + binding("Super+Shift+T", "toggle-column-tabbed", None), + binding("Super+Ctrl+[", "previous-tab", None), + binding("Super+Ctrl+]", "next-tab", None), binding("Super+Shift+Space", "toggle-floating", None), binding("Super+V", "focus-floating", None), binding("Super+Tab", "toggle-overview", None), @@ -495,6 +515,10 @@ pub enum BindingAction { ToggleLayout, CycleColumnWidth, CenterColumn, + ToggleColumnTabbed, + JoinTab(ferese_layout::Direction), + LeaveTab, + CycleTab(bool), Consume, Expel, ToggleFloating, @@ -609,8 +633,12 @@ impl Binding { BindingAction::ToggleLayout => "Switch workspace layout".into(), BindingAction::CycleColumnWidth => "Cycle column width".into(), BindingAction::CenterColumn => "Center focused column".into(), - BindingAction::Consume => "Join window into column".into(), - BindingAction::Expel => "Move window out of column".into(), + BindingAction::ToggleColumnTabbed => "Toggle stacked / tabbed column".into(), + BindingAction::JoinTab(direction) => format!("Join tab group {direction:?}"), + BindingAction::LeaveTab => "Leave tab group".into(), + BindingAction::CycleTab(previous) => if *previous { "Previous tab" } else { "Next tab" }.into(), + BindingAction::Consume => "Join window into column or tab group".into(), + BindingAction::Expel => "Leave column or tab group".into(), BindingAction::ToggleFloating => "Toggle floating window".into(), BindingAction::Media(action) => match *action { "next" => "Next track", diff --git a/compositor/src/config/layout.rs b/compositor/src/config/layout.rs index ca0ecdd..b07b001 100644 --- a/compositor/src/config/layout.rs +++ b/compositor/src/config/layout.rs @@ -1,6 +1,17 @@ use super::*; impl Config { + pub fn default_column_display(&self) -> ferese_layout::ColumnDisplay { + match self + .scrolling + .default_column_display + .unwrap_or(ColumnDisplayValue::Stacked) + { + ColumnDisplayValue::Stacked => ferese_layout::ColumnDisplay::Stacked, + ColumnDisplayValue::Tabbed => ferese_layout::ColumnDisplay::Tabbed, + } + } + pub fn layout_mode(&self) -> LayoutMode { match self.layout.mode.unwrap_or(LayoutModeValue::Scrolling) { LayoutModeValue::Scrolling => LayoutMode::Scrolling, diff --git a/compositor/src/input.rs b/compositor/src/input.rs index f77734d..37c4f1b 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -550,6 +550,9 @@ impl Ferese { } return; } + if event.state() == ButtonState::Released && self.tab_buttons.remove(&event.button_code()) { + return; + } let pointer = self.seat.get_pointer().expect("seat has a pointer"); let serial = SERIAL_COUNTER.next_serial(); if self.session_lock.active() { @@ -580,6 +583,17 @@ impl Ferese { return; } + if event.state() == ButtonState::Pressed + && !pointer.is_grabbed() + && let Some(id) = self.tab_at(pointer.current_location()) + { + self.tab_buttons.insert(event.button_code()); + if event.button_code() == BTN_LEFT { + self.activate_managed_window(id); + } + return; + } + // Native popup_done destroys Iced's window immediately. For // effects-capable shell popups, release input now but defer // popup_done until the compositor's whole-surface fade ends. @@ -907,6 +921,9 @@ impl Ferese { self.focus_lock_surface(); return; } + if self.tab_at(position).is_some() { + return; + } let keyboard = self.seat.get_keyboard().expect("seat has a keyboard"); self.focus_output_at(position); @@ -1161,6 +1178,10 @@ impl Ferese { BindingAction::ToggleLayout => self.toggle_layout_mode(), BindingAction::CycleColumnWidth => self.cycle_focused_column_width(), BindingAction::CenterColumn => self.center_focused_column(), + BindingAction::ToggleColumnTabbed => self.toggle_column_tabbed(), + BindingAction::JoinTab(direction) => self.join_tab(direction), + BindingAction::LeaveTab => self.leave_tab(), + BindingAction::CycleTab(previous) => self.cycle_tab(previous), BindingAction::Consume => self.consume_focused_window(), BindingAction::Expel => self.expel_focused_window(), BindingAction::ToggleFloating => self.toggle_focused_floating(), diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index eb6509c..6935262 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -1084,6 +1084,11 @@ impl Ferese { } "cycle-column-width" => self.cycle_focused_column_width(), "center-column" => self.center_focused_column(), + "toggle-column-tabbed" => self.toggle_column_tabbed(), + "join-tab" => self.join_tab(direction_arg(args)?), + "leave-tab" => self.leave_tab(), + "next-tab" => self.cycle_tab(false), + "previous-tab" => self.cycle_tab(true), "consume" => self.consume_focused_window(), "expel" => self.expel_focused_window(), "close" => self.close_focused_window(), diff --git a/compositor/src/overview.rs b/compositor/src/overview.rs index 353b74b..b08ff97 100644 --- a/compositor/src/overview.rs +++ b/compositor/src/overview.rs @@ -44,7 +44,7 @@ pub(crate) fn init_font_loader( event_loop.handle().insert_source(source, |event, _, state| { if let channel::Event::Msg((family, font)) = event && state.overview.complete_font_load(&family, font) - && state.overview.is_presenting() + && (state.overview.is_presenting() || state.tab_bars.values().any(|bars| !bars.is_empty())) { crate::backends::direct::render_all(state); } @@ -60,20 +60,7 @@ pub(crate) fn init_font_loader( // Rescan on a font change so newly installed fonts remain usable. let mut database = fontdb::Database::new(); database.load_system_fonts(); - let families = [fontdb::Family::Name(&family), fontdb::Family::SansSerif]; - let font = database - .query(&fontdb::Query { - families: &families, - ..Default::default() - }) - .and_then(|id| { - database.with_face_data(id, |bytes, index| { - FontVec::try_from_vec_and_index(bytes.to_vec(), index) - .ok() - .map(FontArc::new) - }) - }) - .flatten(); + let font = label_font(&database, &family); if results.send((family, font)).is_err() { break; } @@ -85,6 +72,32 @@ pub(crate) fn init_font_loader( Ok(()) } +// fontdb's generic sans may name a font that is absent on this system. +// Resolve the requested family first, then use an installed text face. +fn label_font(database: &fontdb::Database, family: &str) -> Option { + let families = [ + fontdb::Family::Name(family), + fontdb::Family::SansSerif, + fontdb::Family::Name("Noto Sans"), + fontdb::Family::Name("DejaVu Sans"), + fontdb::Family::Name("Liberation Sans"), + ]; + let preferred = database.query(&fontdb::Query { + families: &families, + ..Default::default() + }); + preferred + .into_iter() + .chain(database.faces().map(|face| face.id)) + .find_map(|id| { + database.with_face_data(id, |bytes, index| { + let font = FontVec::try_from_vec_and_index(bytes.to_vec(), index).ok()?; + // Skip symbol-only fonts when searching the fallback faces. + (font.glyph_id('A').0 != 0 && font.glyph_id('0').0 != 0).then(|| FontArc::new(font)) + })? + }) +} + type LabelBuffer = (MemoryRenderBuffer, smithay::utils::Size); type LabelStyle = (u64, [u32; 4]); type OverviewStateLabel = HashMap>; @@ -1177,6 +1190,14 @@ mod tests { font.expect("overview rendering tests require a system font") } + #[test] + fn missing_label_font_falls_back_to_an_installed_text_font() { + let mut database = fontdb::Database::new(); + database.load_system_fonts(); + assert!(label_font(&database, "ferese-missing-font-fixture").is_some()); + assert!(label_font(&fontdb::Database::new(), "sans-serif").is_none()); + } + #[test] fn font_reload_keeps_labels_until_current_result_arrives() { let mut overview = OverviewState::with_font_family("first".into()); diff --git a/compositor/src/render/mod.rs b/compositor/src/render/mod.rs index f66b228..cd25a5b 100644 --- a/compositor/src/render/mod.rs +++ b/compositor/src/render/mod.rs @@ -11,6 +11,7 @@ mod overview; mod resources; mod scene; mod shader; +mod tabs; use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -329,6 +330,8 @@ pub(crate) enum OverviewChromePart { Outline, Strip, Caption, + Tab, + TabActive, } #[derive(Debug)] diff --git a/compositor/src/render/overview.rs b/compositor/src/render/overview.rs index 906bea1..940cf8c 100644 --- a/compositor/src/render/overview.rs +++ b/compositor/src/render/overview.rs @@ -122,7 +122,9 @@ pub(super) fn overview_strip_elements( && let Some(cache) = state.render.outputs.get_mut(&output_id) { cache.chrome.retain(|(id, part), _| match part { - OverviewChromePart::Caption => state.windows.window(ferese_layout::WindowId(*id)).is_some(), + OverviewChromePart::Caption | OverviewChromePart::Tab | OverviewChromePart::TabActive => { + state.windows.window(ferese_layout::WindowId(*id)).is_some() + } OverviewChromePart::Card | OverviewChromePart::Outline => card_ids.contains(id), OverviewChromePart::Strip => true, }); diff --git a/compositor/src/render/resources.rs b/compositor/src/render/resources.rs index 9a20120..000f966 100644 --- a/compositor/src/render/resources.rs +++ b/compositor/src/render/resources.rs @@ -69,6 +69,11 @@ impl RenderResources { pub fn remove_window(&mut self, id: WindowId) { self.windows.remove(&id); + for output in self.outputs.values_mut() { + output.chrome.retain(|(key, part), _| { + *key != id.0 || !matches!(part, OverviewChromePart::Tab | OverviewChromePart::TabActive) + }); + } } pub fn remove_output(&mut self, id: OutputId) { diff --git a/compositor/src/render/scene.rs b/compositor/src/render/scene.rs index 9d29e3d..fff1462 100644 --- a/compositor/src/render/scene.rs +++ b/compositor/src/render/scene.rs @@ -195,6 +195,7 @@ fn scene_elements( if let Some(group) = closing.remove(&Some(id)) { elements.extend(group); } + elements.extend(super::tabs::tab_elements(state, renderer, output, frame, id, capture)); let constrain = rounded_visual_rect(visual, output_geometry.loc); let material_surface = window .toplevel() diff --git a/compositor/src/render/tabs.rs b/compositor/src/render/tabs.rs new file mode 100644 index 0000000..e0ee58b --- /dev/null +++ b/compositor/src/render/tabs.rs @@ -0,0 +1,123 @@ +use super::*; + +pub(super) fn tab_elements( + state: &mut Ferese, + renderer: &mut GlesRenderer, + output: &Output, + frame: &crate::state::FrameScene, + active: ferese_layout::WindowId, + capture: bool, +) -> Vec { + if frame.overview.is_presenting() { + return Vec::new(); + } + let Some(workspace) = state.workspaces.workspace_for_window(active) else { + return Vec::new(); + }; + let Some(bar) = state + .visible_tab_bars(workspace) + .find(|bar| bar.group.active == active) + .cloned() + else { + return Vec::new(); + }; + let Some(sample) = frame.windows.get(&active) else { + return Vec::new(); + }; + if bar.rect.height <= 0.0 { + return Vec::new(); + } + let content = sample.presentation.bounds.current; + let alpha = sample.presentation.alpha(); + let Some(output_geometry) = state.space.output_geometry(output) else { + return Vec::new(); + }; + let scale = output.current_scale().fractional_scale(); + let width = content.width / bar.group.windows.len() as f64; + let mut elements = Vec::new(); + state.retain_overview_labels(); + for (index, id) in bar.group.windows.iter().copied().enumerate() { + let rect = ferese_layout::Rect::new( + content.x + width * index as f64, + content.y - bar.rect.height, + width, + bar.rect.height, + ); + let protected = capture + && state + .windows + .window(id) + .is_none_or(|window| state.capture_protected(window)); + if !protected + && width > 20.0 + && let Some((buffer, size)) = state.overview_window_label(id, scale, width) + { + let location = Point::::from(( + ((rect.x - f64::from(output_geometry.loc.x) + 8.0) * scale).round() as i32, + ((rect.y - f64::from(output_geometry.loc.y) + rect.height * 0.5) * scale).round() as i32 - size.h / 2, + )); + if let Ok(element) = MemoryRenderBufferRenderElement::from_buffer( + renderer, + location.to_f64(), + &buffer, + Some(alpha), + Some(Rectangle::from_size((f64::from(size.w), f64::from(size.h)).into())), + Some( + ( + (f64::from(size.w) / scale).round() as i32, + (f64::from(size.h) / scale).round() as i32, + ) + .into(), + ), + RenderElementKind::Unspecified, + ) { + let element = RescaleRenderElement::from_element(element, location, 1.0); + let element = RelocateRenderElement::from_element(element, (0, 0), Relocate::Relative); + let clip = ferese_layout::Rect::new(rect.x + 8.0, rect.y, (rect.width - 16.0).max(0.0), rect.height); + if let Some(element) = + CropRenderElement::from_element(element, scale, physical_rect(clip, output_geometry.loc, scale)) + { + elements.push(element.into()); + } + } + } + if id == active { + let thickness = rect.height.min(2.0); + let underline = ferese_layout::Rect::new(rect.x, rect.y + rect.height - thickness, rect.width, thickness); + if let Some(element) = overview_chrome_element( + state, + renderer, + output, + output_geometry, + scale, + (id.0, OverviewChromePart::TabActive), + underline, + color_with_alpha(state.theme_settings.accent_color.0, alpha), + 0.0, + 0.0, + ) { + elements.push(element.into()); + } + } + let color = if id == active { + state.theme_settings.surface_raised_color.0 + } else { + state.theme_settings.surface_base_color.0 + }; + if let Some(element) = overview_chrome_element( + state, + renderer, + output, + output_geometry, + scale, + (id.0, OverviewChromePart::Tab), + rect, + color_with_alpha(color, alpha), + 0.0, + 0.0, + ) { + elements.push(element.into()); + } + } + elements +} diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 781ef4a..f42f06f 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -7,6 +7,7 @@ mod layout; mod navigation; mod outputs; mod reconciliation; +mod tabs; #[cfg(test)] mod window_lifecycle_tests; pub(crate) use reconciliation::{DesktopChanges, DesktopOutput, DesktopTransition}; @@ -455,6 +456,8 @@ pub struct Ferese { workspace_slides: HashMap, workspace_slide_offsets: HashMap, pub focused_window: Option, + pub(crate) tab_bars: HashMap>, + pub(crate) tab_buttons: HashSet, pub(crate) focus_history: ferese_core::FocusHistory, pub(crate) focus_cycle: Option, column_width_presets: Vec, @@ -572,6 +575,7 @@ pub struct RuntimeConfig { pub(crate) panel_style: ferese_panel::PanelStyle, pub focus_effect: crate::config::FocusEffectSettings, pub default_column_width: ColumnWidth, + pub default_column_display: ferese_layout::ColumnDisplay, pub scrolling_focus_strategy: ViewportFocusStrategy, pub column_width_presets: Vec, pub animations_enabled: bool, @@ -748,6 +752,8 @@ impl Ferese { workspace_slides: HashMap::new(), workspace_slide_offsets: HashMap::new(), focused_window: None, + tab_bars: HashMap::new(), + tab_buttons: HashSet::new(), focus_history: Default::default(), focus_cycle: None, column_width_presets: config.column_width_presets, @@ -846,6 +852,9 @@ impl Ferese { state.screenshot_worker = Some(screenshot.worker); state._ipc_socket = Some(screenshot._guard); + state + .workspaces + .set_default_column_display(config.default_column_display); Ok(state) } @@ -951,6 +960,8 @@ impl Ferese { // Hidden layouts are resolved independently of presented geometry. self.workspace_preview_revision = self.workspace_preview_revision.wrapping_add(1); } + self.workspaces + .set_default_column_display(config.default_column_display); self.gap_config = config.gap_config; self.input_settings = config.input_settings; if bindings_changed || input_changed { diff --git a/compositor/src/state/hit_testing.rs b/compositor/src/state/hit_testing.rs index 3099149..2786754 100644 --- a/compositor/src/state/hit_testing.rs +++ b/compositor/src/state/hit_testing.rs @@ -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)) diff --git a/compositor/src/state/layout.rs b/compositor/src/state/layout.rs index af46a14..cc4e3cf 100644 --- a/compositor/src/state/layout.rs +++ b/compositor/src/state/layout.rs @@ -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) -> 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) { diff --git a/compositor/src/state/navigation.rs b/compositor/src/state/navigation.rs index 0990124..45cc88d 100644 --- a/compositor/src/state/navigation.rs +++ b/compositor/src/state/navigation.rs @@ -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; }; diff --git a/compositor/src/state/tabs.rs b/compositor/src/state/tabs.rs new file mode 100644 index 0000000..a5a217f --- /dev/null +++ b/compositor/src/state/tabs.rs @@ -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 { + 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) -> Option { + 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 { + 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); + } + } +} diff --git a/compositor/src/state/window_lifecycle_tests.rs b/compositor/src/state/window_lifecycle_tests.rs index cca2501..4e85d7e 100644 --- a/compositor/src/state/window_lifecycle_tests.rs +++ b/compositor/src/state/window_lifecycle_tests.rs @@ -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]); +} diff --git a/crates/ferese-core/src/workspace.rs b/crates/ferese-core/src/workspace.rs index 3094d29..32a7ed3 100644 --- a/crates/ferese-core/src/workspace.rs +++ b/crates/ferese-core/src/workspace.rs @@ -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 { + 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 { + 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 { 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); + } +} diff --git a/crates/ferese-layout/src/lib.rs b/crates/ferese-layout/src/lib.rs index ffac1f3..c8a8061 100644 --- a/crates/ferese-layout/src/lib.rs +++ b/crates/ferese-layout/src/lib.rs @@ -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, + 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, pub warnings: Vec, + pub tab_bars: Vec, } 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 { 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 { + 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 { + let Node::Stack { children, active } = self.nodes.get(&node)? else { + return None; + }; + let windows: Option> = 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 { + self.group_at(*self.parents.get(self.windows.get(&window)?)?) + } + + pub fn tab_groups(&self) -> Vec { + 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 { + 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 { + 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()); + } +} diff --git a/crates/ferese-layout/src/scrolling.rs b/crates/ferese-layout/src/scrolling.rs index 38a1a90..f31e738 100644 --- a/crates/ferese-layout/src/scrolling.rs +++ b/crates/ferese-layout/src/scrolling.rs @@ -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, + 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, viewport_x: f64, default_width: ColumnWidth, + default_display: ColumnDisplay, focus_strategy: ViewportFocusStrategy, pending_viewport: Option, last_column_sizing: Option, @@ -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 { + 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 { + 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); + } + } +} diff --git a/docs/configuration.md b/docs/configuration.md index 152e8d8..93a676f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -113,6 +113,7 @@ numbers must be finite. Omit optional values rather than writing `null`, use | `scrolling` key | Type / values | Default | Meaning | | --- | --- | --- | --- | +| `default-column-display` | `"stacked"`, `"tabbed"` | `"stacked"` | Display mode of new columns; reload preserves existing column choices | | `default-column-width` | proportion > 0; or `"full"` | `0.5` | Width of new columns | | `focus-strategy` | `"minimal"`, `"center_on_focus"`, `"paged"` | `"minimal"` | Viewport movement on focus | | `width-presets` | array of widths | `[0.3333333333333333, 0.5, 0.6666666666666666, "full"]` | Super+R cycle; empty uses defaults | @@ -526,7 +527,7 @@ invalid. A disabled binding must omit `action` and `argument`. | --- | --- | | `spawn` | Name in `commands` | | `binding-mode` | Named mode, or `"default"` to leave it | -| `focus`, `move`, `resize` | `"left"`, `"right"`, `"up"`, `"down"` | +| `focus`, `move`, `resize`, `join-tab` | `"left"`, `"right"`, `"up"`, `"down"` | | `workspace`, `move-to-workspace` | Workspace number string, 1–255 | | `workspace-next`, `workspace-previous` | None; next/previous workspace on this monitor | | `workspace-back-and-forth` | None; return to the previous workspace on this monitor | @@ -534,7 +535,7 @@ invalid. A disabled binding must omit `action` and `argument`. | `focus-floating` | None; focus the last focused window in the opposite tiled/floating layer on this workspace | | `focus-mru-next`, `focus-mru-previous` | None; cycle windows in focus order | | `none` | None; ignore this trigger | -| `close`, `exit`, `toggle-maximized`, `toggle-fullscreen`, `toggle-layout`, `cycle-column-width`, `center-column`, `consume`, `expel`, `toggle-floating`, `toggle-overview` | None | +| `close`, `exit`, `toggle-maximized`, `toggle-fullscreen`, `toggle-layout`, `cycle-column-width`, `center-column`, `consume`, `expel`, `toggle-column-tabbed`, `leave-tab`, `next-tab`, `previous-tab`, `toggle-floating`, `toggle-overview` | None | ```kdl commands { @@ -544,6 +545,45 @@ binding keys="Super+Enter" action="spawn" argument="terminal" binding keys="Super+V" action="focus-floating" ``` +`join-tab` moves the focused window into the group in the given direction. In +tree layout, `consume` chooses a neighbour in left, right, up, down order. +`leave-tab` splits the focused tab out to the right of its group. `next-tab` and +`previous-tab` cycle within the group and wrap at either end. + +In scrolling layout, **Super+Shift+T** (`toggle-column-tabbed`) switches the +focused column between stacked rows and tabs. Toggling back restores its row +sizes. `consume` adds a window using the destination column's display mode; +`expel` separates it into a column of its own. Up/down focus switches tabs, +while left/right focus moves between columns. Tab selection preserves the +column's width and scroll position. Vertical resizing is inactive in tabbed mode. +`join-tab` explicitly makes the destination column tabbed, including when joining +two rows that already share a column. + +The tab bar shows window titles above the active window and reserves space +inside its allocation. Click a title to switch. New windows open beside the +group. Closing, floating, or moving a tab to another workspace removes it from +the group; a single remaining window loses its tab bar. Maximized and fullscreen +windows hide the bar. Fullscreen remains per-window: restore before grouping or +switching tabs. Switching layout modes keeps every window but dissolves groups. + +To make future scrolling columns tabbed by default: + +```kdl +scrolling { + default-column-display "tabbed" +} +``` + +The same actions are available through `feresectl`: + +```sh +feresectl toggle-column-tabbed +feresectl join-tab left +feresectl next-tab +feresectl previous-tab +feresectl leave-tab +``` + `focus-floating` switches focus between tiled and floating windows without changing placement. It stays on the active workspace and does nothing when the opposite layer has no candidate. `toggle-floating` changes window placement. @@ -610,7 +650,8 @@ matches. A mode/configuration transition stops that action's repeats. Defaults: Super+Enter terminal; Super+Q close; Super+H/J/K/L focus; Super+Shift+H/J/K/L move; Super+Ctrl+H/J/K/L resize; Super+1–9 workspace; Super+Shift+1–9 move to workspace; Super+R width cycle; Super+C center; -Super+[/] consume/expel; Super+F maximize; Super+Shift+F fullscreen; +Super+[/] consume/expel; Super+Shift+T stacked/tabbed column; Super+Ctrl+[/] previous/next tab; +Super+F maximize; Super+Shift+F fullscreen; Super+M layout; Super+Shift+Space floating; Super+V focus tiled/floating; Super+Tab overview; Super+Escape previous visited workspace; Super+BackSpace last focused window; Alt+Tab/Alt+Shift+Tab MRU switching; diff --git a/docs/development.md b/docs/development.md index 2cac462..35402e1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -79,10 +79,14 @@ FERESE_TEST_WINDOW_CAPTURE=1 python3 scripts/tests/test_window_capture_isolated. FERESE_TEST_INHIBIT=1 python3 scripts/tests/test_inhibit_isolated.py FERESE_TEST_RESTORE=1 python3 scripts/tests/test_restore_isolated.py FERESE_TEST_WINDOW_STREAM=1 python3 scripts/tests/test_window_stream_isolated.py +FERESE_TEST_TREE_TABS=1 python3 scripts/tests/test_tree_tabs_isolated.py ``` These checks use disposable nested sessions or private buses to test shortcut -cleanup, window capture, inhibitors, restored permissions and live window streams. +cleanup, window capture, inhibitors, restored permissions, live window streams, +and tree and scrolling tab groups. The tab test checks real terminal windows and rendered title +pixels; `FERESE_TEST_TABS_SCREENSHOT=/tmp/tabs.png` saves screenshots with `-tree` and +`-scrolling` suffixes. Check each script's dependencies before running it. For monitor streaming and lock revocation, install Python GI/GStreamer with diff --git a/docs/ipc.md b/docs/ipc.md index df8042a..b5d1750 100644 --- a/docs/ipc.md +++ b/docs/ipc.md @@ -56,6 +56,12 @@ width/height with left/up and grows them with right/down by 32 logical pixels, subject to client size limits. Fullscreen and maximized floating windows ignore these actions. +`toggle-column-tabbed` changes a scrolling column between stacked rows and tabs. +In either layout, `join-tab left|right|up|down` joins the focused window to a +neighbouring tab group. `next-tab` and `previous-tab` switch tabs, and `leave-tab` +splits the focused tab out. See [Commands and bindings](configuration.md#commands-and-bindings) +for grouping behavior and shortcuts. + ## Live Activities Use `feresectl run --title "Tests" -- cargo test` to publish a command's lifetime, diff --git a/docs/shortcuts.md b/docs/shortcuts.md index 4e6c8d3..259f9e6 100644 --- a/docs/shortcuts.md +++ b/docs/shortcuts.md @@ -49,14 +49,23 @@ two most recently focused windows. | --- | --- | | Super + R | Cycle column width | | Super + C | Center the focused column | -| Super + [ | Place the window in a neighbouring column | -| Super + ] | Extract the window into its own column | +| Super + [ | Join a neighbouring column or tree tab group | +| Super + ] | Leave the column or tree tab group | +| Super + Ctrl + [ / ] | Previous / next tab in a group | +| Super + Shift + T | Toggle a scrolling column between stacked rows and tabs | | Super + M | Switch scrolling / tree layout | | Super + Tab | Open or close overview | In scrolling mode, **Super + [** uses the left neighbour when available, otherwise the right. Try it with two terminals to place them vertically in one column. -**Super + ]** separates the focused window again. +**Super + ]** separates the focused window again. **Super + Shift + T** changes +the column to tabs or restores its stacked rows. In tabbed columns, **Super + J/K** +switches tabs; the usual left/right focus keys still switch columns. + +In tree mode, **Super + [** joins the focused window to a neighbouring window +as a tab. Click a title in the tab bar or use **Super + Ctrl + [ / ]** to switch +tabs. **Super + ]** splits the focused tab out beside the group. For a specific +neighbour, bind `join-tab` with a direction; see [Commands and bindings](configuration.md#commands-and-bindings). In overview, select a workspace to see its windows, then click the window you want. Directional focus keys move selection; Enter activates it and Escape closes diff --git a/feresectl/src/cli.rs b/feresectl/src/cli.rs index af75b49..6e5a29a 100644 --- a/feresectl/src/cli.rs +++ b/feresectl/src/cli.rs @@ -70,9 +70,19 @@ pub enum Command { CycleColumnWidth, /// Center the focused column. CenterColumn, - /// Pull a window into the focused column. + /// Toggle the focused scrolling column between stacked windows and tabs. + ToggleColumnTabbed, + /// Join the focused window to the adjacent tab group. + JoinTab { direction: Direction }, + /// Split the focused tab out beside its group. + LeaveTab, + /// Focus the next tab in the group. + NextTab, + /// Focus the previous tab in the group. + PreviousTab, + /// Join a neighboring column or tree tab group. Consume, - /// Move a window out of the focused column. + /// Leave the focused column or tree tab group. Expel, /// Close the focused window. Close, @@ -244,6 +254,11 @@ impl Command { Self::ToggleKeybindingGuide => ("toggle-keybinding-guide", json!({})), Self::CycleColumnWidth => ("cycle-column-width", json!({})), Self::CenterColumn => ("center-column", json!({})), + Self::ToggleColumnTabbed => ("toggle-column-tabbed", json!({})), + Self::JoinTab { direction } => ("join-tab", json!({"direction": value_name(direction)})), + Self::LeaveTab => ("leave-tab", json!({})), + Self::NextTab => ("next-tab", json!({})), + Self::PreviousTab => ("previous-tab", json!({})), Self::Consume => ("consume", json!({})), Self::Expel => ("expel", json!({})), Self::Close => ("close", json!({})), diff --git a/feresectl/src/main.rs b/feresectl/src/main.rs index 6a1e83f..8784026 100644 --- a/feresectl/src/main.rs +++ b/feresectl/src/main.rs @@ -365,6 +365,22 @@ mod tests { assert!(parse_args(["output-profile".into()]).is_err()); } + #[test] + fn tab_commands_validate_directions_and_reject_extra_arguments() { + for direction in ["left", "right", "up", "down"] { + assert_eq!( + parse_args(["join-tab".into(), direction.into()]).unwrap(), + ("join-tab".into(), json!({"direction": direction})) + ); + } + assert!(parse_args(["join-tab".into()]).is_err()); + assert!(parse_args(["join-tab".into(), "diagonal".into()]).is_err()); + for action in ["toggle-column-tabbed", "leave-tab", "next-tab", "previous-tab"] { + assert_eq!(parse_args([action.into()]).unwrap(), (action.into(), json!({}))); + assert!(parse_args([action.into(), "extra".into()]).is_err()); + } + } + #[test] fn rejects_unknown_or_missing_arguments() { assert_eq!(parse_args(["exit".to_owned()]).unwrap(), ("exit".to_owned(), json!({}))); diff --git a/scripts/tests/test_tree_tabs_isolated.py b/scripts/tests/test_tree_tabs_isolated.py new file mode 100644 index 0000000..1076ae0 --- /dev/null +++ b/scripts/tests/test_tree_tabs_isolated.py @@ -0,0 +1,155 @@ +"""Exercise tree and scrolling tabs with real clients on a private nested desktop. + +FERESE_TEST_TREE_TABS=1 python3 scripts/tests/test_tree_tabs_isolated.py +Requires foot, dbus-daemon, Pillow, and a Wayland host. FERESE_TEST_BINARY and +FERESE_TEST_CTL may point to build artifacts outside target/debug. +""" +import io +import json +import os +from pathlib import Path +from session_socket import ipc_environment, ipc_socket +import signal +import subprocess +import tempfile +import time +import unittest + + +@unittest.skipUnless(os.environ.get("FERESE_TEST_TREE_TABS") == "1", "nested tab test is opt-in") +class TabGroupsTest(unittest.TestCase): + def test_tree_join_cycle_leave_and_close(self): + self.exercise("tree") + + def test_scrolling_join_cycle_leave_and_close(self): + self.exercise("scrolling") + + def exercise(self, mode): + from PIL import Image + + repo = Path(__file__).resolve().parents[2] + binary = repo / os.environ.get("FERESE_TEST_BINARY", "target/debug/ferese") + ctl = repo / os.environ.get("FERESE_TEST_CTL", "target/debug/feresectl") + children = [] + with tempfile.TemporaryDirectory(prefix="ferese-tabs-") as temporary: + root = Path(temporary) + runtime = root / "runtime" + runtime.mkdir(mode=0o700) + config = root / "config/ferese" + config.mkdir(parents=True) + (config / "config.kdl").write_text(f'layout {{ mode "{mode}"; }}\nanimations {{ enabled #false; }}\n') + host = Path(os.environ["WAYLAND_DISPLAY"]) + if not host.is_absolute(): + host = Path(os.environ["XDG_RUNTIME_DIR"]) / host + env = dict(os.environ, WAYLAND_DISPLAY=str(host), XDG_RUNTIME_DIR=str(runtime), + XDG_CONFIG_HOME=str(root / "config"), XDG_STATE_HOME=str(root / "state"), + XDG_DATA_HOME=str(root / "data")) + env.pop("WAYLAND_SOCKET", None) + env.pop("FERESE_SHELL_CONTROL_SOCKET", None) + log_path = root / "compositor.log" + + def launch(command, **kwargs): + child = subprocess.Popen(command, env=ipc_environment(env), start_new_session=True, **kwargs) + children.append(child) + return child + + def command(*args): + return json.loads(subprocess.check_output([str(ctl), "-j", *args], + env=ipc_environment(env), timeout=10)) + + def wait_for(predicate): + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + result = predicate() + if result: + return result + time.sleep(.025) + self.fail("Timed out:\n" + log_path.read_text()) + + def windows(): + return command("windows") + + def mapped(): + return [w for w in windows() if w["mapped"]] + + with log_path.open("w") as log: + try: + bus = launch(["dbus-daemon", "--session", "--nofork", "--print-address=1"], + stdout=subprocess.PIPE, stderr=log, text=True) + env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip() + launch([str(binary), "--backend", "nested"], stdout=log, stderr=log) + wait_for(lambda: ipc_socket(runtime).is_socket()) + env["WAYLAND_DISPLAY"] = str(next(p for p in runtime.glob("wayland-*") if p.is_socket())) + for index, title in enumerate(("Editor", "Build", "Logs"), 1): + launch(["foot", "--app-id", f"ferese.test.tabs.{index}", "--title", title, + "sh", "-c", f"printf '{title}\\n'; sleep 120"], stdout=log, stderr=log) + wait_for(lambda: len(windows()) == index and len(mapped()) >= 1) + if index == 1 and mode == "scrolling": + command("toggle-column-tabbed") + if index > 1: + command("consume") + wait_for(lambda: len(mapped()) == 1) + ids = [w["id"] for w in windows()] + self.assertEqual(command("focused-window")["id"], ids[-1]) + for expected in ids: + command("next-tab") + wait_for(lambda: len(mapped()) == 1 and mapped()[0]["id"] == expected) + command("previous-tab") + self.assertEqual(command("focused-window")["id"], ids[1]) + # New windows split beside the group; they must not become hidden tabs. + launch(["foot", "--title", "Neighbor", "sleep", "120"], stdout=log, stderr=log) + wait_for(lambda: len(windows()) == 4 and len(mapped()) == 2) + neighbor = command("focused-window")["id"] + neighbor_rect = next(w for w in mapped() if w["id"] == neighbor) + command("focus-last-window") + command("next-tab") + wait_for(lambda: len(mapped()) == 2) + current_neighbor = next(w for w in mapped() if w["id"] == neighbor) + for key in ("x", "y", "width", "height"): + self.assertEqual(current_neighbor[key], neighbor_rect[key]) + # Inspect actual text pixels; mapped windows alone do not prove the bar rendered. + active = next(w for w in mapped() if w["focused"]) + scale = command("outputs")[0]["scale"] + captured = None + + def labels_rendered(): + nonlocal captured + captured = subprocess.check_output([str(ctl), "screenshot"], + env=ipc_environment(env), stderr=log, timeout=10) + image = Image.open(io.BytesIO(captured)).convert("RGB") + for index in range(3): + left = active["x"] + active["width"] * index / 3 + 4 + right = left + min(100, active["width"] / 3 - 8) + box = (round(left * scale), round((active["y"] - 25) * scale), + round(right * scale), round((active["y"] - 5) * scale)) + pixels = image.crop(box).tobytes() + if not any(min(pixels[i:i + 3]) > 130 for i in range(0, len(pixels), 3)): + return False + return True + + wait_for(labels_rendered) + if screenshot := os.environ.get("FERESE_TEST_TABS_SCREENSHOT"): + path = Path(screenshot) + path.with_stem(path.stem + "-" + mode).write_bytes(captured) + command("leave-tab") + wait_for(lambda: len(mapped()) == 3) + command("close") + wait_for(lambda: len(windows()) == 3 and len(mapped()) == 2) + command("toggle-layout") + wait_for(lambda: len(mapped()) == 3) + finally: + for child in reversed(children): + if child.poll() is None: + os.killpg(child.pid, signal.SIGTERM) + for child in reversed(children): + try: + child.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(child.pid, signal.SIGKILL) + child.wait(timeout=5) + if bus.stdout: + bus.stdout.close() + + +if __name__ == "__main__": + unittest.main()