feat: animate scrolling workspace viewports

This commit is contained in:
Samuel Onoja committed 2026-09-25 02:41:19 +01:00
1 parent f0862348c9
commit ed529ae900
9 files changed
+623 -52

No files matched your search

+5
View File
@@ -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
+100 -1
View File
@@ -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<ColumnWidthValue>,
focus_strategy: Option<FocusStrategyValue>,
#[serde(default)]
width_presets: Vec<ColumnWidthValue>,
}
#[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<SpringConfig, ConfigError> {
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<f64, ConfigError> {
@@ -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);
}
}
+2
View File
@@ -29,10 +29,12 @@ fn main() -> Result<(), Box<dyn Error>> {
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)?;
+94 -6
View File
@@ -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<String, OutputId>,
pub window_ids: HashMap<Window, WindowId>,
pub window_geometry: HashMap<WindowId, WindowGeometry>,
viewport_animations: HashMap<WorkspaceId, AnimatedValue>,
scrolling_world_x: HashMap<WindowId, (WorkspaceId, AnimatedValue)>,
pub focused_window: Option<WindowId>,
column_width_presets: Vec<ColumnWidth>,
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<CursorIcon, crate::cursor::NamedCursor>,
@@ -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<ColumnWidth>,
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::<Vec<_>>();
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::<HashSet<_>>();
let windows = self
.space
.elements()
@@ -844,6 +910,17 @@ impl Ferese {
.collect::<Vec<_>>();
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,
+123
View File
@@ -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);
+82 -16
View File
@@ -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<f64> {
match self {
Self::Scrolling(layout) => Some(layout.viewport_x()),
Self::Tree(_) => None,
}
}
pub fn preferred_window(&self) -> Option<WindowId> {
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<WindowId>,
default_column_width: ColumnWidth,
focus_strategy: ViewportFocusStrategy,
) -> Result<bool, LayoutError> {
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<WindowId, WindowPlacement>,
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<bool, WorkspaceError> {
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<WindowId> {
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<WindowId> {
@@ -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();
+1 -1
View File
@@ -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;
+145 -21
View File
@@ -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<WindowId> {
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();
+71 -7
View File
@@ -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"