diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 0957855..aa3fdf1 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -124,6 +124,8 @@ pub struct Ferese { closing_windows: HashMap, viewport_animations: HashMap, scrolling_world_x: HashMap, + viewport_coupled_widths: HashMap, + pending_column_width_cycles: HashSet, pub focused_window: Option, column_width_presets: Vec, 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::>(); 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); + } + } } diff --git a/crates/ferese-layout/src/scrolling.rs b/crates/ferese-layout/src/scrolling.rs index 4de81ef..5d07518 100644 --- a/crates/ferese-layout/src/scrolling.rs +++ b/crates/ferese-layout/src/scrolling.rs @@ -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(); diff --git a/docs/ferese-window-manager-spec.md b/docs/ferese-window-manager-spec.md index 9c660e7..3258c47 100644 --- a/docs/ferese-window-manager-spec.md +++ b/docs/ferese-window-manager-spec.md @@ -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