From ed529ae9003ffe73c6de4daa1a80e4c9e541f48a Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Fri, 25 Sep 2026 02:41:19 +0100 Subject: [PATCH] feat: animate scrolling workspace viewports --- README.md | 5 + compositor/src/config.rs | 101 +++++++++++++++- compositor/src/main.rs | 2 + compositor/src/state.rs | 100 +++++++++++++++- crates/ferese-animation/src/lib.rs | 123 +++++++++++++++++++ crates/ferese-core/src/workspace.rs | 98 ++++++++++++--- crates/ferese-layout/src/lib.rs | 2 +- crates/ferese-layout/src/scrolling.rs | 166 ++++++++++++++++++++++---- docs/ferese-window-manager-spec.md | 78 ++++++++++-- 9 files changed, 623 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index b0b57cc..77ea601 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,11 @@ default_column_width = "full" `default_column_width = 1.0` is equivalent. `Super+M` switches the active workspace between scrolling and tree modes. +Scrolling keeps windows in stable workspace coordinates and animates the +viewport only when focus must be revealed. Set +`focus_strategy = "center_on_focus"` under `[scrolling]` to center focused +columns instead. + Scrolling controls use `Super+R` to cycle configured width presets, `Super+C` to center the focused column, `Super+[` to consume the focused window into an adjacent column, and `Super+]` to expel it. `Super+Ctrl+H/J/K/L` resizes in the diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 1feaedf..c8cfa1b 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -7,7 +7,7 @@ use std::path::PathBuf; use ferese_animation::SpringConfig; use ferese_core::LayoutMode; -use ferese_layout::ColumnWidth; +use ferese_layout::{ColumnWidth, ViewportFocusStrategy}; use serde::Deserialize; #[derive(Debug, Default, Deserialize)] @@ -30,6 +30,8 @@ struct AnimationsConfig { speed: f64, #[serde(default)] spring: SpringSettings, + #[serde(default)] + viewport_spring: ViewportSpringSettings, } impl Default for AnimationsConfig { @@ -39,6 +41,27 @@ impl Default for AnimationsConfig { reduced_motion: false, speed: 1.0, spring: SpringSettings::default(), + viewport_spring: ViewportSpringSettings::default(), + } + } +} + +#[derive(Debug, Deserialize)] +struct ViewportSpringSettings { + #[serde(default = "default_viewport_mass")] + mass: f64, + #[serde(default = "default_viewport_stiffness")] + stiffness: f64, + #[serde(default = "default_viewport_damping_ratio")] + damping_ratio: f64, +} + +impl Default for ViewportSpringSettings { + fn default() -> Self { + Self { + mass: default_viewport_mass(), + stiffness: default_viewport_stiffness(), + damping_ratio: default_viewport_damping_ratio(), } } } @@ -78,10 +101,18 @@ enum LayoutModeValue { #[derive(Debug, Default, Deserialize)] struct ScrollingConfig { default_column_width: Option, + focus_strategy: Option, #[serde(default)] width_presets: Vec, } +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +enum FocusStrategyValue { + Minimal, + CenterOnFocus, +} + #[derive(Clone, Debug, Deserialize)] #[serde(untagged)] enum ColumnWidthValue { @@ -187,6 +218,17 @@ impl Config { .collect() } + pub fn scrolling_focus_strategy(&self) -> ViewportFocusStrategy { + match self + .scrolling + .focus_strategy + .unwrap_or(FocusStrategyValue::Minimal) + { + FocusStrategyValue::Minimal => ViewportFocusStrategy::Minimal, + FocusStrategyValue::CenterOnFocus => ViewportFocusStrategy::Center, + } + } + pub fn animations_enabled(&self) -> bool { self.animations.enabled && !self.animations.reduced_motion } @@ -214,6 +256,26 @@ impl Config { ..SpringConfig::default() }) } + + pub fn viewport_spring_config(&self) -> Result { + let mass = + positive_animation_value(self.animations.viewport_spring.mass, "viewport_spring.mass")?; + let stiffness = positive_animation_value( + self.animations.viewport_spring.stiffness, + "viewport_spring.stiffness", + )?; + let damping_ratio = positive_animation_value( + self.animations.viewport_spring.damping_ratio, + "viewport_spring.damping_ratio", + )?; + + Ok(SpringConfig { + mass, + stiffness, + damping: 2.0 * damping_ratio * (stiffness * mass).sqrt(), + ..SpringConfig::default() + }) + } } fn positive_animation_value(value: f64, field: &'static str) -> Result { @@ -244,6 +306,18 @@ const fn default_spring_damping() -> f64 { 53.0 } +const fn default_viewport_mass() -> f64 { + 1.0 +} + +const fn default_viewport_stiffness() -> f64 { + 320.0 +} + +const fn default_viewport_damping_ratio() -> f64 { + 1.0 +} + fn parse_column_width( value: &ColumnWidthValue, field: &'static str, @@ -323,6 +397,21 @@ mod tests { ); } + #[test] + fn parses_scrolling_focus_strategy() { + let minimal = parse(""); + let centered = parse("[scrolling]\nfocus_strategy = \"center_on_focus\""); + + assert_eq!( + minimal.scrolling_focus_strategy(), + ViewportFocusStrategy::Minimal + ); + assert_eq!( + centered.scrolling_focus_strategy(), + ViewportFocusStrategy::Center + ); + } + #[test] fn rejects_non_positive_or_unknown_column_widths() { let zero = parse("[scrolling]\ndefault_column_width = 0.0"); @@ -359,4 +448,14 @@ mod tests { assert!(speed.animation_speed().is_err()); assert!(damping.spring_config().is_err()); } + + #[test] + fn derives_critical_viewport_damping() { + let config = parse(""); + let spring = config.viewport_spring_config().unwrap(); + + assert_eq!(spring.mass, 1.0); + assert_eq!(spring.stiffness, 320.0); + assert!((spring.damping - 35.777_087_64).abs() < 0.000_001); + } } diff --git a/compositor/src/main.rs b/compositor/src/main.rs index b4fab40..58483bf 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -29,10 +29,12 @@ fn main() -> Result<(), Box> { let runtime = RuntimeConfig { layout_mode: config.layout_mode(), default_column_width: config.default_column_width()?, + scrolling_focus_strategy: config.scrolling_focus_strategy(), column_width_presets: config.width_presets()?, animations_enabled: config.animations_enabled(), animation_speed: config.animation_speed()?, spring_config: config.spring_config()?, + viewport_spring_config: config.viewport_spring_config()?, }; let mut state = Ferese::new(&mut event_loop, display, runtime)?; backends::init(launch.backend, &mut event_loop, &mut state)?; diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 99ee9a0..f68224f 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -6,12 +6,14 @@ use std::{ time::{Duration, Instant}, }; -use ferese_animation::{ClientSize, SpringConfig, WindowGeometry}; +use ferese_animation::{AnimatedValue, ClientSize, SpringConfig, WindowGeometry}; use ferese_core::{ - LayoutMode, OutputGeometry, OutputId, OutputWorkspaceMap, WindowPlacement, WorkspaceSet, + LayoutMode, OutputGeometry, OutputId, OutputWorkspaceMap, WindowPlacement, WorkspaceId, + WorkspaceSet, }; use ferese_layout::{ - Axis, ColumnWidth, Direction, GapConfig, LayoutResult, Rect, SizeConstraints, WindowId, + Axis, ColumnWidth, Direction, GapConfig, LayoutResult, Rect, SizeConstraints, + ViewportFocusStrategy, WindowId, }; use smithay::{ @@ -84,11 +86,14 @@ pub struct Ferese { output_identity_ids: HashMap, pub window_ids: HashMap, pub window_geometry: HashMap, + viewport_animations: HashMap, + scrolling_world_x: HashMap, pub focused_window: Option, column_width_presets: Vec, animations_enabled: bool, animation_speed: f64, spring_config: SpringConfig, + viewport_spring_config: SpringConfig, pub cursor_status: CursorImageStatus, pub(crate) cursor_theme: xcursor::CursorTheme, pub(crate) named_cursors: HashMap, @@ -133,10 +138,12 @@ pub struct Ferese { pub struct RuntimeConfig { pub layout_mode: LayoutMode, pub default_column_width: ColumnWidth, + pub scrolling_focus_strategy: ViewportFocusStrategy, pub column_width_presets: Vec, pub animations_enabled: bool, pub animation_speed: f64, pub spring_config: SpringConfig, + pub viewport_spring_config: SpringConfig, } impl Ferese { @@ -342,17 +349,24 @@ impl Ferese { display_handle, loop_signal: event_loop.get_signal(), space: Space::default(), - workspaces: WorkspaceSet::new(config.layout_mode, config.default_column_width), + workspaces: WorkspaceSet::new( + config.layout_mode, + config.default_column_width, + config.scrolling_focus_strategy, + ), output_workspaces: OutputWorkspaceMap::default(), output_ids: HashMap::new(), output_identity_ids: HashMap::new(), window_ids: HashMap::new(), window_geometry: HashMap::new(), + viewport_animations: HashMap::new(), + scrolling_world_x: HashMap::new(), focused_window: None, column_width_presets: config.column_width_presets, animations_enabled: config.animations_enabled, animation_speed: config.animation_speed, spring_config: config.spring_config, + viewport_spring_config: config.viewport_spring_config, cursor_status: CursorImageStatus::default_named(), cursor_theme, named_cursors, @@ -639,6 +653,7 @@ impl Ferese { self.space.unmap_elem(window); self.window_geometry.remove(&id); + self.scrolling_world_x.remove(&id); if let Err(error) = self.workspaces.remove_window(id) { tracing::error!(%error, ?id, "failed to remove window from layout"); } @@ -651,6 +666,7 @@ impl Ferese { pub fn relayout(&mut self) { self.arrange_layers(); + let mut previous_scrolling_world_x = std::mem::take(&mut self.scrolling_world_x); let outputs = self.space.outputs().cloned().collect::>(); let constraints = self.window_constraints(); let mut visible = HashSet::new(); @@ -693,6 +709,18 @@ impl Ferese { continue; } }; + let viewport_target = workspace.layout.viewport_x(); + let viewport_current = viewport_target.map(|target| { + let viewport = self + .viewport_animations + .entry(workspace_id) + .or_insert_with(|| AnimatedValue::new(target)); + viewport.retarget_preserving_motion(target); + if !self.animations_enabled { + viewport.snap(); + } + viewport.current + }); for warning in layout.warnings { tracing::warn!( @@ -732,7 +760,22 @@ impl Ferese { ); visible.insert(*id); - placements.push((window.clone(), *id, rect, is_fullscreen, is_floating)); + let scrolling = if !is_fullscreen && !is_floating { + viewport_target + .zip(viewport_current) + .map(|(target, current)| (workspace_id, rect.x + target, current)) + } else { + None + }; + + placements.push(( + window.clone(), + *id, + rect, + is_fullscreen, + is_floating, + scrolling, + )); } } @@ -749,7 +792,9 @@ impl Ferese { let now = self.start_time.elapsed(); - for (window, id, rect, is_fullscreen, is_floating) in placements { + let mut scrolling_world_x = HashMap::new(); + + for (window, id, rect, is_fullscreen, is_floating, scrolling) in placements { let committed_size = client_size(&window); let geometry = self .window_geometry @@ -759,6 +804,20 @@ impl Ferese { if !self.animations_enabled { geometry.advance(Duration::ZERO, self.spring_config, false); } + if let Some((workspace, world_x, viewport_x)) = scrolling { + let mut animated_world_x = previous_scrolling_world_x + .remove(&id) + .filter(|(previous_workspace, _)| *previous_workspace == workspace) + .map(|(_, world_x)| world_x) + .unwrap_or_else(|| AnimatedValue::new(world_x)); + animated_world_x.set_target(world_x); + if !self.animations_enabled { + animated_world_x.snap(); + } + geometry.visual.current.x = animated_world_x.current - viewport_x; + geometry.visual.velocity.x = 0.0; + scrolling_world_x.insert(id, (workspace, animated_world_x)); + } let visual = geometry.visual.current; let location = (visual.x.round() as i32, visual.y.round() as i32); @@ -804,6 +863,7 @@ impl Ferese { } } } + self.scrolling_world_x = scrolling_world_x; crate::backends::direct::render_all(self); } @@ -832,6 +892,12 @@ impl Ferese { fn advance_animations_by(&mut self, delta: std::time::Duration) -> bool { let delta = delta.mul_f64(self.animation_speed); + let visible_workspaces = self + .space + .outputs() + .filter_map(|output| self.output_ids.get(output)) + .filter_map(|output| self.output_workspaces.active_workspace(*output)) + .collect::>(); let windows = self .space .elements() @@ -844,6 +910,17 @@ impl Ferese { .collect::>(); let mut active_animation = false; + for (workspace, viewport) in &mut self.viewport_animations { + if !visible_workspaces.contains(workspace) { + continue; + } + if self.animations_enabled { + active_animation |= viewport.advance(delta, self.viewport_spring_config); + } else { + viewport.snap(); + } + } + for (window, id) in windows { let Some(geometry) = self.window_geometry.get_mut(&id) else { continue; @@ -851,6 +928,17 @@ impl Ferese { active_animation |= geometry.advance(delta, self.spring_config, self.animations_enabled); + if let Some((workspace, world_x)) = self.scrolling_world_x.get_mut(&id) + && let Some(viewport) = self.viewport_animations.get(workspace) + { + if self.animations_enabled { + active_animation |= world_x.advance(delta, self.spring_config); + } else { + world_x.snap(); + } + geometry.visual.current.x = world_x.current - viewport.current; + geometry.visual.velocity.x = 0.0; + } if let Some(size) = geometry.client.expire_wait(self.start_time.elapsed()) { tracing::warn!( ?id, diff --git a/crates/ferese-animation/src/lib.rs b/crates/ferese-animation/src/lib.rs index 64ae0df..ece79d3 100644 --- a/crates/ferese-animation/src/lib.rs +++ b/crates/ferese-animation/src/lib.rs @@ -56,6 +56,83 @@ pub struct AnimatedRect { pub velocity: RectVelocity, } +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AnimatedValue { + pub current: f64, + pub target: f64, + pub velocity: f64, +} + +impl AnimatedValue { + pub fn new(value: f64) -> Self { + let value = finite_or_zero(value); + + Self { + current: value, + target: value, + velocity: 0.0, + } + } + + pub fn set_target(&mut self, target: f64) { + self.target = finite_or_zero(target); + } + + pub fn retarget_preserving_motion(&mut self, target: f64) { + let target = finite_or_zero(target); + let direction = (target - self.current).signum(); + + if direction != 0.0 && self.velocity.signum() != direction { + self.velocity *= 0.35; + } + self.target = target; + } + + pub fn snap(&mut self) { + self.current = self.target; + self.velocity = 0.0; + } + + pub fn advance(&mut self, delta: Duration, config: SpringConfig) -> bool { + let config = config.normalized(); + if (self.current - self.target).abs() <= config.position_tolerance + && self.velocity.abs() <= config.velocity_tolerance + { + self.snap(); + return false; + } + + let seconds = delta.min(MAX_FRAME_DELTA).as_secs_f64(); + let steps = (seconds / MAX_STEP_SECONDS).ceil().max(1.0) as usize; + let step = seconds / steps as f64; + + for _ in 0..steps { + let previous_error = self.current - self.target; + advance_value( + &mut self.current, + self.target, + &mut self.velocity, + step, + config, + ); + let current_error = self.current - self.target; + if previous_error != 0.0 && previous_error.signum() != current_error.signum() { + self.snap(); + break; + } + } + + if (self.current - self.target).abs() <= config.position_tolerance + && self.velocity.abs() <= config.velocity_tolerance + { + self.snap(); + false + } else { + true + } + } +} + impl AnimatedRect { pub fn new(rect: Rect) -> Self { Self { @@ -276,6 +353,10 @@ fn advance_value( } } +fn finite_or_zero(value: f64) -> f64 { + if value.is_finite() { value } else { 0.0 } +} + fn normalized_rect(rect: Rect) -> Rect { Rect::new( finite_or(rect.x, 0.0), @@ -326,6 +407,48 @@ fn finite_or(value: f64, fallback: f64) -> f64 { mod tests { use super::*; + #[test] + fn scalar_spring_retargets_from_current_motion() { + let mut value = AnimatedValue::new(0.0); + value.set_target(1_000.0); + value.advance(Duration::from_millis(40), SpringConfig::default()); + let current = value.current; + let velocity = value.velocity; + + value.retarget_preserving_motion(2_000.0); + + assert_eq!(value.current, current); + assert_eq!(value.velocity, velocity); + assert_eq!(value.target, 2_000.0); + } + + #[test] + fn scalar_spring_softens_opposing_velocity_on_reversal() { + let mut value = AnimatedValue::new(0.0); + value.set_target(1_000.0); + value.advance(Duration::from_millis(40), SpringConfig::default()); + let current = value.current; + let velocity = value.velocity; + + value.retarget_preserving_motion(-500.0); + + assert!(value.velocity > 0.0); + assert_eq!(value.velocity, velocity * 0.35); + assert_eq!(value.current, current); + } + + #[test] + fn scalar_spring_does_not_overshoot_target() { + let mut value = AnimatedValue::new(0.0); + value.velocity = 20_000.0; + value.set_target(100.0); + + for _ in 0..60 { + value.advance(Duration::from_secs_f64(1.0 / 60.0), SpringConfig::default()); + assert!(value.current <= 100.0); + } + } + #[test] fn logical_target_changes_without_jumping_visual_geometry() { let initial = Rect::new(0.0, 0.0, 400.0, 300.0); diff --git a/crates/ferese-core/src/workspace.rs b/crates/ferese-core/src/workspace.rs index 185d409..b0146cc 100644 --- a/crates/ferese-core/src/workspace.rs +++ b/crates/ferese-core/src/workspace.rs @@ -4,7 +4,7 @@ use std::fmt; use ferese_layout::{ Axis, ColumnWidth, Direction, GapConfig, LayoutError, LayoutResult, LayoutTree, Rect, - ScrollingLayout, SizeConstraints, WindowId, + ScrollingLayout, SizeConstraints, ViewportFocusStrategy, WindowId, }; #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] @@ -40,10 +40,16 @@ impl Default for WorkspaceLayout { } impl WorkspaceLayout { - pub fn new(mode: LayoutMode, default_column_width: ColumnWidth) -> Self { + pub fn new( + mode: LayoutMode, + default_column_width: ColumnWidth, + focus_strategy: ViewportFocusStrategy, + ) -> Self { match mode { LayoutMode::Scrolling => { - Self::Scrolling(ScrollingLayout::with_default_width(default_column_width)) + let mut layout = ScrollingLayout::with_default_width(default_column_width); + layout.set_focus_strategy(focus_strategy); + Self::Scrolling(layout) } LayoutMode::Tree => Self::Tree(LayoutTree::default()), } @@ -56,6 +62,20 @@ impl WorkspaceLayout { } } + pub fn viewport_x(&self) -> Option { + match self { + Self::Scrolling(layout) => Some(layout.viewport_x()), + Self::Tree(_) => None, + } + } + + pub fn preferred_window(&self) -> Option { + match self { + Self::Scrolling(layout) => layout.active_window(), + Self::Tree(layout) => layout.window_ids().next(), + } + } + pub fn contains(&self, window: WindowId) -> bool { match self { Self::Scrolling(layout) => layout.contains(window), @@ -235,6 +255,7 @@ impl WorkspaceLayout { bounds: Rect, focused: Option, default_column_width: ColumnWidth, + focus_strategy: ViewportFocusStrategy, ) -> Result { if self.mode() == mode { return Ok(false); @@ -265,6 +286,7 @@ impl WorkspaceLayout { Self::Tree(layout) => { let windows = layout.window_ids_in_reading_order(bounds)?; let mut scrolling = ScrollingLayout::with_default_width(default_column_width); + scrolling.set_focus_strategy(focus_strategy); let mut previous = None; for window in windows { @@ -339,22 +361,35 @@ pub struct WorkspaceSet { placements: HashMap, default_layout_mode: LayoutMode, default_column_width: ColumnWidth, + scrolling_focus_strategy: ViewportFocusStrategy, next_id: u64, } impl Default for WorkspaceSet { fn default() -> Self { - Self::new(LayoutMode::default(), ColumnWidth::default()) + Self::new( + LayoutMode::default(), + ColumnWidth::default(), + ViewportFocusStrategy::default(), + ) } } impl WorkspaceSet { - pub fn new(default_layout_mode: LayoutMode, default_column_width: ColumnWidth) -> Self { + pub fn new( + default_layout_mode: LayoutMode, + default_column_width: ColumnWidth, + scrolling_focus_strategy: ViewportFocusStrategy, + ) -> Self { let active = WorkspaceId(1); let workspace = Workspace { id: active, name: "1".to_owned(), - layout: WorkspaceLayout::new(default_layout_mode, default_column_width), + layout: WorkspaceLayout::new( + default_layout_mode, + default_column_width, + scrolling_focus_strategy, + ), floating: Vec::new(), last_focused: None, fullscreen: None, @@ -370,6 +405,7 @@ impl WorkspaceSet { placements: HashMap::new(), default_layout_mode, default_column_width, + scrolling_focus_strategy, next_id: 2, } } @@ -423,11 +459,16 @@ impl WorkspaceSet { bounds: Rect, ) -> Result { let default_column_width = self.default_column_width; + let focus_strategy = self.scrolling_focus_strategy; let workspace = self.active_mut(); let focused = tiled_focus(workspace); - let changed = workspace - .layout - .set_mode(mode, bounds, focused, default_column_width)?; + let changed = workspace.layout.set_mode( + mode, + bounds, + focused, + default_column_width, + focus_strategy, + )?; debug_assert!(self.validate().is_ok()); Ok(changed) @@ -588,7 +629,11 @@ impl WorkspaceSet { Workspace { id, name, - layout: WorkspaceLayout::new(self.default_layout_mode, self.default_column_width), + layout: WorkspaceLayout::new( + self.default_layout_mode, + self.default_column_width, + self.scrolling_focus_strategy, + ), floating: Vec::new(), last_focused: None, fullscreen: None, @@ -914,11 +959,13 @@ impl WorkspaceSet { } fn first_window(workspace: &Workspace) -> Option { - workspace - .layout - .window_ids() - .chain(workspace.floating.iter().copied()) - .min_by_key(|window| window.0) + workspace.layout.preferred_window().or_else(|| { + workspace + .floating + .iter() + .copied() + .min_by_key(|window| window.0) + }) } fn tiled_focus(workspace: &Workspace) -> Option { @@ -959,7 +1006,11 @@ mod tests { #[test] fn configured_layout_defaults_apply_to_new_workspaces() { - let mut workspaces = WorkspaceSet::new(LayoutMode::Scrolling, ColumnWidth::Full); + let mut workspaces = WorkspaceSet::new( + LayoutMode::Scrolling, + ColumnWidth::Full, + ViewportFocusStrategy::Minimal, + ); workspaces .insert_window(WindowId(1), Axis::Horizontal, 0.5) .unwrap(); @@ -1044,6 +1095,21 @@ mod tests { assert!(workspaces.validate().is_ok()); } + #[test] + fn closing_a_scrolling_column_focuses_its_adjacent_column() { + let mut workspaces = WorkspaceSet::default(); + for id in 1..=4 { + workspaces + .insert_window(WindowId(id), Axis::Horizontal, 0.5) + .unwrap(); + } + workspaces.focus_window(WindowId(3)).unwrap(); + + workspaces.remove_window(WindowId(3)).unwrap(); + + assert_eq!(workspaces.active().last_focused, Some(WindowId(4))); + } + #[test] fn focus_rejects_a_window_on_an_inactive_workspace() { let mut workspaces = WorkspaceSet::default(); diff --git a/crates/ferese-layout/src/lib.rs b/crates/ferese-layout/src/lib.rs index 0f29912..3cd4fcc 100644 --- a/crates/ferese-layout/src/lib.rs +++ b/crates/ferese-layout/src/lib.rs @@ -4,7 +4,7 @@ use std::fmt; mod scrolling; -pub use scrolling::{Column, ColumnWidth, ScrollingLayout}; +pub use scrolling::{Column, ColumnWidth, ScrollingLayout, ViewportFocusStrategy}; const MIN_SPLIT_RATIO: f64 = 0.05; const MAX_SPLIT_RATIO: f64 = 0.95; diff --git a/crates/ferese-layout/src/scrolling.rs b/crates/ferese-layout/src/scrolling.rs index bc3aba6..4de81ef 100644 --- a/crates/ferese-layout/src/scrolling.rs +++ b/crates/ferese-layout/src/scrolling.rs @@ -9,6 +9,8 @@ const DEFAULT_WIDTH: f64 = 0.5; const MIN_COLUMN_WIDTH: f64 = 0.1; const MAX_COLUMN_WIDTH: f64 = 2.0; const MIN_ROW_HEIGHT: f64 = 0.05; +const COMFORT_START: f64 = 0.2; +const COMFORT_END: f64 = 0.8; #[derive(Clone, Copy, Debug, PartialEq)] pub enum ColumnWidth { @@ -17,6 +19,13 @@ pub enum ColumnWidth { Full, } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum ViewportFocusStrategy { + #[default] + Minimal, + Center, +} + impl Default for ColumnWidth { fn default() -> Self { Self::Proportion(DEFAULT_WIDTH) @@ -67,6 +76,8 @@ pub struct ScrollingLayout { viewport_x: f64, neighbor_context: f64, default_width: ColumnWidth, + focus_strategy: ViewportFocusStrategy, + reveal_pending: bool, } impl Default for ScrollingLayout { @@ -77,6 +88,8 @@ impl Default for ScrollingLayout { viewport_x: 0.0, neighbor_context: 48.0, default_width: ColumnWidth::default(), + focus_strategy: ViewportFocusStrategy::Minimal, + reveal_pending: false, } } } @@ -89,6 +102,10 @@ impl ScrollingLayout { } } + pub fn set_focus_strategy(&mut self, strategy: ViewportFocusStrategy) { + self.focus_strategy = strategy; + } + pub fn default_width(&self) -> ColumnWidth { self.default_width } @@ -101,6 +118,11 @@ impl ScrollingLayout { self.active_column } + pub fn active_window(&self) -> Option { + let column = self.columns.get(self.active_column?)?; + column.windows.get(column.active).copied() + } + pub fn viewport_x(&self) -> f64 { self.viewport_x } @@ -131,6 +153,7 @@ impl ScrollingLayout { self.columns .insert(index, Column::new(window, self.default_width)); self.active_column = Some(index); + self.reveal_pending = self.columns.len() > 1; debug_assert!(self.validate().is_ok()); Ok(()) @@ -141,6 +164,8 @@ impl ScrollingLayout { .window_location(window) .ok_or(LayoutError::UnknownWindow(window))?; let column = &mut self.columns[column_index]; + let removed_active_column = + self.active_column == Some(column_index) && column.windows.len() == 1; column.windows.remove(window_index); column.heights.remove(window_index); @@ -156,6 +181,7 @@ impl ScrollingLayout { column.active = column.active.min(column.windows.len() - 1); column.normalize_heights(); } + self.reveal_pending |= removed_active_column && !self.columns.is_empty(); debug_assert!(self.validate().is_ok()); Ok(()) @@ -165,8 +191,10 @@ impl ScrollingLayout { let (column, index) = self .window_location(window) .ok_or(LayoutError::UnknownWindow(window))?; + let changed_column = self.active_column != Some(column); self.active_column = Some(column); self.columns[column].active = index; + self.reveal_pending |= changed_column; Ok(()) } @@ -191,6 +219,7 @@ impl ScrollingLayout { column.active = insertion; column.normalize_heights(); self.active_column = Some(target_column); + self.reveal_pending = true; debug_assert!(self.validate().is_ok()); Ok(()) @@ -210,6 +239,7 @@ impl ScrollingLayout { self.columns .insert(insertion, Column::new(window, self.default_width)); self.active_column = Some(insertion); + self.reveal_pending = true; debug_assert!(self.validate().is_ok()); Ok(()) @@ -405,7 +435,7 @@ impl ScrollingLayout { let viewport_width = (bounds.width - outer * 2.0).max(1.0); let viewport_height = (bounds.height - outer * 2.0).max(1.0); let mut column_positions = Vec::with_capacity(self.columns.len()); - let mut strip_width = 0.0; + let mut next_column_x = 0.0; for column in &self.columns { let requested = match normalized_width(column.width) { @@ -428,11 +458,13 @@ impl ScrollingLayout { }) .fold(1.0_f64, f64::max); let width = requested.max(minimum).max(1.0); - column_positions.push((strip_width, width)); - strip_width += width + inner; + column_positions.push((next_column_x, width)); + next_column_x += width + inner; + } + if self.reveal_pending { + self.reveal_active_column(&column_positions, viewport_width); + self.reveal_pending = false; } - strip_width = (strip_width - inner).max(0.0); - self.reveal_active_column(&column_positions, strip_width, viewport_width); let mut result = LayoutResult::default(); for (column_index, column) in self.columns.iter().enumerate() { @@ -464,25 +496,36 @@ impl ScrollingLayout { Ok(result) } - fn reveal_active_column( - &mut self, - positions: &[(f64, f64)], - strip_width: f64, - viewport_width: f64, - ) { - let max_scroll = (strip_width - viewport_width).max(0.0); - self.viewport_x = finite_nonnegative(self.viewport_x).min(max_scroll); + fn reveal_active_column(&mut self, positions: &[(f64, f64)], viewport_width: f64) { let Some(active) = self.active_column else { return; }; let (start, width) = positions[active]; let end = start + width; - let context = finite_nonnegative(self.neighbor_context).min(viewport_width / 3.0); + if self.focus_strategy == ViewportFocusStrategy::Center { + self.viewport_x = start + width / 2.0 - viewport_width / 2.0; + return; + } - if start < self.viewport_x + context { - self.viewport_x = (start - context).max(0.0); - } else if end > self.viewport_x + viewport_width - context { - self.viewport_x = (end - viewport_width + context).min(max_scroll); + if start >= self.viewport_x && end <= self.viewport_x + viewport_width { + return; + } + + let comfort_start = viewport_width * COMFORT_START; + let comfort_end = viewport_width * COMFORT_END; + let comfort_width = comfort_end - comfort_start; + + if width > comfort_width { + let context = finite_nonnegative(self.neighbor_context).min(viewport_width / 3.0); + if start > self.viewport_x + comfort_start { + self.viewport_x = start - context; + } else if end < self.viewport_x + comfort_end { + self.viewport_x = end - viewport_width + context; + } + } else if start < self.viewport_x + comfort_start { + self.viewport_x = start - comfort_start; + } else if end > self.viewport_x + comfort_end { + self.viewport_x = end - comfort_end; } } @@ -497,6 +540,7 @@ impl ScrollingLayout { if self.columns[source].windows.len() == 1 { self.columns.swap(source, destination); self.active_column = Some(destination); + self.reveal_pending = true; return Ok(()); } @@ -510,6 +554,7 @@ impl ScrollingLayout { self.columns .insert(insertion, Column::new(window, self.default_width)); self.active_column = Some(insertion); + self.reveal_pending = true; Ok(()) } @@ -523,7 +568,7 @@ impl ScrollingLayout { } pub fn validate(&self) -> Result<(), LayoutError> { - if !self.viewport_x.is_finite() || self.viewport_x < 0.0 { + if !self.viewport_x.is_finite() { return Err(LayoutError::InvalidTree( "scrolling viewport offset is invalid", )); @@ -701,8 +746,8 @@ mod tests { .geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(4))) .unwrap(); - assert_eq!(layout.viewport_x(), 1_000.0); - assert_eq!(result.geometry[&window(4)].x, 500.0); + assert_eq!(layout.viewport_x(), 1_200.0); + assert_eq!(result.geometry[&window(4)].x, 300.0); } #[test] @@ -737,6 +782,85 @@ mod tests { ); } + #[test] + fn manual_resize_does_not_retarget_the_viewport() { + let mut layout = ScrollingLayout::default(); + let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0); + let gaps = GapConfig { + inner: 0.0, + outer: 0.0, + smart: false, + }; + + for id in 1..=3 { + layout + .insert(window(id), (id > 1).then(|| window(id - 1))) + .unwrap(); + layout + .geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(id))) + .unwrap(); + } + let before = layout.viewport_x(); + + layout + .resize_window(window(3), Direction::Right, 0.5) + .unwrap(); + layout + .geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(3))) + .unwrap(); + + assert_eq!(layout.viewport_x(), before); + } + + #[test] + fn viewport_is_not_clamped_to_the_column_strip() { + let mut layout = ScrollingLayout::default(); + let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0); + let gaps = GapConfig { + inner: 0.0, + outer: 0.0, + smart: false, + }; + + for id in 1..=3 { + layout.insert(window(id), None).unwrap(); + } + layout + .geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(3))) + .unwrap(); + layout + .geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(1))) + .unwrap(); + + assert_eq!(layout.viewport_x(), -200.0); + } + + #[test] + fn closing_non_focused_column_does_not_move_viewport() { + let mut layout = ScrollingLayout::default(); + let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0); + let gaps = GapConfig { + inner: 0.0, + outer: 0.0, + smart: false, + }; + + for id in 1..=4 { + layout.insert(window(id), None).unwrap(); + } + layout + .geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(4))) + .unwrap(); + let before = layout.viewport_x(); + + layout.remove(window(1)).unwrap(); + layout + .geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(4))) + .unwrap(); + + assert_eq!(layout.viewport_x(), before); + } + #[test] fn grouping_and_extracting_preserve_membership() { let mut layout = ScrollingLayout::default(); diff --git a/docs/ferese-window-manager-spec.md b/docs/ferese-window-manager-spec.md index 8b2b3be..ac1655d 100644 --- a/docs/ferese-window-manager-spec.md +++ b/docs/ferese-window-manager-spec.md @@ -465,19 +465,77 @@ Scrolling layouts maintain these invariants after every transaction: 3. Proportional and fixed widths are positive and finite. Height weights are positive, finite, match the column's window count, and normalize deterministically. -4. `viewport_x` is finite and clamped to the strip, while permitting the final - column to align with either viewport edge. +4. `viewport_x` is finite. It is an unbounded world-space offset and MUST NOT be + clamped to the populated column strip. Scrolling columns form a horizontal strip that MAY be wider than the usable -output. Windows within a column divide its height. The viewport follows focus -only as far as required to reveal the focused window and SHOULD retain part of -an adjacent column when space permits. It MUST NOT recenter on every focus -change. An explicit center command MAY center the focused column. +output. Windows within a column divide its height; there is no vertical +scrolling. Window geometry uses stable workspace coordinates and presentation +subtracts the workspace viewport: + +```text +screen_x = window_world_x - viewport_x +``` + +Navigation animates one viewport spring per visible scrolling workspace. +Existing windows MUST NOT receive independent horizontal motion for the same +navigation. Retargeting while the viewport is moving preserves its current +interpolated position and velocity; viewport moves are not queued. + +Viewport translation uses a dedicated critically damped spring, separate from +window geometry: + +```text +mass = 1.0 +stiffness = 320.0 +damping_ratio = 1.0 +damping = 2 * damping_ratio * sqrt(stiffness * mass) # approximately 35.8 +``` + +It MUST NOT visibly overshoot. Rapid navigation retargets this same spring +immediately and may coalesce commands received before the next presented frame. +Same-direction velocity is preserved. Direction reversal preserves the current +position, reduces opposing velocity when necessary, and reverses without a +snap. + +New normal windows insert immediately after the focused column at their final +world position and become focused. The compositor then computes the minimum +viewport movement needed to expose the new column. A small local reveal using +opacity, scale, or an x offset MAY supplement this motion, but the viewport is +the primary animation. + +When the focused column closes, focus moves to an adjacent column and the +viewport retargets to reveal it. Closing a non-focused column MUST NOT move the +viewport. Manual column resize also MUST NOT retarget it; a subsequent +navigation, open, or focused-close operation may do so. + +During an in-flight viewport transition, resize leaves the viewport target and +motion intact while affected world geometry uses the normal window spring. The +composed transform is: + +```text +screen_x = animated_world_x - animated_viewport_x +``` + +Resize MUST NOT restart or cancel the viewport spring. If reflow would make the +focused window completely unreachable, Ferese MAY apply only the minimum +visibility correction, not normal comfort-region recentering. + +The default reveal strategy avoids scrolling when the focused column is already +fully visible. Otherwise it moves only enough to place the column in a comfort +region approximately 20% through 80% of the viewport. It MUST NOT center every +focus change. `focus_strategy = "center_on_focus"` selects that alternative, +and the explicit center command always centers the focused column. When a large +column cannot fit the comfort region, Ferese SHOULD retain 40–80 logical pixels +of the previous column where possible. + +Each scrolling workspace owns its viewport. Outputs animate their hosted +workspaces independently; neither columns nor a viewport span outputs. Opening, closing, moving, or resizing a window MUST preserve a stable visual anchor: columns before the focused column do not jump merely because a later column changed. Each workspace stores and restores its own viewport. Output -resize or migration clamps that viewport without discarding it. Constraint +resize or migration preserves that viewport without imposing strip bounds. Constraint resolution MUST prefer the focused column, preserve user-selected widths where possible, and report conflicts rather than silently compressing every column to fit. These stable anchors, deterministic constraints, and retained neighboring @@ -1016,6 +1074,11 @@ stiffness = 700.0 damping = 53.0 mass = 1.0 +[animations.viewport_spring] +stiffness = 320.0 +damping_ratio = 1.0 +mass = 1.0 + [layout] mode = "scrolling" inner_gap = 10.0 @@ -1026,6 +1089,7 @@ smart_gaps = true default_column_width = 0.5 # Use 1.0 or "full" to open every new column at the viewport width. width_presets = [0.333333, 0.5, 0.666667, 1.0] +focus_strategy = "minimal" neighbor_context = 48.0 new_window = "column_after_focused"