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

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

No files matched your search

+48 -3
View File
@@ -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<ColumnDisplayValue>,
default_column_width: Option<ColumnWidthValue>,
focus_strategy: Option<FocusStrategyValue>,
#[serde(default)]
width_presets: Vec<ColumnWidthValue>,
}
#[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));
}
+30 -2
View File
@@ -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<BindingConfig> {
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",
+11
View File
@@ -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,
+21
View File
@@ -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(),
+5
View File
@@ -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(),
+36 -15
View File
@@ -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<FontArc> {
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<i32, smithay::utils::Buffer>);
type LabelStyle = (u64, [u32; 4]);
type OverviewStateLabel = HashMap<String, HashMap<LabelStyle, LabelBuffer>>;
@@ -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());
+3
View File
@@ -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)]
+3 -1
View File
@@ -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,
});
+5
View File
@@ -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) {
+1
View File
@@ -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()
+123
View File
@@ -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<AnimatedWindowRenderElement> {
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::<i32, Physical>::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
}
+11
View File
@@ -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<OutputId, WorkspaceSlide>,
workspace_slide_offsets: HashMap<WorkspaceId, (f64, f64)>,
pub focused_window: Option<WindowId>,
pub(crate) tab_bars: HashMap<WorkspaceId, Vec<ferese_layout::TabBar>>,
pub(crate) tab_buttons: HashSet<u32>,
pub(crate) focus_history: ferese_core::FocusHistory,
pub(crate) focus_cycle: Option<ferese_core::FocusCycle>,
column_width_presets: Vec<ColumnWidth>,
@@ -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<ColumnWidth>,
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 {
+3
View File
@@ -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))
+39 -8
View File
@@ -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) {
+21 -1
View File
@@ -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;
};
+144
View File
@@ -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]);
}
+161 -3
View File
@@ -50,6 +50,13 @@ impl WorkspaceLayout {
}
}
fn with_column_display(mut self, display: ferese_layout::ColumnDisplay) -> Self {
if let Self::Scrolling(layout) = &mut self {
layout.set_default_display(display);
}
self
}
pub fn mode(&self) -> LayoutMode {
match self {
Self::Scrolling(_) => LayoutMode::Scrolling,
@@ -111,6 +118,20 @@ impl WorkspaceLayout {
}
}
pub fn tab_group(&self, window: WindowId) -> Option<ferese_layout::TabGroup> {
match self {
Self::Tree(tree) => tree.tab_group(window),
Self::Scrolling(layout) => layout.tab_group(window),
}
}
pub fn adjacent_tab(&self, window: WindowId, previous: bool) -> Option<WindowId> {
match self {
Self::Tree(tree) => tree.adjacent_tab(window, previous),
Self::Scrolling(layout) => layout.adjacent_tab(window, previous),
}
}
pub fn stack_window(&mut self, window: WindowId, target: WindowId) -> Result<(), LayoutError> {
match self {
Self::Scrolling(layout) => layout.move_into_column(window, target),
@@ -158,7 +179,32 @@ impl WorkspaceLayout {
constraints,
focused.filter(|window| layout.contains(*window)),
),
Self::Tree(layout) => layout.geometry_with_constraints(bounds, gaps, constraints, focused),
Self::Tree(layout) => {
let groups = layout.tab_groups();
if groups.is_empty() {
return layout.geometry_with_constraints(bounds, gaps, constraints, focused);
}
const TAB_HEIGHT: f64 = ferese_layout::TAB_BAR_HEIGHT;
let mut constraints = constraints.clone();
for group in &groups {
for id in &group.windows {
let constraint = constraints.entry(*id).or_default();
constraint.min_height += TAB_HEIGHT;
constraint.max_height = constraint.max_height.map(|height| height + TAB_HEIGHT);
}
}
let mut result = layout.geometry_with_constraints(bounds, gaps, &constraints, focused)?;
for group in groups {
if let Some(rect) = result.geometry.get_mut(&group.active) {
let height = TAB_HEIGHT.min((rect.height - 1.0).max(0.0));
let header = Rect::new(rect.x, rect.y, rect.width, height);
rect.y += height;
rect.height -= height;
result.tab_bars.push(ferese_layout::TabBar { group, rect: header });
}
}
Ok(result)
}
}
}
@@ -206,7 +252,7 @@ impl WorkspaceLayout {
layout.extract_to_column(window)?;
Ok(grouped)
}
Self::Tree(_) => Ok(false),
Self::Tree(layout) => layout.leave_tab_group(window),
}
}
@@ -345,6 +391,7 @@ pub struct WorkspaceSet {
default_layout_mode: LayoutMode,
default_column_width: ColumnWidth,
scrolling_focus_strategy: ViewportFocusStrategy,
default_column_display: ferese_layout::ColumnDisplay,
next_id: u64,
}
@@ -382,10 +429,21 @@ impl WorkspaceSet {
default_layout_mode,
default_column_width,
scrolling_focus_strategy,
default_column_display: ferese_layout::ColumnDisplay::Stacked,
next_id: 2,
}
}
/// Applies to future columns, including those in future workspaces.
pub fn set_default_column_display(&mut self, display: ferese_layout::ColumnDisplay) {
self.default_column_display = display;
for workspace in self.workspaces.values_mut() {
if let WorkspaceLayout::Scrolling(layout) = &mut workspace.layout {
layout.set_default_display(display);
}
}
}
pub fn reconfigure_live(
&mut self,
mode: LayoutMode,
@@ -427,6 +485,7 @@ impl WorkspaceSet {
}
}
scrolling.set_default_width(width);
scrolling.set_default_display(self.default_column_display);
if strategy != self.scrolling_focus_strategy {
scrolling.set_focus_strategy(strategy);
}
@@ -508,11 +567,15 @@ impl WorkspaceSet {
pub fn set_active_layout_mode(&mut self, mode: LayoutMode, bounds: Rect) -> Result<bool, WorkspaceError> {
let default_column_width = self.default_column_width;
let focus_strategy = self.scrolling_focus_strategy;
let display = self.default_column_display;
let workspace = self.active_mut();
let focused = tiled_focus(workspace);
let changed = workspace
.layout
.set_mode(mode, bounds, focused, default_column_width, focus_strategy)?;
if let WorkspaceLayout::Scrolling(layout) = &mut workspace.layout {
layout.set_default_display(display);
}
debug_assert!(self.validate().is_ok());
Ok(changed)
@@ -689,7 +752,8 @@ impl WorkspaceSet {
self.default_layout_mode,
self.default_column_width,
self.scrolling_focus_strategy,
),
)
.with_column_display(self.default_column_display),
floating: Vec::new(),
last_focused: None,
fullscreen: None,
@@ -1690,3 +1754,97 @@ mod tests {
)
}
}
#[cfg(test)]
mod tab_geometry_tests {
use super::*;
#[test]
fn live_layout_conversion_preserves_default_column_display() {
use ferese_layout::ColumnDisplay;
let mut workspaces = WorkspaceSet::default();
workspaces.set_default_column_display(ColumnDisplay::Tabbed);
for mode in [LayoutMode::Tree, LayoutMode::Scrolling] {
workspaces
.reconfigure_live(
mode,
workspaces.default_column_width,
workspaces.scrolling_focus_strategy,
&HashMap::new(),
)
.unwrap();
}
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
let WorkspaceLayout::Scrolling(layout) = &workspaces.active().layout else {
panic!()
};
assert_eq!(layout.columns()[0].display, ColumnDisplay::Tabbed);
}
#[test]
fn default_column_display_only_changes_future_columns_and_reaches_new_workspaces() {
use ferese_layout::ColumnDisplay;
let mut workspaces = WorkspaceSet::default();
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
workspaces.set_default_column_display(ColumnDisplay::Tabbed);
workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap();
let WorkspaceLayout::Scrolling(layout) = &workspaces.active().layout else {
panic!()
};
assert_eq!(layout.columns()[0].display, ColumnDisplay::Stacked);
assert_eq!(layout.columns()[1].display, ColumnDisplay::Tabbed);
let next = workspaces.create_workspace();
workspaces.activate(next).unwrap();
workspaces.insert_window(WindowId(3), Axis::Horizontal, 0.5).unwrap();
let WorkspaceLayout::Scrolling(layout) = &workspaces.active().layout else {
panic!()
};
assert_eq!(layout.columns()[0].display, ColumnDisplay::Tabbed);
workspaces.set_default_column_display(ColumnDisplay::Stacked);
workspaces.insert_window(WindowId(4), Axis::Horizontal, 0.5).unwrap();
let WorkspaceLayout::Scrolling(layout) = &workspaces.active().layout else {
panic!()
};
assert_eq!(layout.columns()[0].display, ColumnDisplay::Tabbed);
assert_eq!(layout.columns()[1].display, ColumnDisplay::Stacked);
}
#[test]
fn tab_header_reserves_space_and_disappears_after_leaving() {
let mut layout = WorkspaceLayout::Tree(LayoutTree::default());
layout.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
layout
.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
.unwrap();
layout.stack_window(WindowId(2), WindowId(1)).unwrap();
for height in [12., 200., 600.] {
let bounds = Rect::new(0., 0., 800., height);
let gaps = GapConfig {
inner: 0.,
outer: 0.,
smart: false,
};
let result = layout
.resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(WindowId(2)))
.unwrap();
assert_eq!(result.tab_bars.len(), 1);
let header = result.tab_bars[0].rect;
let content = result.geometry[&WindowId(2)];
assert_eq!(header.y + header.height, content.y);
assert_eq!(content.height + header.height, height);
assert!(content.height > 0.0);
assert!(!result.geometry.contains_key(&WindowId(1)));
}
layout.extract_window(WindowId(2)).unwrap();
let result = layout
.resolve_geometry_with_constraints(
Rect::new(0., 0., 800., 600.),
GapConfig::default(),
&HashMap::new(),
None,
)
.unwrap();
assert!(result.tab_bars.is_empty());
assert_eq!(result.geometry.len(), 2);
}
}
+183 -1
View File
@@ -4,7 +4,9 @@ use std::fmt;
mod scrolling;
pub use scrolling::{Column, ColumnWidth, ScrollingLayout, ViewportFocusStrategy, ViewportTarget};
pub use scrolling::{Column, ColumnDisplay, ColumnWidth, ScrollingLayout, ViewportFocusStrategy, ViewportTarget};
pub const TAB_BAR_HEIGHT: f64 = 30.0;
const MIN_SPLIT_RATIO: f64 = 0.05;
const MAX_SPLIT_RATIO: f64 = 0.95;
@@ -15,6 +17,18 @@ pub struct WindowId(pub u64);
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct NodeId(u64);
#[derive(Clone, Debug, PartialEq)]
pub struct TabGroup {
pub windows: Vec<WindowId>,
pub active: WindowId,
}
#[derive(Clone, Debug, PartialEq)]
pub struct TabBar {
pub group: TabGroup,
pub rect: Rect,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Axis {
Horizontal,
@@ -108,6 +122,7 @@ pub struct ConstraintWarning {
pub struct LayoutResult {
pub geometry: HashMap<WindowId, Rect>,
pub warnings: Vec<ConstraintWarning>,
pub tab_bars: Vec<TabBar>,
}
impl Default for GapConfig {
@@ -165,6 +180,18 @@ impl LayoutTree {
self.windows.keys().copied()
}
pub fn visible_window_count(&self) -> usize {
fn count(tree: &LayoutTree, node: NodeId) -> usize {
match tree.nodes.get(&node) {
Some(Node::Window(_)) => 1,
Some(Node::Split { first, second, .. }) => count(tree, *first) + count(tree, *second),
Some(Node::Stack { children, active }) => children.get(*active).map_or(0, |child| count(tree, *child)),
None => 0,
}
}
self.root.map_or(0, |root| count(self, root))
}
pub fn preferred_window(&self) -> Option<WindowId> {
let leaf = self.first_window(self.root?).ok()?;
match self.nodes.get(&leaf)? {
@@ -211,6 +238,8 @@ impl LayoutTree {
.ok_or(LayoutError::UnknownWindow(focused))?,
None => self.first_window(root)?,
};
// A new split belongs beside the whole tab group, not inside one tab.
let target = self.tab_parent(target).unwrap_or(target);
let old_parent = self.parents.get(&target).copied();
let window_node = self.insert_node(Node::Window(window));
self.windows.insert(window, window_node);
@@ -307,6 +336,13 @@ impl LayoutTree {
return Err(LayoutError::UnknownWindow(window));
}
if self
.tab_group(window)
.is_some_and(|group| group.windows.contains(&target))
{
return self.activate_window(window);
}
self.remove(window)?;
let target_node = self
@@ -316,6 +352,17 @@ impl LayoutTree {
.ok_or(LayoutError::UnknownWindow(target))?;
let old_parent = self.parents.get(&target_node).copied();
let window_node = self.insert_node(Node::Window(window));
if let Some(parent) = self.tab_parent(target_node) {
let Some(Node::Stack { children, active }) = self.nodes.get_mut(&parent) else {
unreachable!()
};
children.push(window_node);
*active = children.len() - 1;
self.windows.insert(window, window_node);
self.parents.insert(window_node, parent);
debug_assert!(self.validate().is_ok());
return Ok(());
}
let stack = self.insert_node(Node::Stack {
children: vec![target_node, window_node],
active: 1,
@@ -336,6 +383,73 @@ impl LayoutTree {
Ok(())
}
fn tab_parent(&self, leaf: NodeId) -> Option<NodeId> {
let parent = *self.parents.get(&leaf)?;
match self.nodes.get(&parent)? {
Node::Stack { children, .. }
if children
.iter()
.all(|id| matches!(self.nodes.get(id), Some(Node::Window(_)))) =>
{
Some(parent)
}
_ => None,
}
}
fn group_at(&self, node: NodeId) -> Option<TabGroup> {
let Node::Stack { children, active } = self.nodes.get(&node)? else {
return None;
};
let windows: Option<Vec<_>> = children
.iter()
.map(|id| match self.nodes.get(id) {
Some(Node::Window(window)) => Some(*window),
_ => None,
})
.collect();
let windows = windows?;
Some(TabGroup {
active: *windows.get(*active)?,
windows,
})
}
pub fn tab_group(&self, window: WindowId) -> Option<TabGroup> {
self.group_at(*self.parents.get(self.windows.get(&window)?)?)
}
pub fn tab_groups(&self) -> Vec<TabGroup> {
let mut groups: Vec<_> = self.nodes.keys().filter_map(|id| self.group_at(*id)).collect();
groups.sort_by_key(|group| group.windows[0].0);
groups
}
pub fn adjacent_tab(&self, window: WindowId, previous: bool) -> Option<WindowId> {
let group = self.tab_group(window)?;
let index = group.windows.iter().position(|id| *id == window)?;
let step = if previous { group.windows.len() - 1 } else { 1 };
Some(group.windows[(index + step) % group.windows.len()])
}
/// Leave the group in place and split the departing window to its right.
pub fn leave_tab_group(&mut self, window: WindowId) -> Result<bool, LayoutError> {
if !self.contains(window) {
return Err(LayoutError::UnknownWindow(window));
}
let Some(group) = self.tab_group(window) else {
return Ok(false);
};
let target = group
.windows
.into_iter()
.find(|id| *id != window)
.expect("group has at least two tabs");
self.remove(window)?;
self.insert(window, Some(target), Axis::Horizontal, 0.5)?;
Ok(true)
}
pub fn activate_window(&mut self, window: WindowId) -> Result<(), LayoutError> {
let mut child = self
.windows
@@ -1576,3 +1690,71 @@ mod tests {
}
}
}
#[cfg(test)]
mod tab_tests {
use super::*;
fn nested() -> LayoutTree {
let mut tree = LayoutTree::default();
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.6)
.unwrap();
tree.insert(WindowId(3), Some(WindowId(1)), Axis::Vertical, 0.4)
.unwrap();
tree.insert(WindowId(4), Some(WindowId(3)), Axis::Horizontal, 0.5)
.unwrap();
tree
}
#[test]
fn joining_and_leaving_tabs_preserve_neighboring_splits() {
let mut tree = nested();
let bounds = Rect::new(0., 0., 1000., 800.);
let neighbor = tree.geometry(bounds).unwrap()[&WindowId(2)];
tree.stack_window(WindowId(4), WindowId(3)).unwrap();
tree.stack_window(WindowId(1), WindowId(3)).unwrap();
assert_eq!(
tree.tab_group(WindowId(1)).unwrap().windows,
[WindowId(3), WindowId(4), WindowId(1)]
);
assert_eq!(tree.geometry(bounds).unwrap()[&WindowId(2)], neighbor);
assert_eq!(tree.visible_window_count(), 2);
let before = tree.clone();
tree.stack_window(WindowId(1), WindowId(4)).unwrap();
assert_eq!(tree, before, "joining the same group must not reorder it");
assert_eq!(tree.adjacent_tab(WindowId(1), false), Some(WindowId(3)));
assert_eq!(tree.adjacent_tab(WindowId(3), true), Some(WindowId(1)));
assert!(tree.leave_tab_group(WindowId(1)).unwrap());
assert_eq!(tree.geometry(bounds).unwrap()[&WindowId(2)], neighbor);
assert_eq!(tree.tab_group(WindowId(4)).unwrap().windows, [WindowId(3), WindowId(4)]);
assert!(tree.tab_group(WindowId(1)).is_none());
assert!(!tree.leave_tab_group(WindowId(1)).unwrap());
tree.remove(WindowId(4)).unwrap();
assert!(tree.tab_groups().is_empty());
assert!(tree.validate().is_ok());
}
#[test]
fn opening_beside_a_group_and_removing_active_tabs_keep_all_members_reachable() {
let mut tree = nested();
tree.stack_window(WindowId(4), WindowId(3)).unwrap();
tree.stack_window(WindowId(1), WindowId(3)).unwrap();
tree.insert(WindowId(5), Some(WindowId(1)), Axis::Vertical, 0.3)
.unwrap();
assert_eq!(tree.tab_groups().len(), 1);
let bounds = Rect::new(0., 0., 1000., 800.);
for id in [WindowId(3), WindowId(4), WindowId(1)] {
tree.activate_window(id).unwrap();
let geometry = tree.geometry(bounds).unwrap();
assert!(geometry.contains_key(&WindowId(5)));
assert!(geometry.contains_key(&id));
assert_eq!(geometry.len(), 3);
}
tree.remove(WindowId(1)).unwrap();
assert_eq!(tree.tab_group(WindowId(3)).unwrap().active, WindowId(4));
tree.remove(WindowId(3)).unwrap();
assert!(tree.tab_groups().is_empty());
assert!(tree.validate().is_ok());
}
}
+280 -4
View File
@@ -11,6 +11,13 @@ const MAX_COLUMN_WIDTH: f64 = 2.0;
const MIN_ROW_HEIGHT: f64 = 0.05;
const REVEAL_EPSILON: f64 = 0.5;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum ColumnDisplay {
#[default]
Stacked,
Tabbed,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ColumnWidth {
Proportion(f64),
@@ -60,6 +67,7 @@ pub struct Column {
pub active: usize,
pub width: ColumnWidth,
pub heights: Vec<f64>,
pub display: ColumnDisplay,
}
impl Column {
@@ -69,6 +77,7 @@ impl Column {
active: 0,
width: normalized_width(width),
heights: vec![1.0],
display: ColumnDisplay::Stacked,
}
}
@@ -191,6 +200,7 @@ pub struct ScrollingLayout {
active_column: Option<usize>,
viewport_x: f64,
default_width: ColumnWidth,
default_display: ColumnDisplay,
focus_strategy: ViewportFocusStrategy,
pending_viewport: Option<PendingViewportRequest>,
last_column_sizing: Option<ColumnSizing>,
@@ -205,6 +215,7 @@ impl Default for ScrollingLayout {
active_column: None,
viewport_x: 0.0,
default_width: ColumnWidth::default(),
default_display: ColumnDisplay::default(),
focus_strategy: ViewportFocusStrategy::Minimal,
pending_viewport: None,
last_column_sizing: None,
@@ -237,6 +248,55 @@ impl ScrollingLayout {
}
}
pub fn set_default_display(&mut self, display: ColumnDisplay) {
self.default_display = display;
}
pub fn set_column_display(&mut self, window: WindowId, display: ColumnDisplay) -> Result<(), LayoutError> {
let (index, _) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
self.columns[index].display = display;
Ok(())
}
pub fn toggle_column_tabbed(&mut self, window: WindowId) -> Result<(), LayoutError> {
let (index, _) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
let column = &mut self.columns[index];
column.display = match column.display {
ColumnDisplay::Stacked => ColumnDisplay::Tabbed,
ColumnDisplay::Tabbed => ColumnDisplay::Stacked,
};
Ok(())
}
pub fn tab_group(&self, window: WindowId) -> Option<crate::TabGroup> {
let (index, _) = self.window_location(window)?;
let column = &self.columns[index];
(column.display == ColumnDisplay::Tabbed && column.windows.len() > 1).then(|| crate::TabGroup {
windows: column.windows.clone(),
active: column.windows[column.active],
})
}
pub fn adjacent_tab(&self, window: WindowId, previous: bool) -> Option<WindowId> {
let group = self.tab_group(window)?;
let index = group.windows.iter().position(|id| *id == window)?;
let step = if previous { group.windows.len() - 1 } else { 1 };
Some(group.windows[(index + step) % group.windows.len()])
}
pub fn visible_window_count(&self) -> usize {
self.columns
.iter()
.map(|c| {
if c.display == ColumnDisplay::Tabbed {
1
} else {
c.windows.len()
}
})
.sum()
}
pub fn set_default_width(&mut self, width: ColumnWidth) {
self.default_width = normalized_width(width);
}
@@ -333,7 +393,9 @@ impl ScrollingLayout {
.and_then(|focused| self.window_location(focused))
.map(|(column, _)| column + 1)
.unwrap_or(self.columns.len());
self.columns.insert(index, Column::new(window, self.default_width));
let mut column = Column::new(window, self.default_width);
column.display = self.default_display;
self.columns.insert(index, column);
self.active_column = Some(index);
self.request_reveal((self.columns.len() > 1).then_some(window));
@@ -399,7 +461,14 @@ impl ScrollingLayout {
/// An explicit selection must reveal even a window already focused by hover.
pub fn focus_and_reveal(&mut self, window: WindowId) -> Result<(), LayoutError> {
let (column, row) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
let switching_tab = self.active_column == Some(column)
&& self.columns[column].display == ColumnDisplay::Tabbed
&& self.columns[column].active != row;
self.focus_without_reveal(window)?;
if switching_tab {
return Ok(());
}
self.request_focus(Some(PendingFocusRequest::Reveal(window)));
Ok(())
}
@@ -407,6 +476,12 @@ impl ScrollingLayout {
pub fn slide_focus_from(&mut self, previous: WindowId, focused: WindowId) -> Result<(), LayoutError> {
self.window_location(previous)
.ok_or(LayoutError::UnknownWindow(previous))?;
if self
.tab_group(previous)
.is_some_and(|group| group.windows.contains(&focused))
{
return self.focus_without_reveal(focused);
}
if self.focus_strategy == ViewportFocusStrategy::Minimal {
return self.focus_and_reveal(focused);
}
@@ -450,7 +525,9 @@ impl ScrollingLayout {
let width = self.columns[source].width;
self.remove(window)?;
let insertion = (source + 1).min(self.columns.len());
self.columns.insert(insertion, Column::new(window, width));
let mut column = Column::new(window, width);
column.display = self.default_display;
self.columns.insert(insertion, column);
self.active_column = Some(insertion);
self.request_reveal(self.active_window());
@@ -598,6 +675,10 @@ impl ScrollingLayout {
return Ok(false);
}
if self.columns[column].display == ColumnDisplay::Tabbed && matches!(direction, Direction::Up | Direction::Down)
{
return Ok(false);
}
let changed = match direction {
Direction::Left | Direction::Right => {
let current = match normalized_width(self.columns[column].width) {
@@ -646,7 +727,7 @@ impl ScrollingLayout {
return Ok(LayoutResult::default());
}
let window_count = self.window_ids().count();
let window_count = self.visible_window_count();
let inner = finite_nonnegative(gaps.inner);
let outer = if gaps.smart && window_count == 1 {
0.0
@@ -751,6 +832,51 @@ impl ScrollingLayout {
let mut result = LayoutResult::default();
for (column_index, column) in self.columns.iter().enumerate() {
let (column_x, column_width) = column_positions[column_index];
if column.display == ColumnDisplay::Tabbed {
let window = column.windows[column.active];
let header = if column.windows.len() > 1 {
crate::TAB_BAR_HEIGHT.min((viewport_height - 1.0).max(0.0))
} else {
0.0
};
let mut rect = Rect::new(
bounds.x + outer + column_x - self.viewport_x,
bounds.y + outer + header,
column_width,
viewport_height - header,
);
// All tabs use one size, even when their client constraints differ.
// Keep the column allocation stable and honor compatible maximums.
let mut common = SizeConstraints::default();
for id in &column.windows {
let c = normalized_constraints(constraints.get(id).copied().unwrap_or_default());
common.min_width = common.min_width.max(c.min_width);
common.min_height = common.min_height.max(c.min_height);
common.max_width = match (common.max_width, c.max_width) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
};
common.max_height = match (common.max_height, c.max_height) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
};
}
let common = normalized_constraints(common);
record_minimum_warnings(window, rect, common, &mut result.warnings);
apply_maximums(window, &mut rect, common, &mut result.warnings);
if header > 0.0 {
result.tab_bars.push(crate::TabBar {
group: crate::TabGroup {
windows: column.windows.clone(),
active: window,
},
rect: Rect::new(rect.x, rect.y - header, rect.width, header),
});
}
result.geometry.insert(window, rect);
continue;
}
let available_height = (viewport_height - inner * (column.windows.len().saturating_sub(1) as f64)).max(0.0);
let row_constraints = column
.windows
@@ -875,7 +1001,9 @@ impl ScrollingLayout {
let width = self.columns[source].width;
self.remove(window)?;
let insertion = insertion.min(self.columns.len());
self.columns.insert(insertion, Column::new(window, width));
let mut column = Column::new(window, width);
column.display = self.default_display;
self.columns.insert(insertion, column);
self.active_column = Some(insertion);
self.request_reveal(self.active_window());
Ok(())
@@ -2522,3 +2650,151 @@ mod tests {
}
}
}
#[cfg(test)]
mod tabbed_tests {
use super::*;
fn fixture() -> ScrollingLayout {
let mut layout = ScrollingLayout::default();
for id in 1..=4 {
layout.insert(WindowId(id), layout.active_window()).unwrap();
}
layout.move_into_column(WindowId(3), WindowId(2)).unwrap();
layout.move_into_column(WindowId(4), WindowId(3)).unwrap();
layout
}
#[test]
fn toggling_tabs_restores_weights_and_keeps_viewport_and_column_widths() {
for strategy in [
ViewportFocusStrategy::Minimal,
ViewportFocusStrategy::Center,
ViewportFocusStrategy::Paged,
] {
let mut layout = fixture();
layout.set_focus_strategy(strategy);
layout.resize_window(WindowId(3), Direction::Up, 0.1).unwrap();
let bounds = Rect::new(0., 0., 1000., 800.);
let gaps = GapConfig::default();
let before = layout
.resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(WindowId(4)))
.unwrap();
let widths = layout.allocated_widths.clone();
let weights = layout.columns[1].heights.clone();
layout.toggle_column_tabbed(WindowId(4)).unwrap();
let tabbed = layout
.resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(WindowId(4)))
.unwrap();
let viewport = layout.viewport_x();
assert_eq!(tabbed.geometry.len(), 2);
assert_eq!(tabbed.tab_bars.len(), 1);
assert_eq!(layout.allocated_widths, widths);
let rect = tabbed.geometry[&WindowId(4)];
for id in [WindowId(2), WindowId(3), WindowId(4)] {
layout.focus_and_reveal(id).unwrap();
let result = layout
.resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(id))
.unwrap();
assert_eq!(result.geometry[&id], rect);
assert_eq!(layout.viewport_x(), viewport);
assert_eq!(layout.allocated_widths, widths);
}
assert!(!layout.resize_window(WindowId(3), Direction::Up, 0.1).unwrap());
layout.toggle_column_tabbed(WindowId(4)).unwrap();
let after = layout
.resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(WindowId(4)))
.unwrap();
assert_eq!(layout.columns[1].heights, weights);
assert_eq!(before.geometry, after.geometry);
assert!(after.tab_bars.is_empty());
}
}
#[test]
fn tab_navigation_join_leave_and_single_column_policy_remain_consistent() {
let mut layout = fixture();
layout.set_default_display(ColumnDisplay::Tabbed);
layout.toggle_column_tabbed(WindowId(4)).unwrap();
assert_eq!(
layout.directional_neighbor(WindowId(3), Direction::Up).unwrap(),
Some(WindowId(2))
);
assert_eq!(layout.adjacent_tab(WindowId(4), false), Some(WindowId(2)));
assert_eq!(layout.adjacent_tab(WindowId(2), true), Some(WindowId(4)));
layout.move_into_column(WindowId(1), WindowId(2)).unwrap();
assert_eq!(layout.visible_window_count(), 1);
assert_eq!(layout.columns[0].display, ColumnDisplay::Tabbed);
layout.extract_to_column(WindowId(1)).unwrap();
assert_eq!(layout.columns.len(), 2);
assert!(layout.columns.iter().all(|c| c.display == ColumnDisplay::Tabbed));
assert!(layout.tab_group(WindowId(1)).is_none());
layout.remove(WindowId(4)).unwrap();
layout.remove(WindowId(3)).unwrap();
assert!(layout.tab_group(WindowId(2)).is_none());
layout.insert(WindowId(5), Some(WindowId(2))).unwrap();
assert_eq!(layout.columns[1].display, ColumnDisplay::Tabbed);
assert!(layout.validate().is_ok());
}
#[test]
fn tab_swipe_does_not_recenter_a_manually_offset_viewport() {
let mut layout = fixture();
layout.set_focus_strategy(ViewportFocusStrategy::Center);
layout.toggle_column_tabbed(WindowId(4)).unwrap();
let bounds = Rect::new(0., 0., 1000., 800.);
layout
.resolve_geometry_with_constraints(bounds, GapConfig::default(), &HashMap::new(), Some(WindowId(4)))
.unwrap();
layout.viewport_x += 33.0;
let position = layout.viewport_x;
layout.focus_and_reveal(WindowId(3)).unwrap();
layout.slide_focus_from(WindowId(4), WindowId(3)).unwrap();
layout
.resolve_geometry_with_constraints(bounds, GapConfig::default(), &HashMap::new(), Some(WindowId(3)))
.unwrap();
assert_eq!(layout.viewport_x, position);
}
#[test]
fn constraints_do_not_change_tab_geometry_on_selection() {
let mut layout = fixture();
layout.toggle_column_tabbed(WindowId(4)).unwrap();
let constraints = HashMap::from([
(
WindowId(2),
SizeConstraints {
min_width: 400.,
max_height: Some(300.),
..Default::default()
},
),
(
WindowId(3),
SizeConstraints {
max_width: Some(450.),
min_height: 200.,
..Default::default()
},
),
]);
let mut previous = None;
for id in [WindowId(2), WindowId(3), WindowId(4)] {
let result = layout
.resolve_geometry_with_constraints(
Rect::new(0., 0., 1000., 800.),
GapConfig::default(),
&constraints,
Some(id),
)
.unwrap();
let rect = result.geometry[&id];
assert_eq!(rect.width, 450.);
assert_eq!(rect.height, 300.);
if let Some(previous) = previous {
assert_eq!(rect, previous);
}
previous = Some(rect);
}
}
}
+44 -3
View File
@@ -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;
+5 -1
View File
@@ -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
+6
View File
@@ -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,
+12 -3
View File
@@ -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
+17 -2
View File
@@ -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!({})),
+16
View File
@@ -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!({})));
+155
View File
@@ -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()