diff --git a/compositor/src/state/layout.rs b/compositor/src/state/layout.rs index c3c33d8..a37c2e4 100644 --- a/compositor/src/state/layout.rs +++ b/compositor/src/state/layout.rs @@ -148,7 +148,48 @@ impl Ferese { }) .collect::>(); let mut previous_scrolling_world_x = self.windows.take_world_positions(); - let pending_column_width_cycles = self.windows.take_column_width_requests(); + let mut pending_column_width_cycles = self.windows.take_column_width_requests(); + let mut column_width_sources = HashMap::new(); + if !pending_column_width_cycles.is_empty() { + for workspace in self.workspaces.iter() { + let WorkspaceLayout::Scrolling(layout) = &workspace.layout else { + continue; + }; + for column in layout.columns() { + if !column.windows.iter().any(|id| pending_column_width_cycles.contains(id)) { + continue; + } + // A cycle belongs to the column. Copy one presented motion + // to every row; sibling configure holds already release the + // whole column together, including partial client commits. + let mut rows = column.windows.iter().filter(|id| { + self.windows + .geometry(id) + .is_some_and(|geometry| !geometry.is_zooming() && !geometry.is_fullscreen()) + }); + let source = rows + .clone() + .find_map(|id| { + self.windows + .record(*id)? + .coupled_width + .filter(|(id, _)| *id == workspace.id) + }) + .or_else(|| { + rows.find_map(|id| { + let visual = self.windows.geometry(id)?.visual; + let mut width = AnimatedValue::new(visual.current.width); + width.velocity = visual.velocity.width; + Some((workspace.id, width)) + }) + }); + pending_column_width_cycles.extend(column.windows.iter().copied()); + if let Some(source) = source { + column_width_sources.extend(column.windows.iter().map(|id| (*id, source))); + } + } + } + } let outputs = self.space.outputs().cloned().collect::>(); let constraints = self.window_constraints(); let mut visible = HashSet::new(); @@ -374,6 +415,9 @@ impl Ferese { .as_ref() .is_some_and(|(previous_workspace, _)| *previous_workspace == workspace)); if coupled { + if let Some(source) = column_width_sources.get(&id) { + record.coupled_width = Some(*source); + } let width = record .coupled_width .get_or_insert_with(|| (workspace, AnimatedValue::new(geometry.visual.current.width))); diff --git a/compositor/src/state/window_lifecycle_tests.rs b/compositor/src/state/window_lifecycle_tests.rs index bb31ff8..b7e51b3 100644 --- a/compositor/src/state/window_lifecycle_tests.rs +++ b/compositor/src/state/window_lifecycle_tests.rs @@ -31,6 +31,127 @@ fn fixture() -> (smithay::reexports::calloop::EventLoop<'static, Ferese>, Ferese (events, state, output) } +#[test] +fn width_cycle_keeps_stacked_rows_together_through_holds_forecasts_and_reversal() { + if !crate::startup_tests::private_runtime( + "state::window_lifecycle_tests::width_cycle_keeps_stacked_rows_together_through_holds_forecasts_and_reversal", + ) { + return; + } + for animations in [true, false] { + let (mut events, mut state, output) = fixture(); + state.animation_test_time = Some(Duration::ZERO); + state.gap_config = GapConfig { + inner: 10.0, + outer: 10.0, + smart: false, + }; + state.column_width_presets = vec![ColumnWidth::Proportion(0.5), ColumnWidth::Full]; + let WorkspaceLayout::Scrolling(layout) = &mut state.workspaces.active_mut().layout else { + panic!("expected scrolling layout"); + }; + layout.set_default_width(ColumnWidth::Proportion(0.5)); + layout.set_focus_strategy(ferese_layout::ViewportFocusStrategy::Minimal); + let (neighbor, _neighbor_wire) = window(&mut state, &mut events, 0xff112233); + let (first, _first_wire) = window(&mut state, &mut events, 0xff445566); + let (second, _second_wire) = window(&mut state, &mut events, 0xff778899); + let a = state.windows.ids()[&first]; + let b = state.windows.ids()[&second]; + state.workspaces.stack_window(b, a).unwrap(); + state.focused_window = Some(state.windows.ids()[&neighbor]); + state.relayout(); + state.focused_window = Some(b); + state.relayout(); + let ids = [a, b]; + for id in state.windows.resizing().copied().collect::>() { + state.windows.clear_transaction(&id); + } + state.release_presentation_dependencies(); + state.advance_animations_at(Duration::ZERO, Duration::ZERO); + let before = state.windows.geometry(&a).unwrap().visual.current.width; + assert_eq!(state.windows.geometry(&b).unwrap().visual.current.width, before); + state.animations_enabled = animations; + state.spring_config = SpringConfig { + stiffness: 900.0, + damping: 65.0, + ..Default::default() + }; + state.viewport_spring_config = SpringConfig { + stiffness: 200.0, + damping: 30.0, + ..Default::default() + }; + state.focused_window = Some(b); + let workspace = state.workspaces.active_id(); + let viewport_before = state.viewports[&workspace].motion().target; + // An interrupted width motion must provide one source and velocity for + // the column, even if a sibling previously used its general spring. + if animations { + state.windows.update(a, |record| { + let mut width = AnimatedValue::new(before); + width.velocity = 25.0; + record.coupled_width = Some((workspace, width)); + }); + } + state.cycle_focused_column_width(); + assert!(state.viewports[&workspace].motion().target > viewport_before); + for id in ids { + assert!(state.windows.record(id).unwrap().coupled_width.is_some()); + assert_eq!(state.windows.transaction(&id).is_some(), animations); + } + let assert_equal = |state: &Ferese| { + let first = *state.windows.geometry(&a).unwrap(); + let second = *state.windows.geometry(&b).unwrap(); + assert_eq!(first.visual.current.width, second.visual.current.width); + assert_eq!(first.visual.velocity.width, second.visual.velocity.width); + for horizon in [Duration::ZERO, Duration::from_millis(4), Duration::from_millis(16)] { + let scene = state.sample_frame(&output, horizon); + assert_eq!( + scene.windows[&a].geometry.visual.current.width, + scene.windows[&b].geometry.visual.current.width + ); + } + assert_eq!(*state.windows.geometry(&a).unwrap(), first); + assert_eq!(*state.windows.geometry(&b).unwrap(), second); + }; + assert_equal(&state); + state.windows.clear_transaction(&a); + state.release_presentation_dependencies(); + assert_eq!( + state.presentation_dependencies.reflow_blocked(a, &state.windows), + animations + ); + assert_eq!( + state.presentation_dependencies.reflow_blocked(b, &state.windows), + animations + ); + for millis in [16, 100, 299, 300, 316, 332] { + state.advance_animations_at(Duration::from_millis(16), Duration::from_millis(millis)); + assert_equal(&state); + } + assert!( + state.windows.transaction(&b).is_none(), + "existing deadline releases the last sibling" + ); + if animations { + assert!(state.windows.geometry(&a).unwrap().visual.current.width > before); + } + // Retarget the same column while its spring is moving. + state.cycle_focused_column_width(); + assert_equal(&state); + for id in ids { + state.windows.clear_transaction(&id); + } + state.release_presentation_dependencies(); + for frame in 1..=150 { + state.advance_animations_at(Duration::from_millis(16), Duration::from_millis(332 + frame * 16)); + assert_equal(&state); + } + assert_eq!(state.windows.geometry(&a).unwrap().visual.current.width, before); + assert_eq!(state.windows.geometry(&b).unwrap().visual.current.width, before); + } +} + #[test] fn floating_placement_reserves_the_full_opening_window_footprint() { if !crate::startup_tests::private_runtime( diff --git a/crates/ferese-layout/src/scrolling.rs b/crates/ferese-layout/src/scrolling.rs index c1b2d0f..38a1a90 100644 --- a/crates/ferese-layout/src/scrolling.rs +++ b/crates/ferese-layout/src/scrolling.rs @@ -32,6 +32,28 @@ impl Default for ColumnWidth { } } +#[derive(Clone, Copy, Debug, PartialEq)] +struct ColumnSizing { + viewport_width: f64, + inner_gap: f64, +} + +impl ColumnSizing { + fn resolve(self, width: ColumnWidth) -> f64 { + match normalized_width(width) { + ColumnWidth::Proportion(value) => { + ((self.viewport_width + self.inner_gap) * value - self.inner_gap).max(1.0) + } + ColumnWidth::Fixed(value) => value, + ColumnWidth::Full => self.viewport_width, + } + } + + fn proportion(self, resolved_width: f64) -> f64 { + (resolved_width + self.inner_gap) / (self.viewport_width + self.inner_gap) + } +} + #[derive(Clone, Debug, PartialEq)] pub struct Column { pub windows: Vec, @@ -171,7 +193,7 @@ pub struct ScrollingLayout { default_width: ColumnWidth, focus_strategy: ViewportFocusStrategy, pending_viewport: Option, - last_viewport_width: Option, + last_column_sizing: Option, allocated_widths: HashMap, viewport_basis: Option, } @@ -185,7 +207,7 @@ impl Default for ScrollingLayout { default_width: ColumnWidth::default(), focus_strategy: ViewportFocusStrategy::Minimal, pending_viewport: None, - last_viewport_width: None, + last_column_sizing: None, allocated_widths: HashMap::new(), viewport_basis: None, } @@ -335,7 +357,13 @@ impl ScrollingLayout { Some(active) => Some(active.min(self.columns.len() - 1)), }; } else { - column.active = column.active.min(column.windows.len() - 1); + column.active = if window_index < column.active { + column.active - 1 + } else { + // Removing the active row selects its successor, or the last + // surviving row when there is no successor. + column.active.min(column.windows.len() - 1) + }; column.normalize_heights(); } if self.pending_focus() == Some(PendingFocusRequest::Reveal(window)) @@ -419,9 +447,10 @@ impl ScrollingLayout { return Ok(()); } + let width = self.columns[source].width; self.remove(window)?; let insertion = (source + 1).min(self.columns.len()); - self.columns.insert(insertion, Column::new(window, self.default_width)); + self.columns.insert(insertion, Column::new(window, width)); self.active_column = Some(insertion); self.request_reveal(self.active_window()); @@ -488,7 +517,7 @@ impl ScrollingLayout { Some(position) }) .collect::>(); - let viewport_width = self.last_viewport_width.unwrap(); + let viewport_width = self.last_column_sizing.unwrap().viewport_width; self.record_viewport_basis( &positions, inner, @@ -560,6 +589,8 @@ impl ScrollingLayout { Ok(true) } + /// Horizontal resize adjusts proportions. Fixed widths need a resolved + /// viewport and start from the allocated column width, including minimums. pub fn resize_window(&mut self, window: WindowId, direction: Direction, amount: f64) -> Result { let (column, row) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?; let amount = if amount.is_finite() { amount.abs() } else { 0.0 }; @@ -571,7 +602,13 @@ impl ScrollingLayout { Direction::Left | Direction::Right => { let current = match normalized_width(self.columns[column].width) { ColumnWidth::Proportion(value) => value, - ColumnWidth::Fixed(_) | ColumnWidth::Full => DEFAULT_WIDTH, + ColumnWidth::Full => 1.0, + ColumnWidth::Fixed(width) => { + let Some(sizing) = self.last_column_sizing else { + return Ok(false); + }; + sizing.proportion(self.allocated_column_width(window).unwrap_or(width)) + } }; let adjustment = if direction == Direction::Right { amount } else { -amount }; let resized = (current + adjustment).clamp(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH); @@ -618,6 +655,10 @@ 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 sizing = ColumnSizing { + viewport_width, + inner_gap: inner, + }; // Strip coordinates start at the viewport's inset left edge. Inner // gaps belong to column positions; outer margins inset the viewport // and are added back exactly once when placing windows on the output. @@ -625,11 +666,7 @@ impl ScrollingLayout { let mut next_column_x = 0.0; for column in &self.columns { - let requested = match normalized_width(column.width) { - ColumnWidth::Proportion(proportion) => ((viewport_width + inner) * proportion - inner).max(1.0), - ColumnWidth::Fixed(width) => width, - ColumnWidth::Full => viewport_width, - }; + let requested = sizing.resolve(column.width); let minimum = column .windows .iter() @@ -652,8 +689,8 @@ impl ScrollingLayout { .zip(&column_positions) .flat_map(|(column, (_, width))| column.windows.iter().map(move |id| (*id, *width))) .collect(); - let viewport_resized = self.last_viewport_width != Some(viewport_width); - self.last_viewport_width = Some(viewport_width); + let viewport_resized = self.last_column_sizing.map(|sizing| sizing.viewport_width) != Some(viewport_width); + self.last_column_sizing = Some(sizing); match self.pending_viewport.take() { Some(PendingViewportRequest::WidthCycle(_)) => { if let Some(active) = self.active_column { @@ -714,7 +751,13 @@ impl ScrollingLayout { let mut result = LayoutResult::default(); for (column_index, column) in self.columns.iter().enumerate() { let (column_x, column_width) = column_positions[column_index]; - let available_height = (viewport_height - inner * (column.windows.len().saturating_sub(1) as f64)).max(1.0); + let available_height = (viewport_height - inner * (column.windows.len().saturating_sub(1) as f64)).max(0.0); + let row_constraints = column + .windows + .iter() + .map(|window| normalized_constraints(constraints.get(window).copied().unwrap_or_default())) + .collect::>(); + let row_heights = constrained_row_heights(column, available_height, &row_constraints); let mut y = bounds.y + outer; for (window_index, window) in column.windows.iter().enumerate() { @@ -722,16 +765,12 @@ impl ScrollingLayout { bounds.x + outer + column_x - self.viewport_x, y, column_width, - available_height * column.heights[window_index], + row_heights[window_index], ); - let constraint = constraints - .get(window) - .copied() - .map(normalized_constraints) - .unwrap_or_default(); + let constraint = row_constraints[window_index]; record_minimum_warnings(*window, rect, constraint, &mut result.warnings); apply_maximums(*window, &mut rect, constraint, &mut result.warnings); - y += available_height * column.heights[window_index] + inner; + y += row_heights[window_index] + inner; result.geometry.insert(*window, rect); } } @@ -833,9 +872,10 @@ impl ScrollingLayout { } let insertion = if destination < source { source } else { source + 1 }; + let width = self.columns[source].width; self.remove(window)?; let insertion = insertion.min(self.columns.len()); - self.columns.insert(insertion, Column::new(window, self.default_width)); + self.columns.insert(insertion, Column::new(window, width)); self.active_column = Some(insertion); self.request_reveal(self.active_window()); Ok(()) @@ -898,7 +938,13 @@ impl ScrollingLayout { } fn resize_rows(column: &mut Column, row: usize, neighbor: usize, amount: f64) -> bool { - let transferable = (column.heights[neighbor] - MIN_ROW_HEIGHT).max(0.0); + // Keep the usual floor where it fits, capped at half an equal row's share. + // A row already below that floor can still donate half its current weight. + let floor = MIN_ROW_HEIGHT + .min(0.5 / column.windows.len() as f64) + .min(column.heights[neighbor] * 0.5) + .max(f64::from_bits(1)); + let transferable = (column.heights[neighbor] - floor).max(0.0); let adjustment = amount.min(transferable); if adjustment == 0.0 { return false; @@ -909,6 +955,55 @@ fn resize_rows(column: &mut Column, row: usize, neighbor: usize, amount: f64) -> true } +fn constrained_row_heights(column: &Column, available: f64, constraints: &[SizeConstraints]) -> Vec { + let mut heights = vec![0.0; column.windows.len()]; + let minimum_total = constraints.iter().map(|constraint| constraint.min_height).sum::(); + if minimum_total > available { + // Match the tree layout's focus priority under impossible minima. + // Other rows retain their relative weights in the remaining space. + heights[column.active] = constraints[column.active].min_height.min(available); + let remaining = available - heights[column.active]; + let weight = column + .heights + .iter() + .enumerate() + .filter(|(row, _)| *row != column.active) + .map(|(_, weight)| weight) + .sum::(); + for (row, height) in heights.iter_mut().enumerate() { + if row != column.active { + *height = remaining * column.heights[row] / weight; + } + } + return heights; + } + + // Pin rows whose weighted share is below their minimum, then redistribute + // among the remaining rows using their original relative weights. + let mut remaining = available; + let mut pending = (0..column.windows.len()).collect::>(); + while !pending.is_empty() { + let weight = pending.iter().map(|row| column.heights[*row]).sum::(); + let pinned = pending + .iter() + .copied() + .filter(|row| remaining * column.heights[*row] / weight < constraints[*row].min_height) + .collect::>(); + if pinned.is_empty() { + for row in pending { + heights[row] = remaining * column.heights[row] / weight; + } + break; + } + for row in &pinned { + heights[*row] = constraints[*row].min_height; + remaining -= heights[*row]; + } + pending.retain(|row| !pinned.contains(row)); + } + heights +} + fn normalized_width(width: ColumnWidth) -> ColumnWidth { match width { ColumnWidth::Proportion(value) if value.is_finite() && value > 0.0 => ColumnWidth::Proportion(value), @@ -956,6 +1051,270 @@ fn apply_maximums( mod tests { use super::*; + fn stacked_layout(weights: &[f64]) -> ScrollingLayout { + let mut layout = ScrollingLayout::default(); + layout.insert(window(1), None).unwrap(); + for row in 1..weights.len() { + let id = window(row as u64 + 1); + layout.insert(id, Some(window(row as u64))).unwrap(); + layout.move_into_column(id, window(row as u64)).unwrap(); + } + layout.columns[0].heights = weights.to_vec(); + layout + } + + fn row_geometry( + layout: &mut ScrollingLayout, + constraints: &HashMap, + inner: f64, + ) -> LayoutResult { + layout + .resolve_geometry_with_constraints( + Rect::new(0.0, 0.0, 1_000.0, 800.0), + GapConfig { + inner, + outer: 0.0, + smart: false, + }, + constraints, + None, + ) + .unwrap() + } + + #[test] + fn row_removal_preserves_the_remembered_active_window() { + for (active, removed, expected) in [(2, 1, 2), (2, 2, 3), (2, 3, 2), (3, 3, 2)] { + for leave_before_removal in [false, true] { + let mut layout = stacked_layout(&[1.0 / 3.0; 3]); + layout.insert(window(4), Some(window(3))).unwrap(); + layout.focus(window(active)).unwrap(); + if leave_before_removal { + layout.focus(window(4)).unwrap(); + } + layout.remove(window(removed)).unwrap(); + assert_eq!( + layout.columns()[0].windows[layout.columns()[0].active], + window(expected) + ); + if !leave_before_removal { + assert_eq!(layout.active_window(), Some(window(expected))); + layout.focus(window(4)).unwrap(); + } + let restored = layout + .directional_neighbor(window(4), Direction::Left) + .unwrap() + .unwrap(); + assert_eq!(restored, window(expected)); + layout.focus(restored).unwrap(); + assert_eq!(layout.active_window(), Some(window(expected))); + assert!(layout.validate().is_ok()); + } + } + } + + #[test] + fn feasible_row_minima_preserve_unconstrained_proportions_and_weights() { + let mut layout = stacked_layout(&[0.1, 0.3, 0.6]); + let weights = layout.columns()[0].heights.clone(); + let constraints = HashMap::from([( + window(1), + SizeConstraints { + min_height: 500.0, + ..Default::default() + }, + )]); + let result = row_geometry(&mut layout, &constraints, 0.0); + assert_eq!(result.geometry[&window(1)].height, 500.0); + assert!((result.geometry[&window(2)].height - 100.0).abs() < 1e-6); + assert!((result.geometry[&window(3)].height - 200.0).abs() < 1e-6); + assert!( + !result + .warnings + .iter() + .any(|warning| warning.kind == ConstraintKind::MinimumHeight) + ); + assert_eq!(layout.columns()[0].heights, weights); + } + + #[test] + fn simultaneous_row_minima_account_for_inner_gaps() { + let mut layout = stacked_layout(&[0.1, 0.1, 0.8]); + let constraints = HashMap::from([ + ( + window(1), + SizeConstraints { + min_height: 250.0, + ..Default::default() + }, + ), + ( + window(2), + SizeConstraints { + min_height: 300.0, + ..Default::default() + }, + ), + ]); + for (gap, last_height) in [(0.0, 250.0), (20.0, 210.0), (124.5, 1.0)] { + let result = row_geometry(&mut layout, &constraints, gap); + assert_eq!(result.geometry[&window(1)].height, 250.0); + assert_eq!(result.geometry[&window(2)].height, 300.0); + assert!((result.geometry[&window(3)].height - last_height).abs() < 1e-6); + assert_eq!(result.geometry[&window(2)].y, 250.0 + gap); + assert_eq!(result.geometry[&window(3)].y, 550.0 + 2.0 * gap); + assert!( + !result + .warnings + .iter() + .any(|warning| warning.kind == ConstraintKind::MinimumHeight) + ); + } + } + + #[test] + fn changing_row_minima_restores_weight_intent_and_applies_maximums() { + let mut layout = stacked_layout(&[0.25, 0.75]); + let weights = layout.columns()[0].heights.clone(); + for (minimum, maximum, expected) in [(500.0, None, 500.0), (100.0, None, 200.0), (100.0, Some(150.0), 150.0)] { + let constraints = HashMap::from([( + window(1), + SizeConstraints { + min_height: minimum, + max_height: maximum, + ..Default::default() + }, + )]); + let result = row_geometry(&mut layout, &constraints, 0.0); + assert_eq!(result.geometry[&window(1)].height, expected); + assert_eq!(layout.columns()[0].heights, weights); + assert!( + !result + .warnings + .iter() + .any(|warning| warning.kind == ConstraintKind::MinimumHeight) + ); + assert_eq!( + result + .warnings + .iter() + .any(|warning| warning.kind == ConstraintKind::MaximumHeight), + maximum.is_some() + ); + } + let result = row_geometry(&mut layout, &HashMap::new(), 0.0); + assert_eq!(result.geometry[&window(1)].height, 200.0); + assert_eq!(result.geometry[&window(2)].height, 600.0); + assert_eq!(layout.columns()[0].heights, weights); + } + + #[test] + fn impossible_row_minima_prioritize_the_remembered_active_row() { + let mut layout = stacked_layout(&[0.25, 0.25, 0.5]); + let constraints = (1..=3) + .map(|id| { + ( + window(id), + SizeConstraints { + min_height: 500.0, + ..Default::default() + }, + ) + }) + .collect(); + for focused in 1..=3 { + layout.focus(window(focused)).unwrap(); + let result = row_geometry(&mut layout, &constraints, 10.0); + assert_eq!(result.geometry[&window(focused)].height, 500.0); + assert!((result.geometry.values().map(|rect| rect.height).sum::() - 780.0).abs() < 1e-6); + let warnings = result + .warnings + .iter() + .filter(|warning| warning.kind == ConstraintKind::MinimumHeight) + .collect::>(); + assert_eq!(warnings.len(), 2); + assert!(warnings.iter().all(|warning| warning.window != window(focused))); + assert_eq!(row_geometry(&mut layout, &constraints, 10.0), result); + } + let constraints = HashMap::from([( + window(3), + SizeConstraints { + min_height: 900.0, + ..Default::default() + }, + )]); + let result = row_geometry(&mut layout, &constraints, 10.0); + assert_eq!(result.geometry[&window(3)].height, 780.0); + assert_eq!(result.geometry[&window(1)].height, 0.0); + assert_eq!(result.geometry[&window(2)].height, 0.0); + assert_eq!( + result + .warnings + .iter() + .filter(|warning| warning.kind == ConstraintKind::MinimumHeight) + .count(), + 3 + ); + let result = row_geometry(&mut layout, &constraints, 500.0); + assert!(result.geometry.values().all(|rect| rect.height == 0.0)); + } + + #[test] + fn expelling_rows_preserves_column_width_intent_but_new_windows_use_default() { + for width in [ + ColumnWidth::Proportion(0.75), + ColumnWidth::Full, + ColumnWidth::Fixed(700.0), + ] { + for direction in [None, Some(Direction::Left), Some(Direction::Right)] { + let mut layout = stacked_layout(&[0.5, 0.5]); + layout.set_column_width(window(1), width).unwrap(); + layout.insert(window(3), Some(window(2))).unwrap(); + // Provide a left-hand neighbor as well as a right-hand one. + layout.columns.swap(0, 1); + layout.insert(window(4), Some(window(2))).unwrap(); + layout.focus(window(2)).unwrap(); + if let Some(direction) = direction { + assert!(layout.move_window(window(2), direction).unwrap()); + } else { + layout.extract_to_column(window(2)).unwrap(); + } + for id in [1, 2] { + let (column, _) = layout.window_location(window(id)).unwrap(); + assert_eq!(layout.columns()[column].width, width); + } + layout.insert(window(5), Some(window(2))).unwrap(); + let (column, _) = layout.window_location(window(5)).unwrap(); + assert_eq!(layout.columns()[column].width, layout.default_width()); + assert!(layout.validate().is_ok()); + } + } + } + + #[test] + fn vertical_resize_progresses_with_more_than_twenty_rows() { + for equal_weights in [false, true] { + let mut layout = stacked_layout(&[1.0 / 32.0; 32]); + if !equal_weights { + // Stacking a new row repeatedly produces unequal small weights. + layout.columns[0].heights = (0..32).map(|row| 2.0_f64.powi(row - 31)).collect(); + layout.columns[0].normalize_heights(); + } + let before = layout.columns()[0].heights.clone(); + assert!(layout.resize_window(window(1), Direction::Down, 0.05).unwrap()); + assert!(layout.columns()[0].heights[0] > before[0]); + assert!(layout.columns()[0].heights[1] < before[1]); + assert!(layout.resize_window(window(2), Direction::Up, 0.05).unwrap()); + // Repeated transfers must not eventually underflow the donor to zero. + for _ in 0..1_100 { + layout.resize_window(window(1), Direction::Down, 1.0).unwrap(); + } + assert!(layout.columns()[0].heights.iter().all(|weight| *weight > 0.0)); + assert!((layout.columns()[0].heights.iter().sum::() - 1.0).abs() < 1e-12); + assert!(layout.validate().is_ok()); + } + } + fn window(id: u64) -> WindowId { WindowId(id) } @@ -1801,6 +2160,183 @@ mod tests { assert_eq!(layout.columns()[0].heights, vec![0.6, 0.4]); } + #[test] + fn proportional_resize_keeps_the_existing_proportion() { + let mut layout = ScrollingLayout::default(); + layout.insert(window(1), None).unwrap(); + assert!(layout.resize_window(window(1), Direction::Right, 0.05).unwrap()); + assert_eq!(layout.columns()[0].width, ColumnWidth::Proportion(0.55)); + } + + #[test] + fn full_width_resize_left_starts_at_one() { + let mut layout = ScrollingLayout::with_default_width(ColumnWidth::Full); + layout.insert(window(1), None).unwrap(); + assert!(layout.resize_window(window(1), Direction::Left, 0.05).unwrap()); + assert_eq!(layout.columns()[0].width, ColumnWidth::Proportion(0.95)); + } + + #[test] + fn full_width_resize_right_grows_instead_of_shrinking() { + let mut layout = ScrollingLayout::with_default_width(ColumnWidth::Full); + layout.insert(window(1), None).unwrap(); + let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0); + let before = layout + .resolve_geometry_with_constraints(bounds, GapConfig::default(), &HashMap::new(), None) + .unwrap(); + assert!(layout.resize_window(window(1), Direction::Right, 0.05).unwrap()); + let after = layout + .resolve_geometry_with_constraints(bounds, GapConfig::default(), &HashMap::new(), None) + .unwrap(); + assert_eq!(layout.columns()[0].width, ColumnWidth::Proportion(1.05)); + assert!(after.geometry[&window(1)].width > before.geometry[&window(1)].width); + } + + #[test] + fn fixed_resize_uses_the_current_viewport_and_gap() { + let mut layout = ScrollingLayout::with_default_width(ColumnWidth::Fixed(400.0)); + layout.insert(window(1), None).unwrap(); + for (width, inner, outer, smart) in [ + (1_000.0, 10.0, 10.0, false), + (800.0, 24.0, 32.0, false), + (800.0, 48.0, 32.0, false), + (800.0, 12.0, 32.0, true), + ] { + layout.set_column_width(window(1), ColumnWidth::Fixed(400.0)).unwrap(); + let bounds = Rect::new(0.0, 0.0, width, 800.0); + let gaps = GapConfig { inner, outer, smart }; + let before = layout + .resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), None) + .unwrap(); + assert_eq!(before.geometry[&window(1)].width, 400.0); + let viewport = width - if smart { 0.0 } else { 2.0 * outer }; + let expected = (400.0 + inner) / (viewport + inner) + 0.05; + assert!(layout.resize_window(window(1), Direction::Right, 0.05).unwrap()); + assert_eq!(layout.columns()[0].width, ColumnWidth::Proportion(expected)); + let after = layout + .resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), None) + .unwrap(); + assert!((after.geometry[&window(1)].width - (400.0 + (viewport + inner) * 0.05)).abs() < 1e-6); + } + } + + #[test] + fn fixed_resize_starts_from_the_minimum_constrained_column_width() { + let mut layout = ScrollingLayout::with_default_width(ColumnWidth::Fixed(300.0)); + layout.insert(window(1), None).unwrap(); + let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0); + let gaps = GapConfig { + inner: 10.0, + outer: 10.0, + smart: false, + }; + let constraints = HashMap::from([( + window(1), + SizeConstraints { + min_width: 600.0, + ..Default::default() + }, + )]); + let before = layout + .resolve_geometry_with_constraints(bounds, gaps, &constraints, None) + .unwrap(); + assert_eq!(before.geometry[&window(1)].width, 600.0); + layout.resize_window(window(1), Direction::Right, 0.05).unwrap(); + let after = layout + .resolve_geometry_with_constraints(bounds, gaps, &constraints, None) + .unwrap(); + assert!((after.geometry[&window(1)].width - 649.5).abs() < 1e-6); + layout.resize_window(window(1), Direction::Left, 0.1).unwrap(); + let shrunk = layout + .resolve_geometry_with_constraints(bounds, gaps, &constraints, None) + .unwrap(); + assert_eq!(shrunk.geometry[&window(1)].width, 600.0); + } + + #[test] + fn repeated_fixed_resize_steps_continue_from_the_converted_proportion() { + let mut layout = ScrollingLayout::with_default_width(ColumnWidth::Fixed(400.0)); + layout.insert(window(1), None).unwrap(); + let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0); + let gaps = GapConfig { + inner: 10.0, + outer: 10.0, + smart: false, + }; + layout + .resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), None) + .unwrap(); + for step in 1..=4 { + layout.resize_window(window(1), Direction::Right, 0.05).unwrap(); + let result = layout + .resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), None) + .unwrap(); + assert!((result.geometry[&window(1)].width - (400.0 + 49.5 * f64::from(step))).abs() < 1e-6); + } + layout.resize_window(window(1), Direction::Left, 0.05).unwrap(); + let result = layout + .resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), None) + .unwrap(); + assert!((result.geometry[&window(1)].width - 548.5).abs() < 1e-6); + } + + #[test] + fn fixed_resize_without_resolved_geometry_leaves_width_unchanged() { + let mut layout = ScrollingLayout::with_default_width(ColumnWidth::Fixed(400.0)); + layout.insert(window(1), None).unwrap(); + assert!(!layout.resize_window(window(1), Direction::Right, 0.05).unwrap()); + assert_eq!(layout.columns()[0].width, ColumnWidth::Fixed(400.0)); + } + + #[test] + fn width_cycle_after_fixed_resize_preserves_precise_viewport_dependencies() { + let mut layout = ScrollingLayout::with_default_width(ColumnWidth::Fixed(400.0)); + for id in 1..=3 { + layout.insert(window(id), (id > 1).then(|| window(id - 1))).unwrap(); + } + let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0); + let gaps = GapConfig { + inner: 10.0, + outer: 10.0, + smart: false, + }; + layout + .resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(2))) + .unwrap(); + let target = layout.viewport_target().cloned(); + let viewport = layout.viewport_x(); + layout.resize_window(window(1), Direction::Right, 0.05).unwrap(); + layout + .resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(2))) + .unwrap(); + assert_eq!(layout.viewport_x(), viewport); + assert_eq!(layout.viewport_target(), target.as_ref()); + + layout + .cycle_column_width(window(2), &[ColumnWidth::Fixed(400.0), ColumnWidth::Full]) + .unwrap(); + layout + .resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(2))) + .unwrap(); + assert_eq!(layout.columns()[1].width, ColumnWidth::Full); + assert!((layout.viewport_x() - 459.5).abs() < 1e-6); + let first = layout.allocated_column_width(window(1)).unwrap(); + let second = layout.allocated_column_width(window(2)).unwrap(); + assert!(layout.viewport_depends_on_width(window(1), 400.0, first)); + assert!(layout.viewport_depends_on_width(window(2), 400.0, second)); + assert!(!layout.viewport_depends_on_width(window(3), 300.0, 400.0)); + let held = layout + .viewport_target() + .unwrap() + .with_held_widths(&[(window(1), 400.0, first)], None); + assert!((held - 410.0).abs() < 1e-6); + let held = layout + .viewport_target() + .unwrap() + .with_held_widths(&[(window(2), 400.0, second)], None); + assert_eq!(held, 0.0); + } + #[test] fn width_cycle_wraps_through_configured_presets() { let mut layout = ScrollingLayout::default();