fix: preserve scrolling zoom direction

This commit is contained in:
Samuel Onoja committed 2026-09-25 03:58:20 +01:00
1 parent 578b536b26
commit c601dcbac5
3 files changed
+236 -5

No files matched your search

+91 -4
View File
@@ -124,6 +124,8 @@ pub struct Ferese {
closing_windows: HashMap<WindowId, ClosingAnimation>, closing_windows: HashMap<WindowId, ClosingAnimation>,
viewport_animations: HashMap<WorkspaceId, AnimatedValue>, viewport_animations: HashMap<WorkspaceId, AnimatedValue>,
scrolling_world_x: HashMap<WindowId, (WorkspaceId, AnimatedValue)>, scrolling_world_x: HashMap<WindowId, (WorkspaceId, AnimatedValue)>,
viewport_coupled_widths: HashMap<WindowId, (WorkspaceId, AnimatedValue)>,
pending_column_width_cycles: HashSet<WindowId>,
pub focused_window: Option<WindowId>, pub focused_window: Option<WindowId>,
column_width_presets: Vec<ColumnWidth>, column_width_presets: Vec<ColumnWidth>,
gap_config: GapConfig, gap_config: GapConfig,
@@ -430,6 +432,8 @@ impl Ferese {
closing_windows: HashMap::new(), closing_windows: HashMap::new(),
viewport_animations: HashMap::new(), viewport_animations: HashMap::new(),
scrolling_world_x: HashMap::new(), scrolling_world_x: HashMap::new(),
viewport_coupled_widths: HashMap::new(),
pending_column_width_cycles: HashSet::new(),
focused_window: None, focused_window: None,
column_width_presets: config.column_width_presets, column_width_presets: config.column_width_presets,
gap_config: config.gap_config, gap_config: config.gap_config,
@@ -817,6 +821,8 @@ impl Ferese {
self.closing_windows.remove(&id); self.closing_windows.remove(&id);
self.window_rules_applied.remove(&id); self.window_rules_applied.remove(&id);
self.scrolling_world_x.remove(&id); self.scrolling_world_x.remove(&id);
self.viewport_coupled_widths.remove(&id);
self.pending_column_width_cycles.remove(&id);
if let Err(error) = self.workspaces.remove_window(id) { if let Err(error) = self.workspaces.remove_window(id) {
tracing::error!(%error, ?id, "failed to remove window from layout"); tracing::error!(%error, ?id, "failed to remove window from layout");
} }
@@ -830,6 +836,7 @@ impl Ferese {
pub fn relayout(&mut self) { pub fn relayout(&mut self) {
self.arrange_layers(); self.arrange_layers();
let mut previous_scrolling_world_x = std::mem::take(&mut self.scrolling_world_x); let mut previous_scrolling_world_x = std::mem::take(&mut self.scrolling_world_x);
let pending_column_width_cycles = std::mem::take(&mut self.pending_column_width_cycles);
let outputs = self.space.outputs().cloned().collect::<Vec<_>>(); let outputs = self.space.outputs().cloned().collect::<Vec<_>>();
let constraints = self.window_constraints(); let constraints = self.window_constraints();
let mut visible = HashSet::new(); let mut visible = HashSet::new();
@@ -873,17 +880,20 @@ impl Ferese {
} }
}; };
let viewport_target = workspace.layout.viewport_x(); let viewport_target = workspace.layout.viewport_x();
let viewport_current = viewport_target.map(|target| { let viewport_motion = viewport_target.map(|target| {
let viewport = self let viewport = self
.viewport_animations .viewport_animations
.entry(workspace_id) .entry(workspace_id)
.or_insert_with(|| AnimatedValue::new(target)); .or_insert_with(|| AnimatedValue::new(target));
let target_changed = (viewport.target - target).abs() > 0.001;
viewport.retarget_preserving_motion(target); viewport.retarget_preserving_motion(target);
if !self.animations_enabled { if !self.animations_enabled {
viewport.snap(); viewport.snap();
} }
viewport.current (viewport.current, target_changed)
}); });
let viewport_current = viewport_motion.map(|(current, _)| current);
let viewport_target_changed = viewport_motion.is_some_and(|(_, changed)| changed);
for warning in layout.warnings { for warning in layout.warnings {
tracing::warn!( tracing::warn!(
@@ -938,6 +948,7 @@ impl Ferese {
is_fullscreen, is_fullscreen,
is_floating, is_floating,
scrolling, scrolling,
pending_column_width_cycles.contains(id) && viewport_target_changed,
)); ));
} }
} }
@@ -957,7 +968,7 @@ impl Ferese {
let mut scrolling_world_x = HashMap::new(); let mut scrolling_world_x = HashMap::new();
for (window, id, rect, is_fullscreen, is_floating, scrolling) in placements { for (window, id, rect, is_fullscreen, is_floating, scrolling, couple_width) in placements {
let committed_size = client_size(&window); let committed_size = client_size(&window);
let geometry = self let geometry = self
.window_geometry .window_geometry
@@ -980,6 +991,28 @@ impl Ferese {
geometry.visual.current.x = animated_world_x.current - viewport_x; geometry.visual.current.x = animated_world_x.current - viewport_x;
geometry.visual.velocity.x = 0.0; geometry.visual.velocity.x = 0.0;
scrolling_world_x.insert(id, (workspace, animated_world_x)); scrolling_world_x.insert(id, (workspace, animated_world_x));
let coupled = couple_width
|| self
.viewport_coupled_widths
.get(&id)
.is_some_and(|(previous_workspace, _)| *previous_workspace == workspace);
if coupled {
let width = self.viewport_coupled_widths.entry(id).or_insert_with(|| {
(workspace, AnimatedValue::new(geometry.visual.current.width))
});
if width.0 != workspace {
*width = (workspace, AnimatedValue::new(geometry.visual.current.width));
}
width.1.retarget_preserving_motion(rect.width);
if !self.animations_enabled {
width.1.snap();
}
geometry.visual.current.width = width.1.current;
geometry.visual.velocity.width = width.1.velocity;
} else if pending_column_width_cycles.contains(&id) {
self.viewport_coupled_widths.remove(&id);
}
} }
let visual = geometry.visual.current; let visual = geometry.visual.current;
let location = (visual.x.round() as i32, visual.y.round() as i32); let location = (visual.x.round() as i32, visual.y.round() as i32);
@@ -1096,13 +1129,25 @@ impl Ferese {
} }
} }
let mut settled_coupled_widths = Vec::new();
for (window, id) in windows { for (window, id) in windows {
let Some(geometry) = self.window_geometry.get_mut(&id) else { let Some(geometry) = self.window_geometry.get_mut(&id) else {
continue; continue;
}; };
let coupled_target = self
.viewport_coupled_widths
.get(&id)
.map(|(_, width)| width.target);
if coupled_target.is_some() {
geometry.visual.target.width = geometry.visual.current.width;
geometry.visual.velocity.width = 0.0;
}
active_animation |= active_animation |=
geometry.advance(delta, self.spring_config, self.animations_enabled); geometry.advance(delta, self.spring_config, self.animations_enabled);
if let Some(target) = coupled_target {
geometry.visual.target.width = target;
}
if let Some((workspace, world_x)) = self.scrolling_world_x.get_mut(&id) if let Some((workspace, world_x)) = self.scrolling_world_x.get_mut(&id)
&& let Some(viewport) = self.viewport_animations.get(workspace) && let Some(viewport) = self.viewport_animations.get(workspace)
{ {
@@ -1114,6 +1159,20 @@ impl Ferese {
geometry.visual.current.x = world_x.current - viewport.current; geometry.visual.current.x = world_x.current - viewport.current;
geometry.visual.velocity.x = 0.0; geometry.visual.velocity.x = 0.0;
} }
if let Some((_, width)) = self.viewport_coupled_widths.get_mut(&id) {
let width_active = if self.animations_enabled {
width.advance(delta, self.viewport_spring_config)
} else {
width.snap();
false
};
active_animation |= width_active;
geometry.visual.current.width = width.current;
geometry.visual.velocity.width = width.velocity;
if !width_active {
settled_coupled_widths.push(id);
}
}
if let Some(size) = geometry.client.expire_wait(self.start_time.elapsed()) { if let Some(size) = geometry.client.expire_wait(self.start_time.elapsed()) {
tracing::warn!( tracing::warn!(
?id, ?id,
@@ -1130,6 +1189,9 @@ impl Ferese {
false, false,
); );
} }
for id in settled_coupled_widths {
self.viewport_coupled_widths.remove(&id);
}
active_animation active_animation
} }
@@ -1320,7 +1382,10 @@ impl Ferese {
.workspaces .workspaces
.cycle_column_width(window, &self.column_width_presets) .cycle_column_width(window, &self.column_width_presets)
{ {
Ok(true) => self.relayout(), Ok(true) => {
self.pending_column_width_cycles.insert(window);
self.relayout();
}
Ok(false) => {} Ok(false) => {}
Err(error) => tracing::error!(%error, ?window, "failed to cycle column width"), Err(error) => tracing::error!(%error, ?window, "failed to cycle column width"),
} }
@@ -1715,4 +1780,26 @@ mod tests {
assert!(animation.close_sent); assert!(animation.close_sent);
assert!(!animation.advance(Duration::from_secs(1))); assert!(!animation.advance(Duration::from_secs(1)));
} }
#[test]
fn coupled_right_column_width_keeps_its_right_edge_stable() {
let spring = SpringConfig {
mass: 1.0,
stiffness: 320.0,
damping: 2.0 * 320.0_f64.sqrt(),
position_tolerance: 0.1,
velocity_tolerance: 0.1,
};
let mut width = AnimatedValue::new(980.0);
let mut viewport = AnimatedValue::new(495.0);
width.retarget_preserving_motion(485.0);
viewport.retarget_preserving_motion(0.0);
for _ in 0..120 {
width.advance(Duration::from_secs_f64(1.0 / 120.0), spring);
viewport.advance(Duration::from_secs_f64(1.0 / 120.0), spring);
assert!((width.current - viewport.current - 485.0).abs() < 0.001);
}
}
} }
+102 -1
View File
@@ -78,6 +78,7 @@ pub struct ScrollingLayout {
default_width: ColumnWidth, default_width: ColumnWidth,
focus_strategy: ViewportFocusStrategy, focus_strategy: ViewportFocusStrategy,
reveal_pending: bool, reveal_pending: bool,
width_cycle_pending: bool,
} }
impl Default for ScrollingLayout { impl Default for ScrollingLayout {
@@ -90,6 +91,7 @@ impl Default for ScrollingLayout {
default_width: ColumnWidth::default(), default_width: ColumnWidth::default(),
focus_strategy: ViewportFocusStrategy::Minimal, focus_strategy: ViewportFocusStrategy::Minimal,
reveal_pending: false, reveal_pending: false,
width_cycle_pending: false,
} }
} }
} }
@@ -281,6 +283,7 @@ impl ScrollingLayout {
.map(|index| presets[(index + 1) % presets.len()]) .map(|index| presets[(index + 1) % presets.len()])
.unwrap_or(presets[0]); .unwrap_or(presets[0]);
self.columns[column].width = next; self.columns[column].width = next;
self.width_cycle_pending = next != current;
Ok(next != current) Ok(next != current)
} }
@@ -461,7 +464,11 @@ impl ScrollingLayout {
column_positions.push((next_column_x, width)); column_positions.push((next_column_x, width));
next_column_x += width + inner; next_column_x += width + inner;
} }
if self.reveal_pending { if self.width_cycle_pending {
self.retarget_after_width_cycle(&column_positions, viewport_width);
self.width_cycle_pending = false;
self.reveal_pending = false;
} else if self.reveal_pending {
self.reveal_active_column(&column_positions, viewport_width); self.reveal_active_column(&column_positions, viewport_width);
self.reveal_pending = false; self.reveal_pending = false;
} }
@@ -529,6 +536,27 @@ impl ScrollingLayout {
} }
} }
fn retarget_after_width_cycle(&mut self, positions: &[(f64, f64)], viewport_width: f64) {
let Some(active) = self.active_column else {
return;
};
let Some(&(start, width)) = positions.get(active) else {
return;
};
let Some(&(last_start, last_width)) = positions.last() else {
return;
};
let strip_width = last_start + last_width;
if strip_width <= viewport_width {
self.viewport_x = 0.0;
} else if width >= viewport_width {
self.viewport_x = start;
} else {
self.viewport_x = (start + width - viewport_width).max(0.0);
}
}
fn move_horizontally( fn move_horizontally(
&mut self, &mut self,
window: WindowId, window: WindowId,
@@ -986,6 +1014,79 @@ mod tests {
assert_eq!(layout.columns()[0].width, ColumnWidth::Proportion(0.5)); assert_eq!(layout.columns()[0].width, ColumnWidth::Proportion(0.5));
} }
#[test]
fn left_column_zoom_pushes_and_restores_the_right_column() {
let (mut layout, bounds, gaps) = two_half_width_layout();
let presets = [ColumnWidth::Proportion(0.5), ColumnWidth::Full];
layout.focus(window(1)).unwrap();
layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(1)))
.unwrap();
layout.cycle_column_width(window(1), &presets).unwrap();
let zoomed = layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(1)))
.unwrap();
assert_eq!(layout.viewport_x(), 0.0);
assert_eq!(zoomed.geometry[&window(1)].x, 10.0);
assert_eq!(zoomed.geometry[&window(1)].width, 980.0);
assert_eq!(zoomed.geometry[&window(2)].x, 1_000.0);
layout.cycle_column_width(window(1), &presets).unwrap();
let restored = layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(1)))
.unwrap();
assert_eq!(layout.viewport_x(), 0.0);
assert_eq!(restored.geometry[&window(1)].x, 10.0);
assert_eq!(restored.geometry[&window(2)].x, 505.0);
}
#[test]
fn right_column_zoom_moves_left_neighbor_out_and_back() {
let (mut layout, bounds, gaps) = two_half_width_layout();
let presets = [ColumnWidth::Proportion(0.5), ColumnWidth::Full];
layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(2)))
.unwrap();
layout.cycle_column_width(window(2), &presets).unwrap();
let zoomed = layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(2)))
.unwrap();
assert_eq!(layout.viewport_x(), 495.0);
assert_eq!(zoomed.geometry[&window(1)].x, -485.0);
assert_eq!(zoomed.geometry[&window(2)].x, 10.0);
assert_eq!(zoomed.geometry[&window(2)].width, 980.0);
layout.cycle_column_width(window(2), &presets).unwrap();
let restored = layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(2)))
.unwrap();
assert_eq!(layout.viewport_x(), 0.0);
assert_eq!(restored.geometry[&window(1)].x, 10.0);
assert_eq!(restored.geometry[&window(2)].x, 505.0);
}
fn two_half_width_layout() -> (ScrollingLayout, Rect, GapConfig) {
let mut layout = ScrollingLayout::default();
layout.insert(window(1), None).unwrap();
layout.insert(window(2), Some(window(1))).unwrap();
(
layout,
Rect::new(0.0, 0.0, 1_000.0, 800.0),
GapConfig {
inner: 10.0,
outer: 10.0,
smart: false,
},
)
}
#[test] #[test]
fn explicit_center_places_a_middle_column_in_the_viewport_center() { fn explicit_center_places_a_middle_column_in_the_viewport_center() {
let mut layout = ScrollingLayout::default(); let mut layout = ScrollingLayout::default();
+43
View File
@@ -548,6 +548,49 @@ a new column, cycle standard widths, set a precise width, or redistribute
heights within a column. Full-width columns occupy the viewport but do not alter heights within a column. Full-width columns occupy the viewport but do not alter
neighboring column widths. neighboring column widths.
### 5.1 Scrolling column zoom motion
Cycling a scrolling column between a proportional width and full width is a
layout morph, not a camera-scale effect. The focused column's visual allocation
animates with the normal geometry spring while the scrolling viewport uses its
dedicated critically damped spring. Ferese sends the final client size once and
may scale and clip the latest valid buffer inside the animated allocation until
the matching commit arrives.
For two half-width columns, zooming the left column expands it in place and
pushes the right column out through the right edge. Zooming the right column
retargets the viewport so it expands into the viewport while the left column
leaves through the left edge. Zoom-out continuously reverses these
relationships: a right neighbor returns from the right and a left neighbor
returns from the left. A neighbor remains a live window and MUST NOT fade,
teleport, use a screenshot substitute, or receive an unrelated translation.
The presented horizontal position remains the composition:
```text
screen_x = animated_world_x - animated_viewport_x
```
Width-cycle viewport targets expose the final scrolling layout. If the complete
column strip fits, the viewport returns to the strip origin. A full-width
focused column aligns to the usable viewport; a smaller focused column in a
larger strip keeps as much real left-side context as fits. This retargeting is
specific to column zoom. Pointer-driven and directional manual resize continue
toward the existing viewport target and do not trigger comfort-region
recentering.
Zoom is interruptible. A reversed width cycle or focus change retargets active
geometry and viewport springs from their current positions and velocities; it
MUST NOT finish or snap through the previous target first. Hit testing follows
presented geometry. Reduced motion shortens the settle while preserving spatial
direction, and disabling animations snaps directly to the final layout.
Tests MUST cover half-plus-half zoom and zoom-out for either focused column,
neighbor exit and return direction, bounded focused-anchor drift, interrupted
and reversed transitions, focus changes during motion, geometry/viewport
composition, one final configure rather than per-frame configure traffic,
animated hit testing, reduced motion, and animation-disabled behavior.
Mode conversion is deterministic. Scrolling-to-tree conversion builds Mode conversion is deterministic. Scrolling-to-tree conversion builds
horizontal structure from columns and vertical structure from windows within a horizontal structure from columns and vertical structure from windows within a
column, preserving reading order and size proportions. Tree-to-scrolling column, preserving reading order and size proportions. Tree-to-scrolling