Fix scrolling column sizing and row presentation invariants

Resize full and fixed columns from their resolved width. Preserve active rows during removal and source widths during extraction, satisfy feasible row minima without changing weights, and keep vertical resize usable for large stacks.

Propagate viewport-coupled width cycles across every row from a common presentation motion. Cover client holds, forecasts, reversal and reduced motion with regression tests.
This commit is contained in:
Samuel Onoja committed 2026-10-07 10:00:14 +01:00
1 parent 966c8988eb
commit 5804562c84
3 files changed
+725 -24

No files matched your search

+45 -1
View File
@@ -148,7 +148,48 @@ impl Ferese {
})
.collect::<HashMap<_, _>>();
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::<Vec<_>>();
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)));
@@ -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::<Vec<_>>() {
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(