From 042698ea61033a81eb7bb44f6bd0ae5b6385011f Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Mon, 12 Oct 2026 23:52:25 +0100 Subject: [PATCH] feat(layout): overlay scrolling tab indicators and hover titles --- compositor/src/config.rs | 5 + compositor/src/config/tab_indicator.rs | 133 +++++ compositor/src/input.rs | 8 + compositor/src/overview.rs | 10 + compositor/src/render/mod.rs | 5 + compositor/src/render/overview.rs | 10 +- compositor/src/render/resources.rs | 11 +- compositor/src/render/tab_indicator.rs | 247 ++++++++ compositor/src/render/tabs.rs | 14 + compositor/src/state.rs | 8 + compositor/src/state/animation.rs | 5 +- compositor/src/state/layout.rs | 1 + compositor/src/state/navigation.rs | 1 + compositor/src/state/prediction.rs | 1 + compositor/src/state/tab_indicator.rs | 558 ++++++++++++++++++ compositor/src/state/tabs.rs | 12 +- .../src/state/window_lifecycle_tests.rs | 98 +++ crates/ferese-layout/src/scrolling.rs | 48 +- docs/configuration.md | 50 +- docs/shortcuts.md | 4 + scripts/tests/test_tree_tabs_isolated.py | 17 +- 21 files changed, 1223 insertions(+), 23 deletions(-) create mode 100644 compositor/src/config/tab_indicator.rs create mode 100644 compositor/src/render/tab_indicator.rs create mode 100644 compositor/src/state/tab_indicator.rs diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 588da5b..b9de62b 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -4,6 +4,7 @@ mod input_motion; mod layout; mod loading; mod outputs; +mod tab_indicator; mod xwayland; use std::collections::{HashMap, HashSet}; @@ -28,6 +29,7 @@ use outputs::*; pub use outputs::{LidPolicy, OutputLayout, OutputModeRequest, OutputProfile, OutputSettings, OutputTransform}; use serde::Deserialize; use smithay::input::keyboard::{Keycode, keysyms, xkb}; +pub(crate) use tab_indicator::TabIndicatorSettings; use crate::window_rules; use crate::window_rules::{WindowRule, WindowRuleConfig}; @@ -167,6 +169,7 @@ impl Config { focus_effect: self.focus_effect_settings()?, default_column_width: self.default_column_width()?, default_column_display: self.default_column_display(), + tab_indicator: self.tab_indicator_settings()?, scrolling_focus_strategy: self.scrolling_focus_strategy(), column_width_presets: self.width_presets()?, animations_enabled: self.animations_enabled(), @@ -230,6 +233,8 @@ enum LayoutModeValue { #[derive(Debug, Default, Deserialize)] struct ScrollingConfig { + #[serde(default)] + tab_indicator: tab_indicator::TabIndicatorConfig, default_column_display: Option, default_column_width: Option, focus_strategy: Option, diff --git a/compositor/src/config/tab_indicator.rs b/compositor/src/config/tab_indicator.rs new file mode 100644 index 0000000..dfbea92 --- /dev/null +++ b/compositor/src/config/tab_indicator.rs @@ -0,0 +1,133 @@ +use super::*; + +#[derive(Clone, Debug, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub(super) struct TabIndicatorConfig { + width: f64, + segment_height: f64, + segment_gap: f64, + hit_width: f64, + gap_threshold: f64, + list_width: f64, + row_height: f64, + padding: f64, + font_size: f64, + flash_duration_ms: u64, + active_color: Option, + inactive_color: Option, + background_color: Option, + text_color: Option, +} + +impl Default for TabIndicatorConfig { + fn default() -> Self { + Self { + width: 4.0, + segment_height: 18.0, + segment_gap: 4.0, + hit_width: 12.0, + gap_threshold: 8.0, + list_width: 220.0, + row_height: 28.0, + padding: 4.0, + font_size: 12.0, + flash_duration_ms: 1000, + active_color: None, + inactive_color: None, + background_color: None, + text_color: None, + } + } +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct TabIndicatorSettings { + pub width: f64, + pub segment_height: f64, + pub segment_gap: f64, + pub hit_width: f64, + pub gap_threshold: f64, + pub list_width: f64, + pub row_height: f64, + pub padding: f64, + pub font_size: f64, + pub flash_duration: std::time::Duration, + pub active_color: Option<[f32; 4]>, + pub inactive_color: Option<[f32; 4]>, + pub background_color: Option<[f32; 4]>, + pub text_color: Option<[f32; 4]>, +} + +impl Config { + pub(crate) fn tab_indicator_settings(&self) -> Result { + let c = &self.scrolling.tab_indicator; + let range = |value: f64, min: f64, max: f64, field: &'static str| { + if value.is_finite() && (min..=max).contains(&value) { + Ok(value) + } else { + Err(ConfigError::LayoutValue { field, value }) + } + }; + let color = + |value: &Option, field| value.as_ref().map(|s| parse_color(s, field).map(|c| c.0)).transpose(); + let width = range(c.width, 1.0, 16.0, "tab_indicator.width")?; + let hit_width = range(c.hit_width, width, 64.0, "tab_indicator.hit_width")?; + let font_size = range(c.font_size, 8.0, 32.0, "tab_indicator.font_size")?; + Ok(TabIndicatorSettings { + width, + hit_width, + font_size, + segment_height: range(c.segment_height, width, 128.0, "tab_indicator.segment_height")?, + segment_gap: range(c.segment_gap, 0.0, 64.0, "tab_indicator.segment_gap")?, + gap_threshold: range(c.gap_threshold, width, 128.0, "tab_indicator.gap_threshold")?, + list_width: range(c.list_width, 80.0, 640.0, "tab_indicator.list_width")?, + row_height: range(c.row_height, font_size + 6.0, 80.0, "tab_indicator.row_height")?, + padding: range(c.padding, 0.0, 24.0, "tab_indicator.padding")?, + flash_duration: std::time::Duration::from_millis(range( + c.flash_duration_ms as f64, + 0.0, + 60_000.0, + "tab_indicator.flash_duration_ms", + )? as u64), + active_color: color(&c.active_color, "tab_indicator.active_color")?, + inactive_color: color(&c.inactive_color, "tab_indicator.inactive_color")?, + background_color: color(&c.background_color, "tab_indicator.background_color")?, + text_color: color(&c.text_color, "tab_indicator.text_color")?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn indicator_defaults_overrides_and_validation() { + let c = Config::parse_source("").unwrap().tab_indicator_settings().unwrap(); + assert_eq!( + (c.width, c.segment_height, c.segment_gap, c.hit_width, c.gap_threshold), + (4., 18., 4., 12., 8.) + ); + assert_eq!(c.flash_duration.as_millis(), 1000); + assert!(c.active_color.is_none()); + let config = Config::parse_source( + "scrolling { tab-indicator { width 3; hit-width 14; flash-duration-ms 750; active-color \"#ff0000\"; }; }", + ) + .unwrap(); + let c = config.tab_indicator_settings().unwrap(); + assert_eq!((c.width, c.hit_width, c.flash_duration.as_millis()), (3., 14., 750)); + assert_eq!(c.active_color, Some([1., 0., 0., 1.])); + for source in [ + "width 0", + "segment-gap -1", + "hit-width 2", + "row-height 8", + "flash-duration-ms 60001", + "active-color \"invalid\"", + ] { + let config = Config::parse_source(&format!("scrolling {{ tab-indicator {{ {source}; }}; }}")).unwrap(); + assert!(config.tab_indicator_settings().is_err(), "{source}"); + } + assert!(Config::parse_source("scrolling { tab-indicator { typo 1; }; }").is_err()); + } +} diff --git a/compositor/src/input.rs b/compositor/src/input.rs index 37c4f1b..b951abe 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -484,6 +484,7 @@ impl Ferese { } let position = self.constrain_pointer_position(&pointer, position); + self.update_tab_indicator_hover(position); pointer.motion( self, self.surface_under(position), @@ -528,6 +529,7 @@ impl Ferese { let requested = self.clamp_pointer_position(position + delta); let location = self.constrain_pointer_position(&pointer, requested); + self.update_tab_indicator_hover(location); pointer.motion( self, self.surface_under(location), @@ -712,6 +714,12 @@ impl Ferese { return; } let pointer = self.seat.get_pointer().expect("seat has a pointer"); + if self.scroll_tab_indicator( + pointer.current_location(), + if vertical != 0.0 { vertical } else { horizontal }, + ) { + return; + } if self.scroll_overview_strip( pointer.current_location(), if horizontal != 0.0 { horizontal } else { vertical }, diff --git a/compositor/src/overview.rs b/compositor/src/overview.rs index 2617fd0..5511d22 100644 --- a/compositor/src/overview.rs +++ b/compositor/src/overview.rs @@ -576,6 +576,16 @@ impl Ferese { Some(buffer) } + pub(crate) fn indicator_label( + &mut self, + text: &str, + scale: f64, + color: [f32; 4], + size: f64, + ) -> Option { + self.overview.label_with_size(text, scale, color, size) + } + pub(crate) fn overview_workspace_label( &mut self, index: u32, diff --git a/compositor/src/render/mod.rs b/compositor/src/render/mod.rs index cd25a5b..88c09d0 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 tab_indicator; mod tabs; use std::collections::HashMap; use std::sync::Arc; @@ -332,6 +333,10 @@ pub(crate) enum OverviewChromePart { Caption, Tab, TabActive, + TabRail, + TabList, + TabRow, + TabFlash, } #[derive(Debug)] diff --git a/compositor/src/render/overview.rs b/compositor/src/render/overview.rs index 940cf8c..1bdc18b 100644 --- a/compositor/src/render/overview.rs +++ b/compositor/src/render/overview.rs @@ -122,9 +122,13 @@ 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 | OverviewChromePart::Tab | OverviewChromePart::TabActive => { - state.windows.window(ferese_layout::WindowId(*id)).is_some() - } + OverviewChromePart::Caption + | OverviewChromePart::Tab + | OverviewChromePart::TabActive + | OverviewChromePart::TabRail + | OverviewChromePart::TabList + | OverviewChromePart::TabRow + | OverviewChromePart::TabFlash => 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 000f966..4155eab 100644 --- a/compositor/src/render/resources.rs +++ b/compositor/src/render/resources.rs @@ -71,7 +71,16 @@ impl RenderResources { 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) + *key != id.0 + || !matches!( + part, + OverviewChromePart::Tab + | OverviewChromePart::TabActive + | OverviewChromePart::TabRail + | OverviewChromePart::TabList + | OverviewChromePart::TabRow + | OverviewChromePart::TabFlash + ) }); } } diff --git a/compositor/src/render/tab_indicator.rs b/compositor/src/render/tab_indicator.rs new file mode 100644 index 0000000..45e21ab --- /dev/null +++ b/compositor/src/render/tab_indicator.rs @@ -0,0 +1,247 @@ +use super::*; +use ferese_layout::{Rect, TabBar, WindowId}; + +#[allow(clippy::too_many_arguments)] +fn text_element( + state: &mut Ferese, + renderer: &mut GlesRenderer, + output_origin: Point, + scale: f64, + rect: Rect, + text: &str, + alpha: f32, + centered: bool, +) -> Option { + let settings = state.tab_indicator_settings; + let color = settings.text_color.unwrap_or(state.theme_settings.text_primary_color.0); + let limit = ((rect.width - 16.0) / (settings.font_size * 0.65)).floor().max(1.0) as usize; + let mut chars = text.chars().filter(|c| !c.is_control()); + let mut title = chars.by_ref().take(limit).collect::(); + if chars.next().is_some() { + title.pop(); + title.push('…'); + } + let (buffer, size) = state.indicator_label(&title, scale, color, settings.font_size)?; + let x = if centered { + ((rect.width - f64::from(size.w) / scale) * 0.5).max(8.0) + } else { + 8.0 + }; + let location = Point::::from(( + ((rect.x - f64::from(output_origin.x) + x) * scale).round() as i32, + ((rect.y - f64::from(output_origin.y) + rect.height * 0.5) * scale).round() as i32 - size.h / 2, + )); + let 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, + ) + .ok()?; + let element = RescaleRenderElement::from_element(element, location, 1.0); + let element = RelocateRenderElement::from_element(element, (0, 0), Relocate::Relative); + let clip = Rect::new(rect.x + 8.0, rect.y, (rect.width - 16.0).max(0.0), rect.height); + CropRenderElement::from_element(element, scale, physical_rect(clip, output_origin, scale)).map(Into::into) +} + +fn title(state: &Ferese, id: WindowId, capture: bool) -> String { + if capture && state.windows.window(id).is_none_or(|w| state.capture_protected(w)) { + return "Protected window".into(); + } + state + .windows + .record(id) + .map(|r| { + if !r.title.trim().is_empty() { + r.title.clone() + } else if !r.app_id.is_empty() { + r.app_id.clone() + } else { + "Window".into() + } + }) + .unwrap_or_default() +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn elements( + state: &mut Ferese, + renderer: &mut GlesRenderer, + output: &Output, + bar: &TabBar, + content: Rect, + alpha: f32, + capture: bool, +) -> Vec { + let Some(g) = state.tab_indicator_geometry(bar, content) else { + return Vec::new(); + }; + let Some(output_geometry) = state.space.output_geometry(output) else { + return Vec::new(); + }; + let scale = output.current_scale().fractional_scale(); + let settings = state.tab_indicator_settings; + let anchor = bar.group.windows[0]; + let accent = settings.active_color.unwrap_or(state.theme_settings.accent_color.0); + let inactive = settings.inactive_color.unwrap_or(state.theme_settings.border_color.0); + let background = settings + .background_color + .unwrap_or(state.theme_settings.surface_base_color.0); + let radius = state.theme_settings.material_radius; + let hover = state + .tab_indicator + .hover + .as_ref() + .filter(|h| h.anchor == anchor) + .map_or(0.0, |h| h.opacity.current.clamp(0.0, 1.0) as f32); + let flash = state + .tab_indicator + .flash + .as_ref() + .filter(|h| h.anchor == anchor) + .map_or(0.0, |h| h.opacity.current.clamp(0.0, 1.0) as f32); + let mut elements = Vec::new(); + state.retain_overview_labels(); + if hover > 0.0 { + let first = state + .tab_indicator + .first_row + .min(bar.group.windows.len().saturating_sub(g.rows)); + for (row, id) in bar.group.windows.iter().copied().skip(first).take(g.rows).enumerate() { + let rect = g.row(row); + let label = title(state, id, capture); + if let Some(element) = text_element( + state, + renderer, + output_geometry.loc, + scale, + rect, + &label, + alpha * hover, + false, + ) { + elements.push(element); + } + if id == bar.group.active { + let mut fill = background; + for i in 0..3 { + fill[i] += (accent[i] - fill[i]) * 0.2; + } + fill[3] = alpha * hover; + if let Some(element) = overview_chrome_element( + state, + renderer, + output, + output_geometry, + scale, + (id.0, OverviewChromePart::TabRow), + rect, + fill, + 0.0, + (radius - g.padding).max(0.0).min(rect.height * 0.5), + ) { + elements.push(element.into()); + } + } + } + if let Some(element) = overview_chrome_element( + state, + renderer, + output, + output_geometry, + scale, + (anchor.0, OverviewChromePart::TabList), + g.list, + color_with_alpha(background, alpha * hover), + 0.0, + radius.min(g.list.height * 0.5), + ) { + elements.push(element.into()); + } + } else if flash > 0.0 { + let index = bar + .group + .windows + .iter() + .position(|id| *id == bar.group.active) + .unwrap_or(0); + let label = format!( + "{}/{} · {}", + index + 1, + bar.group.windows.len(), + title(state, bar.group.active, capture) + ); + let width = (label.chars().count() as f64 * settings.font_size * 0.65 + 24.0).min(g.list.width); + let bounds = Rect::new( + output_geometry.loc.x as f64, + output_geometry.loc.y as f64, + output_geometry.size.w as f64, + output_geometry.size.h as f64, + ); + let rect = Rect::new( + (content.x + (content.width - width) * 0.5).clamp(bounds.x, bounds.x + bounds.width - width), + (content.y + settings.padding).clamp(bounds.y, bounds.y + bounds.height - g.row_height), + width, + g.row_height, + ); + if let Some(element) = text_element( + state, + renderer, + output_geometry.loc, + scale, + rect, + &label, + alpha * flash, + true, + ) { + elements.push(element); + } + if let Some(element) = overview_chrome_element( + state, + renderer, + output, + output_geometry, + scale, + (anchor.0, OverviewChromePart::TabFlash), + rect, + color_with_alpha(background, alpha * flash), + 0.0, + rect.height * 0.5, + ) { + elements.push(element.into()); + } + } + for (index, id) in bar.group.windows.iter().copied().enumerate() { + let rect = Rect::new( + g.rail.x, + g.rail.y + index as f64 * g.pitch, + g.rail.width, + g.segment_height, + ); + let color = if id == bar.group.active { accent } else { inactive }; + if let Some(element) = overview_chrome_element( + state, + renderer, + output, + output_geometry, + scale, + (id.0, OverviewChromePart::TabRail), + rect, + color_with_alpha(color, alpha), + 0.0, + rect.width.min(rect.height) * 0.5, + ) { + elements.push(element.into()); + } + } + elements +} diff --git a/compositor/src/render/tabs.rs b/compositor/src/render/tabs.rs index 4cf07c7..c0feeb8 100644 --- a/compositor/src/render/tabs.rs +++ b/compositor/src/render/tabs.rs @@ -24,6 +24,20 @@ pub(super) fn tab_elements( let Some(sample) = frame.windows.get(&active) else { return Vec::new(); }; + if matches!( + state.workspaces.workspace(workspace).map(|w| &w.layout), + Some(ferese_core::WorkspaceLayout::Scrolling(_)) + ) { + return super::tab_indicator::elements( + state, + renderer, + output, + &bar, + sample.presentation.bounds.current, + sample.presentation.alpha(), + capture, + ); + } if bar.rect.height <= 0.0 { return Vec::new(); } diff --git a/compositor/src/state.rs b/compositor/src/state.rs index f42f06f..5d179ba 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -7,6 +7,7 @@ mod layout; mod navigation; mod outputs; mod reconciliation; +pub(crate) mod tab_indicator; mod tabs; #[cfg(test)] mod window_lifecycle_tests; @@ -456,6 +457,8 @@ pub struct Ferese { workspace_slides: HashMap, workspace_slide_offsets: HashMap, pub focused_window: Option, + pub(crate) tab_indicator: tab_indicator::TabIndicatorState, + pub(crate) tab_indicator_settings: crate::config::TabIndicatorSettings, pub(crate) tab_bars: HashMap>, pub(crate) tab_buttons: HashSet, pub(crate) focus_history: ferese_core::FocusHistory, @@ -576,6 +579,7 @@ pub struct RuntimeConfig { pub focus_effect: crate::config::FocusEffectSettings, pub default_column_width: ColumnWidth, pub default_column_display: ferese_layout::ColumnDisplay, + pub(crate) tab_indicator: crate::config::TabIndicatorSettings, pub scrolling_focus_strategy: ViewportFocusStrategy, pub column_width_presets: Vec, pub animations_enabled: bool, @@ -752,6 +756,8 @@ impl Ferese { workspace_slides: HashMap::new(), workspace_slide_offsets: HashMap::new(), focused_window: None, + tab_indicator: tab_indicator::TabIndicatorState::default(), + tab_indicator_settings: config.tab_indicator, tab_bars: HashMap::new(), tab_buttons: HashSet::new(), focus_history: Default::default(), @@ -980,6 +986,8 @@ impl Ferese { self.idle_inhibit = config.idle_inhibit; self.theme_settings = config.theme_settings; self.panel_corner_radius = config.panel_corner_radius; + self.tab_indicator_settings = config.tab_indicator; + self.clear_tab_indicator(); self.panel_background_opacity = config.panel_background_opacity; self.panel_style = config.panel_style; self.focus_effect = config.focus_effect; diff --git a/compositor/src/state/animation.rs b/compositor/src/state/animation.rs index 9de93fa..1d7027f 100644 --- a/compositor/src/state/animation.rs +++ b/compositor/src/state/animation.rs @@ -102,6 +102,7 @@ impl Ferese { || !self.dismissing_popups.is_empty() || self.render.snapshots().next().is_some() || self.overview.needs_tick() + || self.tab_indicator.is_animating() { return true; } @@ -160,7 +161,9 @@ impl Ferese { self.release_presentation_dependencies(); } - let mut active_animation = false; + let mut active_animation = self + .tab_indicator + .advance(delta, self.spring_config, self.animations_enabled); let mut completed_slides = Vec::new(); for (output, slide) in &mut self.workspace_slides { if slide.held_progress.is_some() { diff --git a/compositor/src/state/layout.rs b/compositor/src/state/layout.rs index cc4e3cf..e04f6fb 100644 --- a/compositor/src/state/layout.rs +++ b/compositor/src/state/layout.rs @@ -640,6 +640,7 @@ impl Ferese { self.rebuild_presentation_dependencies(); self.sync_window_stacking(); self.retarget_overview(); + self.reconcile_tab_indicator(); if !finalize { return; } diff --git a/compositor/src/state/navigation.rs b/compositor/src/state/navigation.rs index 45cc88d..8130002 100644 --- a/compositor/src/state/navigation.rs +++ b/compositor/src/state/navigation.rs @@ -197,6 +197,7 @@ impl Ferese { } self.relayout(); + self.flash_tab_switch(current, next); } pub fn move_direction(&mut self, direction: Direction) { diff --git a/compositor/src/state/prediction.rs b/compositor/src/state/prediction.rs index ce91d48..3a1cc2a 100644 --- a/compositor/src/state/prediction.rs +++ b/compositor/src/state/prediction.rs @@ -196,6 +196,7 @@ impl Ferese { .iter() .any(|window| Some(window.output) == self.output_id(output)) || self.overview.is_animating(self.spring_config) + || self.tab_indicator_output_animating(output) || self .dismissing_popups .iter() diff --git a/compositor/src/state/tab_indicator.rs b/compositor/src/state/tab_indicator.rs new file mode 100644 index 0000000..082196e --- /dev/null +++ b/compositor/src/state/tab_indicator.rs @@ -0,0 +1,558 @@ +use super::*; +use crate::config::TabIndicatorSettings; +use smithay::reexports::calloop::{ + RegistrationToken, + timer::{TimeoutAction, Timer}, +}; + +#[derive(Debug)] +pub(crate) struct Overlay { + pub anchor: WindowId, + pub opacity: AnimatedValue, +} + +#[derive(Debug, Default)] +pub(crate) struct TabIndicatorState { + pub hover: Option, + pub flash: Option, + pub first_row: usize, + timer: Option, +} + +impl TabIndicatorState { + pub fn is_animating(&self) -> bool { + [&self.hover, &self.flash] + .into_iter() + .flatten() + .any(|o| o.opacity.is_animating()) + } + + pub fn advance(&mut self, delta: Duration, spring: SpringConfig, enabled: bool) -> bool { + let mut moving = false; + for overlay in [&mut self.hover, &mut self.flash] { + if let Some(value) = overlay { + if enabled { + moving |= value + .opacity + .advance(delta, crate::presentation::emphasis_spring(spring)); + } else { + value.opacity.snap(); + } + if !value.opacity.is_animating() && value.opacity.current == 0.0 { + *overlay = None; + } + } + } + moving + } +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct IndicatorGeometry { + pub rail: Rect, + pub hit: Rect, + pub list: Rect, + pub segment_height: f64, + pub pitch: f64, + pub row_height: f64, + pub padding: f64, + pub rows: usize, +} + +pub(crate) fn contains(rect: Rect, p: Point) -> bool { + p.x >= rect.x && p.x < rect.x + rect.width && p.y >= rect.y && p.y < rect.y + rect.height +} + +impl IndicatorGeometry { + pub fn new(content: Rect, output: Rect, left_boundary: f64, count: usize, c: TabIndicatorSettings) -> Option { + if count < 2 || content.width <= 0.0 || content.height <= 0.0 || output.width <= 0.0 || output.height <= 0.0 { + return None; + } + let left = content.x.max(output.x); + let right = (content.x + content.width).min(output.x + output.width); + let top = content.y.max(output.y); + let bottom = (content.y + content.height).min(output.y + output.height); + if right - left < c.width || bottom <= top { + return None; + } + let gap = (content.x - left_boundary).max(0.0); + let x = if content.x >= output.x && gap >= c.gap_threshold { + content.x - (c.gap_threshold + c.width) * 0.5 + } else { + left + } + .clamp(output.x, (right - c.width).max(output.x)); + let natural = count as f64 * c.segment_height + count.saturating_sub(1) as f64 * c.segment_gap; + let fit = ((bottom - top) / natural).min(1.0); + let height = (natural * fit).min(bottom - top); + let rail = Rect::new(x, top + (bottom - top - height) * 0.5, c.width, height); + let hit_width = c.hit_width.min(output.width); + let hit = Rect::new( + (x + c.width * 0.5 - hit_width * 0.5).clamp(output.x, output.x + output.width - hit_width), + rail.y, + hit_width, + rail.height, + ); + let padding = c.padding.min(output.height * 0.1).min(output.width * 0.1); + let row_height = c.row_height.min((output.height - 2.0 * padding).max(f64::EPSILON)); + let rows = (((output.height - 2.0 * padding) / row_height).floor() as usize) + .max(1) + .min(count); + let list_width = c.list_width.min(output.width); + let list_height = (rows as f64 * row_height + 2.0 * padding).min(output.height); + let x = if hit.x + hit.width + padding + list_width <= output.x + output.width { + hit.x + hit.width + padding + } else if hit.x - padding - list_width >= output.x { + hit.x - padding - list_width + } else { + (hit.x + hit.width + padding).clamp(output.x, output.x + output.width - list_width) + }; + let list = Rect::new( + x, + (rail.y + rail.height * 0.5 - list_height * 0.5).clamp(output.y, output.y + output.height - list_height), + list_width, + list_height, + ); + Some(Self { + rail, + hit, + list, + segment_height: c.segment_height * fit, + pitch: (c.segment_height + c.segment_gap) * fit, + row_height, + padding, + rows, + }) + } + + pub fn row(&self, visible_index: usize) -> Rect { + Rect::new( + self.list.x + self.padding, + self.list.y + self.padding + visible_index as f64 * self.row_height, + (self.list.width - 2.0 * self.padding).max(0.0), + self.row_height, + ) + } + + pub fn hover_contains(&self, p: Point) -> bool { + let left = self.hit.x.min(self.list.x); + let top = self.hit.y.min(self.list.y); + contains( + Rect::new( + left, + top, + (self.hit.x + self.hit.width).max(self.list.x + self.list.width) - left, + (self.hit.y + self.hit.height).max(self.list.y + self.list.height) - top, + ), + p, + ) + } +} + +impl Ferese { + pub(crate) fn scrolling_tab_bar(&self, anchor: WindowId) -> Option<&ferese_layout::TabBar> { + let workspace = self.workspaces.workspace_for_window(anchor)?; + if !matches!( + self.workspaces.workspace(workspace)?.layout, + WorkspaceLayout::Scrolling(_) + ) { + return None; + } + self.visible_tab_bars(workspace) + .find(|bar| bar.group.windows.first() == Some(&anchor)) + } + + pub(crate) fn tab_indicator_geometry( + &self, + bar: &ferese_layout::TabBar, + content: Rect, + ) -> Option { + let workspace = self.workspaces.workspace_for_window(bar.group.active)?; + let output_id = self.output_workspaces.output_for_workspace(workspace)?; + if self.output_workspaces.active_workspace(output_id) != Some(workspace) { + return None; + } + let output = self.outputs_by_id.get(&output_id)?; + let g = self.space.output_geometry(output)?; + let bounds = Rect::new(g.loc.x as f64, g.loc.y as f64, g.size.w as f64, g.size.h as f64); + let mut boundary = bounds.x; + // Measure the current visible gap, including animated column positions. + for window in self.space.elements() { + let Some(id) = self.windows.ids().get(window).copied() else { + continue; + }; + if id == bar.group.active + || self.workspaces.workspace_for_window(id) != Some(workspace) + || !self.workspaces.workspace(workspace)?.layout.contains(id) + { + continue; + } + let Some(rect) = self.presented_window_rect(id) else { + continue; + }; + if rect.x + rect.width <= content.x + && rect.y < content.y + content.height + && rect.y + rect.height > content.y + { + boundary = boundary.max(rect.x + rect.width); + } + } + IndicatorGeometry::new( + content, + bounds, + boundary, + bar.group.windows.len(), + self.tab_indicator_settings, + ) + } + + fn tab_indicator_can_hit(&self, active: WindowId, p: Point) -> bool { + if self.session_lock.active() + || self.input_capture.active() + || self.overview.is_presenting() + || self.seat.get_pointer().is_some_and(|p| p.is_grabbed()) + || self.layer_surface_under(p, &[Layer::Overlay, Layer::Top]).is_some() + { + return false; + } + // Match the renderer's per-window stacking. An overlay can cover its + // own client and lower windows, never floating windows/popups above it. + for window in self.space.elements().rev() { + let Some(id) = self.windows.ids().get(window).copied() else { + continue; + }; + if id == active { + break; + } + if self.presented_window_rect(id).is_some_and(|r| contains(r, p)) { + return false; + } + } + if self + .window_under_visual(p) + .is_some_and(|w| self.windows.ids().get(&w) == Some(&active)) + && let Some((mut surface, _)) = self.window_surface_under(p) + { + while let Some(parent) = smithay::wayland::compositor::get_parent(&surface) { + surface = parent; + } + if self + .windows + .window(active) + .and_then(|w| w.toplevel()) + .is_none_or(|t| t.wl_surface() != &surface) + { + return false; + } + } + true + } + + pub(crate) fn indicator_tab_at(&self, p: Point) -> Option { + let workspace = self.workspace_under_pointer(p)?; + if !matches!( + self.workspaces.workspace(workspace)?.layout, + WorkspaceLayout::Scrolling(_) + ) { + return None; + } + for bar in self.visible_tab_bars(workspace) { + let Some(content) = self.presented_window_rect(bar.group.active) else { + continue; + }; + let Some(g) = self.tab_indicator_geometry(bar, content) else { + continue; + }; + if !self.tab_indicator_can_hit(bar.group.active, p) { + continue; + } + let anchor = bar.group.windows[0]; + let expanded = self + .tab_indicator + .hover + .as_ref() + .is_some_and(|h| h.anchor == anchor && h.opacity.target > 0.0); + if expanded && contains(g.list, p) { + let first = self + .tab_indicator + .first_row + .min(bar.group.windows.len().saturating_sub(g.rows)); + let index = ((p.y - g.list.y - g.padding).max(0.0) / g.row_height).floor() as usize; + return bar.group.windows.get(first + index.min(g.rows - 1)).copied(); + } + if contains(g.hit, p) { + let index = ((p.y - g.rail.y) / g.pitch).floor() as usize; + return bar.group.windows.get(index.min(bar.group.windows.len() - 1)).copied(); + } + if expanded && g.hover_contains(p) { + return Some(bar.group.active); + } + } + None + } + + pub(crate) fn update_tab_indicator_hover(&mut self, p: Point) { + let anchor = self.indicator_tab_at(p).and_then(|id| { + self.workspaces + .workspace_for_window(id) + .and_then(|ws| self.workspaces.workspace(ws)) + .and_then(|ws| ws.layout.tab_group(id)) + .and_then(|group| group.windows.first().copied()) + }); + let unchanged = match (anchor, &self.tab_indicator.hover) { + (Some(anchor), Some(hover)) => hover.anchor == anchor && hover.opacity.target == 1.0, + (None, Some(hover)) => hover.opacity.target == 0.0, + (None, None) => true, + _ => false, + }; + if unchanged { + return; + } + self.advance_animations(Instant::now()); + if let Some(anchor) = anchor { + let same = self.tab_indicator.hover.as_ref().is_some_and(|h| h.anchor == anchor); + if !same { + let selected = self + .scrolling_tab_bar(anchor) + .and_then(|bar| { + let g = self.tab_indicator_geometry(bar, self.presented_window_rect(bar.group.active)?)?; + let selected = bar.group.windows.iter().position(|id| *id == bar.group.active)?; + Some( + selected + .saturating_sub(g.rows / 2) + .min(bar.group.windows.len().saturating_sub(g.rows)), + ) + }) + .unwrap_or(0); + self.tab_indicator.first_row = selected; + self.tab_indicator.hover = Some(Overlay { + anchor, + opacity: AnimatedValue::new(0.0), + }); + } + if let Some(hover) = &mut self.tab_indicator.hover { + if hover.opacity.target == 1.0 { + return; + } + hover.opacity.retarget_preserving_motion(1.0); + if !self.animations_enabled { + hover.opacity.snap(); + } + } + } else if let Some(hover) = &mut self.tab_indicator.hover { + if hover.opacity.target == 0.0 { + return; + } + hover.opacity.retarget_preserving_motion(0.0); + if !self.animations_enabled { + hover.opacity.snap(); + } + } else { + return; + } + crate::backends::direct::render_all(self); + } + + pub(crate) fn scroll_tab_indicator(&mut self, p: Point, amount: f64) -> bool { + let Some(hover) = &self.tab_indicator.hover else { + return false; + }; + if hover.opacity.target == 0.0 || self.indicator_tab_at(p).is_none() { + return false; + } + let Some(bar) = self.scrolling_tab_bar(hover.anchor) else { + return false; + }; + let Some(content) = self.presented_window_rect(bar.group.active) else { + return false; + }; + let Some(g) = self.tab_indicator_geometry(bar, content) else { + return false; + }; + let max = bar.group.windows.len().saturating_sub(g.rows); + if amount != 0.0 { + self.tab_indicator.first_row = if amount > 0.0 { + self.tab_indicator.first_row.saturating_add(1).min(max) + } else { + self.tab_indicator.first_row.saturating_sub(1).min(max) + }; + crate::backends::direct::render_all(self); + } + true + } + + pub(crate) fn flash_tab_switch(&mut self, previous: WindowId, current: WindowId) { + if previous == current { + return; + } + let Some(workspace) = self + .workspaces + .workspace_for_window(current) + .and_then(|ws| self.workspaces.workspace(ws)) + else { + return; + }; + let WorkspaceLayout::Scrolling(layout) = &workspace.layout else { + return; + }; + let Some(group) = layout.tab_group(current).filter(|g| g.windows.contains(&previous)) else { + return; + }; + if self.tab_indicator_settings.flash_duration.is_zero() { + return; + } + let anchor = group.windows[0]; + if let Some(token) = self.tab_indicator.timer.take() { + self.loop_handle.remove(token); + } + if let Some(flash) = self.tab_indicator.flash.as_mut().filter(|f| f.anchor == anchor) { + flash.opacity.retarget_preserving_motion(1.0); + } else { + let mut opacity = AnimatedValue::new(0.0); + opacity.set_target(1.0); + self.tab_indicator.flash = Some(Overlay { anchor, opacity }); + } + if !self.animations_enabled + && let Some(flash) = &mut self.tab_indicator.flash + { + flash.opacity.snap(); + } + match self.loop_handle.insert_source( + Timer::from_duration(self.tab_indicator_settings.flash_duration), + |_, _, state| { + state.tab_indicator.timer = None; + if let Some(flash) = &mut state.tab_indicator.flash { + flash.opacity.set_target(0.0); + if !state.animations_enabled { + flash.opacity.snap(); + } + } + crate::backends::direct::render_all(state); + TimeoutAction::Drop + }, + ) { + Ok(token) => self.tab_indicator.timer = Some(token), + Err(error) => { + self.tab_indicator.flash = None; + tracing::warn!(%error, "could not schedule tab feedback"); + } + } + crate::backends::direct::render_all(self); + } + + pub(crate) fn reconcile_tab_indicator(&mut self) { + if self.session_lock.active() || self.input_capture.active() || self.overview.is_presenting() { + self.clear_tab_indicator(); + return; + } + if self + .tab_indicator + .hover + .as_ref() + .is_some_and(|h| self.scrolling_tab_bar(h.anchor).is_none()) + { + self.tab_indicator.hover = None; + } + if self + .tab_indicator + .flash + .as_ref() + .is_some_and(|h| self.scrolling_tab_bar(h.anchor).is_none()) + { + self.tab_indicator.flash = None; + if let Some(token) = self.tab_indicator.timer.take() { + self.loop_handle.remove(token); + } + } + } + + pub(crate) fn clear_tab_indicator(&mut self) { + if let Some(token) = self.tab_indicator.timer.take() { + self.loop_handle.remove(token); + } + self.tab_indicator = TabIndicatorState::default(); + } + + pub(crate) fn tab_indicator_output_animating(&self, output: &Output) -> bool { + [&self.tab_indicator.hover, &self.tab_indicator.flash] + .into_iter() + .flatten() + .any(|o| o.opacity.is_animating() && self.window_belongs_to_output(o.anchor, output)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn settings() -> TabIndicatorSettings { + crate::config::Config::parse_source("") + .unwrap() + .tab_indicator_settings() + .unwrap() + } + + #[test] + fn rail_uses_gap_or_inner_edge_without_changing_content_geometry() { + let content = Rect::new(100.25, 20.5, 400., 300.); + let output = Rect::new(0., 0., 800., 600.); + let c = settings(); + for gap in [0., 7.99, 8., 24.] { + let g = IndicatorGeometry::new(content, output, content.x - gap, 3, c).unwrap(); + assert_eq!( + (g.rail.width, g.rail.height, g.segment_height, g.pitch), + (4., 62., 18., 22.) + ); + assert_eq!(g.rail.y, content.y + (content.height - 62.) * 0.5); + assert_eq!(g.hit.width, 12.); + if gap >= 8. { + assert!(g.rail.x >= content.x - gap && g.rail.x + g.rail.width <= content.x); + } else { + assert!(g.rail.x >= content.x && g.rail.x + g.rail.width <= content.x + 12.); + } + assert!(g.hover_contains((g.hit.x + g.hit.width, g.rail.y + 2.).into())); + } + assert!(IndicatorGeometry::new(content, output, 0., 1, c).is_none()); + } + + #[test] + fn overflow_and_fractional_output_edges_keep_list_and_rail_reachable() { + for output in [ + Rect::new(-800.25, 10.5, 800., 600.), + Rect::new(0., 0., 100., 40.), + Rect::new(0., 0., 8., 0.5), + ] { + let content = Rect::new(output.x - 20., output.y, output.width + 40., output.height); + let g = IndicatorGeometry::new(content, output, output.x, 100, settings()).unwrap(); + for rect in [g.rail, g.hit, g.list] { + assert!(rect.x >= output.x && rect.y >= output.y); + assert!(rect.x + rect.width <= output.x + output.width + 1e-8); + assert!(rect.y + rect.height <= output.y + output.height + 1e-8); + } + assert!(g.rows < 100 && g.rows > 0); + assert!(g.segment_height > 0.); + } + } + + #[test] + fn interrupted_hover_spring_keeps_motion_and_settles_without_background_ticks() { + let mut state = TabIndicatorState { + hover: Some(Overlay { + anchor: WindowId(1), + opacity: AnimatedValue::new(0.), + }), + ..Default::default() + }; + state.hover.as_mut().unwrap().opacity.set_target(1.); + state.advance(Duration::from_millis(30), SpringConfig::default(), true); + let before = state.hover.as_ref().unwrap().opacity; + state.hover.as_mut().unwrap().opacity.retarget_preserving_motion(0.); + let after = state.hover.as_ref().unwrap().opacity; + assert_eq!((before.current, before.velocity), (after.current, after.velocity)); + for _ in 0..300 { + state.advance(Duration::from_millis(16), SpringConfig::default(), true); + } + assert!(state.hover.is_none()); + assert!(!state.is_animating()); + } +} diff --git a/compositor/src/state/tabs.rs b/compositor/src/state/tabs.rs index a5a217f..60e2f0b 100644 --- a/compositor/src/state/tabs.rs +++ b/compositor/src/state/tabs.rs @@ -71,8 +71,10 @@ impl Ferese { 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); + if let Some(target) = self.workspaces.active().layout.adjacent_tab(window, previous) + && self.activate_managed_window(target) + { + self.flash_tab_switch(window, target); } } @@ -100,6 +102,12 @@ impl Ferese { return None; } let workspace = self.workspace_under_pointer(position)?; + if matches!( + self.workspaces.workspace(workspace)?.layout, + WorkspaceLayout::Scrolling(_) + ) { + return self.indicator_tab_at(position); + } // Floating windows and popups in front of the group keep their input. if self.window_surface_under(position).is_some() { return None; diff --git a/compositor/src/state/window_lifecycle_tests.rs b/compositor/src/state/window_lifecycle_tests.rs index 4e85d7e..0bef0b9 100644 --- a/compositor/src/state/window_lifecycle_tests.rs +++ b/compositor/src/state/window_lifecycle_tests.rs @@ -1073,3 +1073,101 @@ fn scrolling_tabs_switch_with_directional_focus_and_drop_hidden_resize_holds() { assert_eq!(state.space.elements().count(), 2); assert_eq!(state.tab_bars[&workspace][0].group.windows, [a, b]); } + +#[test] +fn tab_indicator_hover_switch_and_flash_preserve_window_geometry() { + if !crate::startup_tests::private_runtime( + "state::window_lifecycle_tests::tab_indicator_hover_switch_and_flash_preserve_window_geometry", + ) { + 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 a = state.windows.ids()[&first]; + let b = state.windows.ids()[&second]; + let workspace = state.workspaces.active_id(); + let bar = state.tab_bars[&workspace][0].clone(); + let before = state.windows.geometry(&b).unwrap().logical; + let g = state + .tab_indicator_geometry(&bar, state.presented_window_rect(b).unwrap()) + .unwrap(); + let point = (g.hit.x + g.hit.width * 0.5, g.rail.y + g.segment_height * 0.5).into(); + assert_eq!(state.tab_at(point), Some(a)); + assert!( + state.surface_under(point).is_none(), + "the rail must not deliver input to the client" + ); + state.update_tab_indicator_hover(point); + assert_eq!(state.tab_indicator.hover.as_ref().unwrap().opacity.current, 1.); + assert_eq!(state.windows.geometry(&b).unwrap().logical, before); + let row = g.row(0); + let point = (row.x + row.width * 0.5, row.y + row.height * 0.5).into(); + state.update_tab_indicator_hover(point); + assert_eq!(state.tab_at(point), Some(a)); + state.activate_managed_window(a); + assert_eq!(state.windows.geometry(&a).unwrap().logical, before); + assert!(state.tab_indicator.flash.is_none(), "pointer activation does not flash"); + state.update_tab_indicator_hover((799., 599.).into()); + assert_eq!(state.tab_indicator.hover.as_ref().unwrap().opacity.current, 0.); + state.tab_indicator_settings.flash_duration = Duration::from_millis(1); + state.cycle_tab(false); + assert_eq!(state.focused_window, Some(b)); + assert_eq!(state.tab_indicator.flash.as_ref().unwrap().opacity.current, 1.); + assert_eq!(state.windows.geometry(&b).unwrap().logical, before); + let deadline = Instant::now() + Duration::from_secs(1); + while state + .tab_indicator + .flash + .as_ref() + .is_some_and(|f| f.opacity.target != 0.) + && Instant::now() < deadline + { + events.dispatch(Duration::from_millis(5), &mut state).unwrap(); + } + assert_eq!(state.tab_indicator.flash.as_ref().unwrap().opacity.current, 0.); + state.toggle_column_tabbed(); + assert!(state.tab_indicator.hover.is_none()); + assert!(state.tab_indicator.flash.is_none()); + assert!(state.tab_at(point).is_none()); +} + +#[test] +fn tab_indicator_yields_to_a_floating_window_and_disappears_with_one_tab() { + if !crate::startup_tests::private_runtime( + "state::window_lifecycle_tests::tab_indicator_yields_to_a_floating_window_and_disappears_with_one_tab", + ) { + 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 a = state.windows.ids()[&first]; + let b = state.windows.ids()[&second]; + let workspace = state.workspaces.active_id(); + let (overlay, _overlay_wire) = window(&mut state, &mut events, 0xff778899); + let overlay_id = state.windows.ids()[&overlay]; + state.toggle_focused_floating(); + let bar = state.tab_bars[&workspace][0].clone(); + let g = state + .tab_indicator_geometry(&bar, state.presented_window_rect(b).unwrap()) + .unwrap(); + let point = (g.hit.x + 6., g.hit.y + 5.).into(); + state + .workspaces + .set_floating_rect(overlay_id, Rect::new(g.hit.x, g.hit.y, 200., 100.)) + .unwrap(); + state.relayout(); + assert!( + state.tab_at(point).is_none(), + "floating window above the rail keeps its pointer input" + ); + state.activate_managed_window(b); + state.leave_tab(); + assert!(state.scrolling_tab_bar(a).is_none()); + assert!(state.tab_indicator.hover.is_none()); +} diff --git a/crates/ferese-layout/src/scrolling.rs b/crates/ferese-layout/src/scrolling.rs index f31e738..76d54a6 100644 --- a/crates/ferese-layout/src/scrolling.rs +++ b/crates/ferese-layout/src/scrolling.rs @@ -834,16 +834,11 @@ impl ScrollingLayout { 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, + bounds.y + outer, column_width, - viewport_height - header, + viewport_height, ); // All tabs use one size, even when their client constraints differ. // Keep the column allocation stable and honor compatible maximums. @@ -864,13 +859,13 @@ impl ScrollingLayout { 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 { + if column.windows.len() > 1 { 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), + rect, }); } result.geometry.insert(window, rect); @@ -2798,3 +2793,38 @@ mod tabbed_tests { } } } + +#[cfg(test)] +mod overlay_tab_tests { + use super::*; + + #[test] + fn tab_indicator_never_reserves_layout_space() { + let mut layout = ScrollingLayout::default(); + let a = WindowId(1); + let b = WindowId(2); + layout.insert(a, None).unwrap(); + layout.toggle_column_tabbed(a).unwrap(); + let bounds = Rect::new(0., 0., 800., 600.); + let gaps = GapConfig { + inner: 12., + outer: 8., + smart: false, + }; + let before = layout + .resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(a)) + .unwrap(); + layout.insert(b, Some(a)).unwrap(); + layout.move_into_column(b, a).unwrap(); + let after = layout + .resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(b)) + .unwrap(); + assert_eq!(before.geometry[&a], after.geometry[&b]); + assert_eq!(after.tab_bars[0].rect, after.geometry[&b]); + layout.focus_and_reveal(a).unwrap(); + let switched = layout + .resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(a)) + .unwrap(); + assert_eq!(switched.geometry[&a], after.geometry[&b]); + } +} diff --git a/docs/configuration.md b/docs/configuration.md index 93a676f..9a6df87 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -559,12 +559,50 @@ 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. +Tree groups show a compact title bar above the active window. Scrolling columns +use a segmented rail along the left edge. Click a segment to select its tab, or +hover over the rail to open a title list. The list stays open while the pointer +is over the rail or list, and scrolls if it is taller than the screen. Keyboard +tab switching briefly shows `n/N · title` near the top of the window. + +The scrolling indicator and its title list are overlays: neither reserves space +nor changes the window size. With at least 8px of gap to the left, the rail sits +in that gap; otherwise it sits just inside the window's left edge. Its invisible +click area is wider than the rail. On very short windows, the segments compress +to stay within the visible window height. + +New windows open beside the group. Closing, floating, or moving a tab to another +workspace removes it from the group. Single-window groups, maximized windows and +fullscreen windows hide the tab controls. Fullscreen remains per-window: restore +before grouping or switching tabs. Switching layout modes keeps every window but +dissolves groups. + +Configure the scrolling indicator under `scrolling.tab-indicator`: + +```kdl +scrolling { + tab-indicator { + width 4 + segment-height 18 + segment-gap 4 + hit-width 12 + gap-threshold 8 + list-width 220 + row-height 28 + padding 4 + font-size 12 + flash-duration-ms 1000 + } +} +``` + +Sizes are logical pixels. `flash-duration-ms 0` disables keyboard feedback. +Optional `active-color`, `inactive-color`, `background-color`, and `text-color` +accept the same color syntax as theme colors. When omitted, they follow the +current theme's accent, border, base surface, and primary text respectively. +The rail has fully rounded ends; the title list follows the shell corner radius. +Both use flat fills, without blur or shadows. Changes apply on configuration +reload. To make future scrolling columns tabbed by default: diff --git a/docs/shortcuts.md b/docs/shortcuts.md index 259f9e6..2e4b44e 100644 --- a/docs/shortcuts.md +++ b/docs/shortcuts.md @@ -178,3 +178,7 @@ binding "Super+Q" disabled=#true See [Commands and bindings](configuration.md#commands-and-bindings) for action names, arguments, and physical-key matching. + +In scrolling columns, hover over the left-edge tab indicator for window titles. +Click a segment or title to switch tabs. Keyboard tab switching briefly shows the +selected tab number and title without opening the list. diff --git a/scripts/tests/test_tree_tabs_isolated.py b/scripts/tests/test_tree_tabs_isolated.py index 8d29e0a..47f799e 100644 --- a/scripts/tests/test_tree_tabs_isolated.py +++ b/scripts/tests/test_tree_tabs_isolated.py @@ -37,7 +37,8 @@ class TabGroupsTest(unittest.TestCase): 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') + (config / "config.kdl").write_text(f'layout {{ mode "{mode}"; }}\nanimations {{ enabled #false; }}\n' + + 'scrolling { tab-indicator { active-color "#ff0000"; inactive-color "#00ff00"; }; }\n') host = Path(os.environ["WAYLAND_DISPLAY"]) if not host.is_absolute(): host = Path(os.environ["XDG_RUNTIME_DIR"]) / host @@ -117,6 +118,20 @@ class TabGroupsTest(unittest.TestCase): captured = subprocess.check_output([str(ctl), "screenshot"], env=ipc_environment(env), stderr=log, timeout=10) image = Image.open(io.BytesIO(captured)).convert("RGB") + if mode == "scrolling": + # The overlay rail sits inside the left edge at the default 4px gap. + for index, window_id in enumerate(ids): + x = round((active["x"] + 2) * scale) + y = round((active["y"] + active["height"] / 2 + (index - 1) * 22) * scale) + r, g, b = image.getpixel((x, y)) + if window_id == active["id"]: + if r < 180 or g > 60 or b > 60: + return False + elif g < 180 or r > 60 or b > 60: + return False + self.assertEqual(active["y"], current_neighbor["y"]) + self.assertEqual(active["height"], current_neighbor["height"]) + return True for index in range(3): left = active["x"] + active["width"] * index / 3 + 4 right = active["x"] + active["width"] * (index + 1) / 3 - 4