Decouple scrolling translation from resize readiness

Track configure-owned presentation dependencies so independent viewport translation can continue while clients resize. Preserve linked reflow waits, native snapshots, handoff deadlines and shared rendering/input coordinates.

Closes #10.
This commit is contained in:
Samuel Onoja authored and GitHub committed 2026-10-04 08:05:44 +01:00
1 parent c1801e1f07
commit d5a829eb16
14 files changed
+1742 -72

No files matched your search

+402 -1
View File
@@ -59,7 +59,7 @@ fn dispatch(events: &mut EventLoop<'static, Ferese>, state: &mut Ferese) {
state.display_handle.flush_clients().unwrap();
}
fn ack_configure(wire: &mut UnixStream, xdg: u32) {
fn ack_configure(wire: &mut UnixStream, xdg: u32) -> u32 {
wire.set_nonblocking(true).unwrap();
let mut bytes = Vec::new();
let _ = wire.read_to_end(&mut bytes);
@@ -76,6 +76,7 @@ fn ack_configure(wire: &mut UnixStream, xdg: u32) {
}
assert!(serial.is_some(), "missing configure for {xdg}: {bytes:?}");
request(wire, xdg, 4, &[serial.unwrap()], None);
serial.unwrap()
}
fn window(state: &mut Ferese, events: &mut EventLoop<'static, Ferese>, color: u32) -> (Window, UnixStream) {
@@ -593,3 +594,403 @@ fn capture_privacy_pixels_and_policy_transitions() {
&mut capture_texture
)));
}
#[test]
#[ignore = "requires private Wayland sockets and an offscreen EGL device"]
fn resize_dependencies_protocol_and_pixels() {
if std::env::var_os("FERESE_RESIZE_TEST_CHILD").is_none() {
let runtime = tempfile::tempdir().unwrap();
let result = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"state::capture_privacy::tests::resize_dependencies_protocol_and_pixels",
"--ignored",
"--nocapture",
])
.env("FERESE_RESIZE_TEST_CHILD", "1")
.env("FERESE_ENABLE_SCREENCOPY", "1")
.env("XDG_RUNTIME_DIR", runtime.path())
.env("XDG_CONFIG_HOME", runtime.path())
.env("XDG_STATE_HOME", runtime.path())
.env_remove("FERESE_SOCKET")
.output()
.unwrap();
assert!(
result.status.success(),
"{}\n{}",
String::from_utf8_lossy(&result.stdout),
String::from_utf8_lossy(&result.stderr)
);
return;
}
use crate::gestures::SwipeDirection;
use ferese_core::WorkspaceLayout;
use ferese_layout::{ColumnWidth, Direction, Rect, ViewportFocusStrategy};
let mut events = EventLoop::try_new().unwrap();
let config = crate::config::Config::parse_source("animations { enabled #false; }\nlayout { inner-gap 0; outer-gap 0; }\ntheme { geometry { window-radius 0; border-width 0; focus-ring-width 0; }; }\nwindow-rule app-id=\"resize.secret\" block-out-from-screencasts=#true\n").unwrap().runtime_config().unwrap();
let mut state = Ferese::new(&mut events, Display::new().unwrap(), config).unwrap();
state.animation_test_time = Some(Duration::ZERO);
let output = Output::new(
"resize".into(),
PhysicalProperties {
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
output.change_current_state(
Some(Mode {
size: (320, 240).into(),
refresh: 60_000,
}),
Some(Transform::Normal),
None,
None,
);
state.space.map_output(&output, (0, 0));
state.register_output(&output, "resize".into());
let (a, mut a_wire) = window(&mut state, &mut events, 0xffff0000);
let (b, mut b_wire) = window(&mut state, &mut events, 0xff00ff00);
let (c, mut c_wire) = window(&mut state, &mut events, 0xff0000ff);
let a_id = state.windows.ids()[&a];
let b_id = state.windows.ids()[&b];
let c_id = state.windows.ids()[&c];
let workspace = state.workspaces.workspace_for_window(a_id).unwrap();
state.focused_window = Some(a_id);
state.workspaces.focus_window(a_id).unwrap();
state.relayout();
state.animations_enabled = true;
for (_, record) in state.windows.records_mut() {
record.opening = None;
}
let set_width = |state: &mut Ferese, id, width| {
let ferese_core::WorkspaceLayout::Scrolling(layout) =
&mut state.workspaces.workspace_mut(workspace).unwrap().layout
else {
panic!()
};
layout.set_column_width(id, ColumnWidth::Fixed(width)).unwrap();
state.relayout();
state.display_handle.flush_clients().unwrap();
};
set_width(&mut state, b_id, 240.0);
let b_serial = ack_configure(&mut b_wire, 3);
dispatch(&mut events, &mut state);
assert!(state.windows.transaction(&b_id).is_some(), "ack alone must not release");
// This target uses the first column, independently of the pending later width.
let bounds = Rect::new(0.0, 0.0, 320.0, 240.0);
state
.workspaces
.center_window(a_id, bounds, state.gap_config, &state.window_constraints())
.unwrap();
state.relayout();
let before = state.viewport_animations[&workspace].current;
let held_size = state.windows.geometry(&b_id).unwrap().visual.current.width;
let held_world = state.windows.record(b_id).unwrap().world_x.unwrap().1;
state.advance_animations_at(Duration::from_millis(16), Duration::ZERO);
assert_ne!(
state.viewport_animations[&workspace].current, before,
"independent viewport stalled before delayed buffer commit"
);
assert_eq!(state.windows.geometry(&b_id).unwrap().visual.current.width, held_size);
assert_eq!(state.windows.record(b_id).unwrap().world_x.unwrap().1, held_world);
// BASELINE_REPRO_END: the stage above also runs against the reviewed base.
assert!(
!state
.presentation_dependencies
.viewport_blocked(workspace, &state.windows)
);
assert!(state.presentation_dependencies.reflow_blocked(b_id, &state.windows));
assert!(state.presentation_dependencies.reflow_blocked(c_id, &state.windows));
assert!(!state.presentation_dependencies.reflow_blocked(a_id, &state.windows));
let geometry = *state.windows.geometry(&b_id).unwrap();
let position = state.space.element_location(&b).unwrap();
assert_eq!(position.x, geometry.visual.current.x.round() as i32);
let point = (geometry.visual.current.x + 5.0, geometry.visual.current.y + 5.0).into();
let (hit, origin) = state.window_surface_under(point).unwrap();
assert_eq!(hit, *b.toplevel().unwrap().wl_surface());
assert!((origin.x - geometry.visual.current.x).abs() < 0.001);
let forecast = state.sample_frame(&output, Duration::from_millis(8));
let predicted = &forecast.windows[&b_id];
let mut viewport = state.viewport_animations[&workspace];
viewport.advance(
Duration::from_millis(8).mul_f64(state.animation_speed),
state.viewport_spring_config,
);
assert_eq!(
predicted.geometry.visual.current.x,
held_world.current - viewport.current
);
assert_eq!(predicted.geometry.visual.current.width, held_size);
assert_eq!(
predicted.presentation.bounds.current.x,
predicted.geometry.visual.current.x
);
assert_eq!(predicted.presentation.bounds.velocity.x, -viewport.velocity);
assert_eq!(
*state.windows.geometry(&b_id).unwrap(),
geometry,
"sampling mutated authoritative state"
);
let device = EGLDevice::enumerate().unwrap().last().expect("EGL device");
let display = unsafe { EGLDisplay::new(device).unwrap() };
let context = EGLContext::new(&display).unwrap();
let mut renderer = unsafe { GlesRenderer::new(context).unwrap() };
let mut display_texture =
Offscreen::<GlesTexture>::create_buffer(&mut renderer, Fourcc::Abgr8888, (320, 240).into()).unwrap();
let mut capture_texture =
Offscreen::<GlesTexture>::create_buffer(&mut renderer, Fourcc::Abgr8888, (320, 240).into()).unwrap();
// Offscreen fixture explicitly captures the existing native frame; production
// capture-before-commit is exercised separately in the privacy regression.
let snapshot =
crate::render::capture_resize_snapshot(&mut renderer, &b, b.geometry(), 1.0, 16 * 1024 * 1024).unwrap();
state.render.set_snapshot(b_id, snapshot.expect("native snapshot"));
app_id(&mut state, &b, "resize.secret");
state.refresh_capture_privacy();
state.advance_animations_at(Duration::from_millis(16), Duration::from_millis(16));
let actual = state.windows.geometry(&b_id).unwrap().visual.current;
let displayed = pixels(&mut state, &mut renderer, &output, false, &mut display_texture);
let at = ((actual.y.round() as usize + 5) * 320 + actual.x.round() as usize + 5) * 4;
assert_eq!(
&displayed[at..at + 4],
&[0, 255, 0, 255],
"live/snapshot pixels did not translate with geometry"
);
let captured = pixels(&mut state, &mut renderer, &output, true, &mut capture_texture);
assert!(
!captured.chunks_exact(4).any(|pixel| pixel == [0, 255, 0, 255]),
"translated protected snapshot leaked into capture"
);
assert!(state.render.snapshot(&b_id).is_some());
// A second slow client has an independent serial owner.
set_width(&mut state, c_id, 128.0);
ack_configure(&mut c_wire, 3);
dispatch(&mut events, &mut state);
assert!(state.windows.transaction(&c_id).is_some());
state.focused_window = Some(c_id);
state.workspaces.focus_window(c_id).unwrap();
// Centering a later column now genuinely uses both pending allocated widths.
state
.workspaces
.center_window(c_id, bounds, state.gap_config, &state.window_constraints())
.unwrap();
state.relayout();
assert!(
state
.presentation_dependencies
.viewport_blocked(workspace, &state.windows)
);
let frozen = state.viewport_animations[&workspace];
state.advance_animations_at(Duration::from_millis(16), Duration::from_millis(32));
assert_eq!(state.viewport_animations[&workspace], frozen);
// Supersede the acknowledged size, then commit the older acknowledgement.
set_width(&mut state, b_id, 96.0);
let latest = *state.windows.transaction(&b_id).unwrap();
assert!(!latest.accepts(Some(b_serial.into())));
request(&mut b_wire, 2, 1, &[6, 0, 0], None);
request(&mut b_wire, 2, 6, &[], None);
dispatch(&mut events, &mut state);
assert_eq!(
state.windows.transaction(&b_id).unwrap().serial(),
latest.serial(),
"obsolete commit released replacement"
);
ack_configure(&mut b_wire, 3);
dispatch(&mut events, &mut state);
assert!(
state.windows.transaction(&b_id).is_some(),
"latest ack without commit released resize"
);
state.focused_window = Some(a_id);
state.workspaces.focus_window(a_id).unwrap();
// Rapid reversal to the independent first-column target retires viewport waits.
state
.workspaces
.center_window(a_id, bounds, state.gap_config, &state.window_constraints())
.unwrap();
state.relayout();
assert!(
!state
.presentation_dependencies
.viewport_blocked(workspace, &state.windows)
);
let frozen = state.viewport_animations[&workspace].current;
state.advance_animations_at(Duration::from_millis(16), Duration::from_millis(48));
assert_ne!(state.viewport_animations[&workspace].current, frozen);
let client = b.toplevel().unwrap().wl_surface().client().unwrap();
let shm = client
.create_resource::<WlShm, (), Ferese>(&state.display_handle, 1, ())
.unwrap();
let mut resized = tempfile::tempfile().unwrap();
resized.write_all(&0xff00ff00u32.to_ne_bytes().repeat(32 * 48)).unwrap();
request(
&mut b_wire,
shm.id().protocol_id(),
0,
&[7, 32 * 48 * 4],
Some(resized.as_raw_fd()),
);
request(&mut b_wire, 7, 0, &[8, 0, 32, 48, 32 * 4, 0], None);
request(&mut b_wire, 3, 3, &[0, 0, 32, 48], None);
request(&mut b_wire, 2, 1, &[8, 0, 0], None);
request(&mut b_wire, 2, 6, &[], None);
dispatch(&mut events, &mut state);
assert_eq!(b.geometry().size.w, 32, "resized raster was not committed");
assert!(
state.windows.transaction(&b_id).is_none(),
"latest surface commit did not release"
);
assert!(
state.windows.transaction(&c_id).is_some(),
"one client released another owner"
);
// A hidden workspace must not impose its viewport wait on its replacement.
let other = state.workspaces.create_workspace();
let output_id = state.output_id(&output).unwrap();
state.output_workspaces.assign_workspace(output_id, other).unwrap();
state.workspaces.activate(other).unwrap();
state.relayout();
assert!(!state.presentation_dependencies.viewport_blocked(other, &state.windows));
state.output_workspaces.assign_workspace(output_id, workspace).unwrap();
state.workspaces.activate(workspace).unwrap();
state.focused_window = Some(a_id);
state.relayout();
request(&mut c_wire, 2, 1, &[0, 0, 0], None);
request(&mut c_wire, 2, 6, &[], None);
dispatch(&mut events, &mut state);
assert!(
state.windows.transaction(&c_id).is_none(),
"unmap kept a configure owner"
);
// Deliberately unresponsive final configure expires without charging the wait.
set_width(&mut state, b_id, 112.0);
ack_configure(&mut b_wire, 3);
dispatch(&mut events, &mut state);
assert!(state.windows.transaction(&b_id).is_some());
state.advance_animations_at(Duration::from_millis(300), Duration::from_secs(1));
assert!(state.windows.transaction(&b_id).is_none());
for tick in 1..=160 {
state.advance_animations_at(
Duration::from_millis(16),
Duration::from_secs(1) + Duration::from_millis(tick * 16),
);
}
assert!(
state.render.snapshot(&b_id).is_none(),
"resize snapshot outlived settled handoff"
);
// Aborting a preview must restore the original recipe, not append a
// reversal to the candidate recipe (oversized columns expose the difference).
state.animations_enabled = false;
set_width(&mut state, a_id, 400.0);
set_width(&mut state, b_id, 100.0);
state.focused_window = Some(a_id);
state.workspaces.focus_window(a_id).unwrap();
state
.workspaces
.center_window(a_id, bounds, state.gap_config, &state.window_constraints())
.unwrap();
state.relayout();
state.animations_enabled = true;
set_width(&mut state, b_id, 150.0);
ack_configure(&mut b_wire, 3);
dispatch(&mut events, &mut state);
assert!(
!state
.presentation_dependencies
.viewport_blocked(workspace, &state.windows)
);
state.preview_focus_swipe(Direction::Right, SwipeDirection::Left, 0.5);
assert!(state.focus_swipe.is_some());
assert!(
state
.presentation_dependencies
.viewport_blocked(workspace, &state.windows)
);
assert!(state.finish_focus_swipe(None));
assert!(
!state
.presentation_dependencies
.viewport_blocked(workspace, &state.windows),
"cancelled preview contaminated restored target"
);
// Two shrinking prefixes can jointly cross a page boundary although
// neither source width does so alone. A normal partial commit must rebuild
// the remaining graph immediately, without an explicit relayout.
state.animations_enabled = false;
state.advance_animations_at(Duration::ZERO, Duration::from_secs(5));
state.focused_window = Some(b_id);
state.workspaces.focus_window(b_id).unwrap();
let (d, mut d_wire) = window(&mut state, &mut events, 0xffffffff);
let d_id = state.windows.ids()[&d];
set_width(&mut state, a_id, 160.0);
set_width(&mut state, b_id, 160.0);
set_width(&mut state, d_id, 64.0);
state.focused_window = Some(d_id);
{
let WorkspaceLayout::Scrolling(layout) = &mut state.workspaces.workspace_mut(workspace).unwrap().layout else {
panic!()
};
layout.set_focus_strategy(ViewportFocusStrategy::Paged);
layout.focus(a_id).unwrap();
layout.focus(d_id).unwrap();
}
state.relayout();
state.animations_enabled = true;
set_width(&mut state, a_id, 64.0);
set_width(&mut state, b_id, 64.0);
ack_configure(&mut a_wire, 3);
ack_configure(&mut b_wire, 3);
dispatch(&mut events, &mut state);
assert!(
state
.presentation_dependencies
.viewport_blocked(workspace, &state.windows),
"joint page dependency was lost: layout={:?} a={:?} b={:?}",
state.workspaces.workspace(workspace).unwrap().layout,
state.windows.transaction(&a_id),
state.windows.transaction(&b_id)
);
request(&mut a_wire, 2, 1, &[6, 0, 0], None);
request(&mut a_wire, 2, 6, &[], None);
dispatch(&mut events, &mut state);
assert!(state.windows.transaction(&a_id).is_none());
assert!(state.windows.transaction(&b_id).is_some());
assert!(
!state
.presentation_dependencies
.viewport_blocked(workspace, &state.windows),
"partial commit failed to retire collective page wait"
);
// A sibling that commits early still shares the held source width when
// a stacked column is retargeted. Input ordering must not choose its source.
state.animations_enabled = false;
state.advance_animations_at(Duration::ZERO, Duration::from_secs(10));
{
let WorkspaceLayout::Scrolling(layout) = &mut state.workspaces.workspace_mut(workspace).unwrap().layout else {
panic!()
};
layout.move_into_column(d_id, b_id).unwrap();
}
set_width(&mut state, b_id, 100.0);
state.animations_enabled = true;
set_width(&mut state, b_id, 200.0);
ack_configure(&mut b_wire, 3);
ack_configure(&mut d_wire, 3);
dispatch(&mut events, &mut state);
request(&mut d_wire, 2, 1, &[6, 0, 0], None);
request(&mut d_wire, 2, 6, &[], None);
dispatch(&mut events, &mut state);
assert!(state.windows.transaction(&d_id).is_none());
set_width(&mut state, b_id, 150.0);
for id in [b_id, d_id] {
assert_eq!(
state.windows.transaction(&id).unwrap().column_width(),
Some((100.0, 150.0)),
"stacked replacement lost canonical presented source"
);
}
}