fix: preserve scrolling zoom direction
This commit is contained in:
1 parent
578b536b26
commit
c601dcbac5
3 files changed
+236
-5
No files matched your search
+91
-4
@@ -124,6 +124,8 @@ pub struct Ferese {
|
||||
closing_windows: HashMap<WindowId, ClosingAnimation>,
|
||||
viewport_animations: HashMap<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>,
|
||||
column_width_presets: Vec<ColumnWidth>,
|
||||
gap_config: GapConfig,
|
||||
@@ -430,6 +432,8 @@ impl Ferese {
|
||||
closing_windows: HashMap::new(),
|
||||
viewport_animations: HashMap::new(),
|
||||
scrolling_world_x: HashMap::new(),
|
||||
viewport_coupled_widths: HashMap::new(),
|
||||
pending_column_width_cycles: HashSet::new(),
|
||||
focused_window: None,
|
||||
column_width_presets: config.column_width_presets,
|
||||
gap_config: config.gap_config,
|
||||
@@ -817,6 +821,8 @@ impl Ferese {
|
||||
self.closing_windows.remove(&id);
|
||||
self.window_rules_applied.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) {
|
||||
tracing::error!(%error, ?id, "failed to remove window from layout");
|
||||
}
|
||||
@@ -830,6 +836,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 pending_column_width_cycles = std::mem::take(&mut self.pending_column_width_cycles);
|
||||
let outputs = self.space.outputs().cloned().collect::<Vec<_>>();
|
||||
let constraints = self.window_constraints();
|
||||
let mut visible = HashSet::new();
|
||||
@@ -873,17 +880,20 @@ impl Ferese {
|
||||
}
|
||||
};
|
||||
let viewport_target = workspace.layout.viewport_x();
|
||||
let viewport_current = viewport_target.map(|target| {
|
||||
let viewport_motion = viewport_target.map(|target| {
|
||||
let viewport = self
|
||||
.viewport_animations
|
||||
.entry(workspace_id)
|
||||
.or_insert_with(|| AnimatedValue::new(target));
|
||||
let target_changed = (viewport.target - target).abs() > 0.001;
|
||||
viewport.retarget_preserving_motion(target);
|
||||
if !self.animations_enabled {
|
||||
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 {
|
||||
tracing::warn!(
|
||||
@@ -938,6 +948,7 @@ impl Ferese {
|
||||
is_fullscreen,
|
||||
is_floating,
|
||||
scrolling,
|
||||
pending_column_width_cycles.contains(id) && viewport_target_changed,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -957,7 +968,7 @@ impl Ferese {
|
||||
|
||||
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 geometry = self
|
||||
.window_geometry
|
||||
@@ -980,6 +991,28 @@ impl Ferese {
|
||||
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 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 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 {
|
||||
let Some(geometry) = self.window_geometry.get_mut(&id) else {
|
||||
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 |=
|
||||
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)
|
||||
&& 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.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()) {
|
||||
tracing::warn!(
|
||||
?id,
|
||||
@@ -1130,6 +1189,9 @@ impl Ferese {
|
||||
false,
|
||||
);
|
||||
}
|
||||
for id in settled_coupled_widths {
|
||||
self.viewport_coupled_widths.remove(&id);
|
||||
}
|
||||
|
||||
active_animation
|
||||
}
|
||||
@@ -1320,7 +1382,10 @@ impl Ferese {
|
||||
.workspaces
|
||||
.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) => {}
|
||||
Err(error) => tracing::error!(%error, ?window, "failed to cycle column width"),
|
||||
}
|
||||
@@ -1715,4 +1780,26 @@ mod tests {
|
||||
assert!(animation.close_sent);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,7 @@ pub struct ScrollingLayout {
|
||||
default_width: ColumnWidth,
|
||||
focus_strategy: ViewportFocusStrategy,
|
||||
reveal_pending: bool,
|
||||
width_cycle_pending: bool,
|
||||
}
|
||||
|
||||
impl Default for ScrollingLayout {
|
||||
@@ -90,6 +91,7 @@ impl Default for ScrollingLayout {
|
||||
default_width: ColumnWidth::default(),
|
||||
focus_strategy: ViewportFocusStrategy::Minimal,
|
||||
reveal_pending: false,
|
||||
width_cycle_pending: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,6 +283,7 @@ impl ScrollingLayout {
|
||||
.map(|index| presets[(index + 1) % presets.len()])
|
||||
.unwrap_or(presets[0]);
|
||||
self.columns[column].width = next;
|
||||
self.width_cycle_pending = next != current;
|
||||
Ok(next != current)
|
||||
}
|
||||
|
||||
@@ -461,7 +464,11 @@ impl ScrollingLayout {
|
||||
column_positions.push((next_column_x, width));
|
||||
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_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(
|
||||
&mut self,
|
||||
window: WindowId,
|
||||
@@ -986,6 +1014,79 @@ mod tests {
|
||||
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]
|
||||
fn explicit_center_places_a_middle_column_in_the_viewport_center() {
|
||||
let mut layout = ScrollingLayout::default();
|
||||
|
||||
@@ -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
|
||||
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
|
||||
horizontal structure from columns and vertical structure from windows within a
|
||||
column, preserving reading order and size proportions. Tree-to-scrolling
|
||||
|
||||
Reference in new issue
Block a user