diff --git a/compositor/src/resize_transaction.rs b/compositor/src/resize_transaction.rs index b448bae..3caba29 100644 --- a/compositor/src/resize_transaction.rs +++ b/compositor/src/resize_transaction.rs @@ -1,6 +1,5 @@ //! A presentation barrier, not an event-loop or client-commit blocker. -//! Grouping barriers by workspace keeps viewport and neighboring tiles on the -//! same clock. A serial identifies readiness even for cell-grid clients whose +//! Presentation properties bind to the configure they actually depend on. A serial identifies readiness even for cell-grid clients whose //! committed size is smaller than the configure, and after rapid reversals. use std::time::Duration; @@ -13,6 +12,7 @@ pub(crate) struct ResizeTransaction { serial: Serial, started: Duration, source_geometry: Option>, + column_width: Option<(f64, f64)>, } impl ResizeTransaction { @@ -21,9 +21,28 @@ impl ResizeTransaction { serial, started, source_geometry: None, + column_width: None, } } + pub(crate) fn serial(self) -> Serial { + self.serial + } + + pub(crate) fn with_column_width(mut self, source: Option, target: Option) -> Self { + self.column_width = source.zip(target); + self + } + + pub(crate) fn column_width(self) -> Option<(f64, f64)> { + self.column_width + } + + pub(crate) fn width_changes(self) -> bool { + self.column_width + .is_some_and(|(source, target)| (source - target).abs() > 0.001) + } + pub(crate) fn with_source_geometry(mut self, geometry: Rectangle) -> Self { self.source_geometry = Some(geometry); self diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 001f6ab..e2407f1 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -9,6 +9,7 @@ mod outputs; mod reconciliation; pub(crate) use reconciliation::{DesktopChanges, DesktopOutput, DesktopTransition}; mod prediction; +mod presentation_dependencies; pub(crate) use prediction::FrameScene; mod lifecycle; mod window_registry; @@ -154,6 +155,8 @@ struct FocusSwipe { start: f64, destination: f64, progress: f64, + layout: Option, + dependencies: Option, } impl FocusSwipe { @@ -433,7 +436,7 @@ pub struct Ferese { pub(crate) logout_query: Option, pub(crate) logout_owner: Option, viewport_animations: HashMap, - paused_workspaces: HashSet, + presentation_dependencies: presentation_dependencies::PresentationDependencies, #[cfg(feature = "resize-metrics")] pub(crate) resize_metrics: crate::resize_metrics::ResizeMetrics, focus_swipe: Option, @@ -488,6 +491,8 @@ pub struct Ferese { pub(crate) overview: OverviewState, next_output_id: u64, last_animation_tick: Instant, + #[cfg(test)] + animation_test_time: Option, pub popups: PopupManager, pub(crate) dismissing_popups: Vec<(WlSurface, PopupKind, DimAnimation)>, pub seat: Seat, @@ -676,7 +681,7 @@ impl Ferese { logout_query: None, logout_owner: None, viewport_animations: HashMap::new(), - paused_workspaces: HashSet::new(), + presentation_dependencies: Default::default(), #[cfg(feature = "resize-metrics")] resize_metrics: crate::resize_metrics::ResizeMetrics::default(), focus_swipe: None, @@ -726,6 +731,8 @@ impl Ferese { overview: OverviewState::with_font_family(config.overview_font_family), next_output_id: 1, last_animation_tick: start_time, + #[cfg(test)] + animation_test_time: None, popups: PopupManager::default(), dismissing_popups: Vec::new(), seat, @@ -1213,6 +1220,8 @@ mod tests { start: 20.0, destination: 20.0, progress: 2.0, + layout: None, + dependencies: None, }; assert!(swipe.position() > 20.0 && swipe.position() < 84.0); assert!(swipe.release_velocity(0.0, 3.0) > 0.0); @@ -1363,6 +1372,8 @@ mod tests { start: 500.0, destination, progress: 0.75, + layout: None, + dependencies: None, }; let far = swipe.position(); swipe.progress = 0.25; @@ -1391,6 +1402,8 @@ mod tests { start: 0.0, destination: 0.0, progress: 0.5, + layout: None, + dependencies: None, }; assert_eq!(swipe.position(), sign * 32.0); assert_eq!(swipe.destination, 0.0); diff --git a/compositor/src/state/animation.rs b/compositor/src/state/animation.rs index 3212ef6..ce20d7a 100644 --- a/compositor/src/state/animation.rs +++ b/compositor/src/state/animation.rs @@ -1,7 +1,19 @@ use super::*; impl Ferese { + pub(super) fn presentation_now(&self) -> Duration { + #[cfg(test)] + if let Some(now) = self.animation_test_time { + return now; + } + self.start_time.elapsed() + } + pub fn advance_animations(&mut self, now: Instant) -> bool { + #[cfg(test)] + if let Some(time) = self.animation_test_time { + return self.advance_animations_at(Duration::ZERO, time); + } let delta = crate::presentation::frame_delta(&mut self.last_animation_tick, now); // Consume wall time even while presentation is paused. A paused // desktop must not turn suspend/DPMS time into a spring step. @@ -35,7 +47,10 @@ impl Ferese { continue; }; let moving = |value: &AnimatedValue| value.current != value.target || value.velocity != 0.0; - let viewport = self.viewport_animations.get(&workspace).is_some_and(moving) + let viewport = self + .presentation_dependencies + .viewport_blocked(workspace, &self.windows) + && self.viewport_animations.get(&workspace).is_some_and(moving) && self .output_workspaces .output_for_workspace(workspace) @@ -51,6 +66,7 @@ impl Ferese { .chain(workspace.floating.iter().copied()) .any(|other| { other != *id + && self.presentation_dependencies.reflow_blocked(other, &self.windows) && self .windows .window(other) @@ -80,7 +96,7 @@ impl Ferese { fn animations_need_tick(&self) -> bool { if self.focus_swipe.is_some() - || !self.paused_workspaces.is_empty() + || self.presentation_dependencies.needs_tick() || !self.workspace_slides.is_empty() || !self.render.closing.is_empty() || !self.dismissing_popups.is_empty() @@ -125,11 +141,15 @@ impl Ferese { } pub(super) fn advance_animations_by(&mut self, delta: std::time::Duration) -> bool { - self.advance_animations_at(delta, self.start_time.elapsed()) + self.advance_animations_at(delta, self.presentation_now()) } // The production caller supplies monotonic wall time; tests supply a fake clock. pub(super) fn advance_animations_at(&mut self, delta: Duration, now: Duration) -> bool { + #[cfg(test)] + if self.animation_test_time.is_some() { + self.animation_test_time = Some(now); + } if !self.animations_need_tick() { self.sync_window_stacking(); return false; @@ -141,7 +161,11 @@ impl Ferese { .as_ref() .is_some_and(|swipe| !self.focus_swipe_is_current(swipe)) { - self.focus_swipe = None; + if let Some(swipe) = self.focus_swipe.take() { + self.presentation_dependencies + .restore_viewport(swipe.workspace, swipe.dependencies); + self.release_presentation_dependencies(); + } } let mut active_animation = false; @@ -274,13 +298,8 @@ impl Ferese { self.render.remove_window(id); } - // Include transactions expiring on this dispatch: their waiting time - // must not be charged to the first resumed animation step. - let paused_before_expiry = self - .windows - .resizing() - .filter_map(|id| self.workspaces.workspace_for_window(*id)) - .collect::>(); + // Capture holds before expiry so waiting wall time never steps reflow. + let (mut held_reflow, mut held_viewport) = self.presentation_dependencies.holds(&self.windows); #[cfg(feature = "resize-metrics")] self.measure_resize_pauses(now); #[cfg(feature = "resize-metrics")] @@ -291,15 +310,18 @@ impl Ferese { } } } - self.windows.expire_transactions(now); - - let blocked_workspaces = self + let expired = self .windows .resizing() - .filter_map(|id| self.workspaces.workspace_for_window(*id)) - .collect::>(); - let mut resume_pauses = std::mem::replace(&mut self.paused_workspaces, blocked_workspaces.clone()); - resume_pauses.extend(paused_before_expiry); + .any(|id| self.windows.transaction(id).unwrap().expired(now)); + self.windows.expire_transactions(now); + if expired { + self.release_presentation_dependencies(); + } + + let (released_reflow, released_viewport) = self.presentation_dependencies.holds(&self.windows); + held_reflow.extend(released_reflow); + held_viewport.extend(released_viewport); let animations_enabled = self.animations_enabled; let animation_speed = self.animation_speed; #[cfg(feature = "resize-metrics")] @@ -309,10 +331,7 @@ impl Ferese { return false; } - let waiting_for_client = self - .workspaces - .workspace_for_window(*id) - .is_some_and(|workspace| blocked_workspaces.contains(&workspace)); + let waiting_for_client = self.presentation_dependencies.reflow_blocked(*id, &self.windows); // A shrinking client's destination buffer arrives before the // animated bounds reach it. Keep the old native pixels covering // that strip rather than fading them into the neutral resize fill. @@ -349,7 +368,8 @@ impl Ferese { }); active_animation |= self.render.snapshots().next().is_some(); // Keep scheduling frames while waiting, so the deadline cannot stall. - active_animation |= !blocked_workspaces.is_empty() || !resume_pauses.is_empty(); + active_animation |= + self.windows.resizing().next().is_some() || !held_reflow.is_empty() || !held_viewport.is_empty(); for (workspace, viewport) in &mut self.viewport_animations { if self .output_workspaces @@ -359,7 +379,7 @@ impl Ferese { continue; } - if blocked_workspaces.contains(workspace) || resume_pauses.contains(workspace) { + if held_viewport.contains(workspace) { continue; } @@ -380,14 +400,7 @@ impl Ferese { continue; }; - if self - .workspaces - .workspace_for_window(id) - .is_some_and(|workspace| blocked_workspaces.contains(&workspace) || resume_pauses.contains(&workspace)) - { - continue; - } - + let held = held_reflow.contains(&id); let Some(record) = self.windows.record_mut(id) else { continue; }; @@ -397,6 +410,8 @@ impl Ferese { continue; }; + let previous = geometry.visual.current; + geometry.presentation_changed = false; let zooming = geometry.is_zooming(); let coupled_target = record.coupled_width.as_ref().map(|(_, width)| width.target); @@ -404,7 +419,9 @@ impl Ferese { 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 !held { + active_animation |= geometry.advance(delta, self.spring_config, self.animations_enabled); + } if zooming != geometry.is_zooming() { self.stacking_cache.invalidate(); @@ -419,18 +436,28 @@ impl Ferese { { if zooming { sync_scrolling_coordinates(geometry, world_x, viewport, true); - } else if self.animations_enabled { + } else if !held && self.animations_enabled { active_animation |= world_x.advance(delta, self.spring_config); - } else { + } else if !held { world_x.snap(); } if !zooming { - sync_scrolling_coordinates(geometry, world_x, viewport, false); + let mut presented_world = *world_x; + if held { + presented_world.velocity = 0.0; + } + let mut presented_viewport = *viewport; + if held_viewport.contains(workspace) { + presented_viewport.velocity = 0.0; + } + sync_scrolling_coordinates(geometry, &mut presented_world, &presented_viewport, false); } } - if let Some((_, width)) = record.coupled_width.as_mut() { + if let Some((_, width)) = record.coupled_width.as_mut() + && !held + { let width_active = if self.animations_enabled { width.advance(delta, self.viewport_spring_config) } else { @@ -455,7 +482,9 @@ impl Ferese { ); } - if let Some(size) = geometry.presentation_size_request(now) + geometry.presentation_changed |= previous != geometry.visual.current; + if !held + && let Some(size) = geometry.presentation_size_request(now) && !natural_pending && let Some(toplevel) = window.toplevel() { @@ -864,6 +893,10 @@ mod tests { id, crate::resize_transaction::ResizeTransaction::new(1.into(), Duration::from_millis(40)), ); + state.rebuild_presentation_dependencies(); + state + .presentation_dependencies + .wait_for_viewport(workspace, id, 1.into()); state.advance_animations_at(Duration::from_millis(300), Duration::from_millis(340)); assert!(state.windows.transaction(&id).is_none()); assert_eq!(state.viewport_animations[&workspace], frozen); @@ -882,6 +915,10 @@ mod tests { id, crate::resize_transaction::ResizeTransaction::new(2.into(), Duration::from_millis(356)), ); + state.rebuild_presentation_dependencies(); + state + .presentation_dependencies + .wait_for_viewport(workspace, id, 2.into()); state.advance_animations_at(Duration::from_millis(100), Duration::from_millis(456)); let frozen = state.viewport_animations[&workspace]; state.windows.clear_transaction(&id); @@ -935,10 +972,14 @@ mod tests { id, crate::resize_transaction::ResizeTransaction::new(3.into(), Duration::from_millis(800)), ); + state.rebuild_presentation_dependencies(); + state + .presentation_dependencies + .wait_for_viewport(workspace, id, 3.into()); state.advance_animations_at(Duration::from_millis(100), Duration::from_millis(900)); - assert!(state.paused_workspaces.contains(&workspace)); + assert!(state.presentation_dependencies.needs_tick()); state.windows.clear_transaction(&id); state.advance_animations_at(Duration::from_millis(16), Duration::from_millis(916)); - assert!(state.paused_workspaces.is_empty()); + assert!(!state.presentation_dependencies.needs_tick()); } } diff --git a/compositor/src/state/capture_privacy_tests.rs b/compositor/src/state/capture_privacy_tests.rs index 381c76f..9965500 100644 --- a/compositor/src/state/capture_privacy_tests.rs +++ b/compositor/src/state/capture_privacy_tests.rs @@ -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::::create_buffer(&mut renderer, Fourcc::Abgr8888, (320, 240).into()).unwrap(); + let mut capture_texture = + Offscreen::::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::(&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" + ); + } +} diff --git a/compositor/src/state/layout.rs b/compositor/src/state/layout.rs index 0511b35..75122d7 100644 --- a/compositor/src/state/layout.rs +++ b/compositor/src/state/layout.rs @@ -111,6 +111,42 @@ impl Ferese { self.advance_animations(Instant::now()); self.stacking_cache.invalidate(); self.arrange_layers(); + let source_column_widths = self + .windows + .records() + .filter_map(|(&id, record)| { + let layout = &self + .workspaces + .workspace(self.workspaces.workspace_for_window(id)?)? + .layout; + let WorkspaceLayout::Scrolling(layout) = layout else { + return None; + }; + Some(( + id, + record + .resize + .and_then(|transaction| transaction.column_width().map(|(source, _)| source)) + .or_else(|| { + // A sibling may have committed while this column is still + // held. Replacement configures must share that cohort's + // presented source, rather than its latest allocated width. + layout + .columns() + .iter() + .find(|column| column.windows.contains(&id)) + .and_then(|column| { + column.windows.iter().find_map(|sibling| { + self.windows.transaction(sibling).and_then(|transaction| { + transaction.column_width().map(|(source, _)| source) + }) + }) + }) + }) + .or_else(|| layout.allocated_column_width(id))?, + )) + }) + .collect::>(); let mut previous_scrolling_world_x = self.windows.take_world_positions(); let pending_column_width_cycles = self.windows.take_column_width_requests(); let outputs = self.space.outputs().cloned().collect::>(); @@ -261,7 +297,7 @@ impl Ferese { self.unmap_invisible_windows(&visible); - let now = self.start_time.elapsed(); + let now = self.presentation_now(); placements.sort_by_key(|(_, id, ..)| self.window_stack.rank(*id)); @@ -408,7 +444,6 @@ impl Ferese { && had_geometry && self.animations_enabled { - self.paused_workspaces.extend(self.workspaces.workspace_for_window(id)); #[cfg(feature = "resize-metrics")] self.resize_metrics.begin( id, @@ -419,12 +454,23 @@ impl Ferese { self.windows.set_transaction( id, crate::resize_transaction::ResizeTransaction::new(serial, now) - .with_source_geometry(window.geometry()), + .with_source_geometry(window.geometry()) + .with_column_width( + source_column_widths.get(&id).copied(), + self.workspaces + .workspace_for_window(id) + .and_then(|workspace| self.workspaces.workspace(workspace)) + .and_then(|workspace| match &workspace.layout { + WorkspaceLayout::Scrolling(layout) => layout.allocated_column_width(id), + _ => None, + }), + ), ); } } } + self.rebuild_presentation_dependencies(); self.sync_window_stacking(); self.retarget_overview(); if !finalize { diff --git a/compositor/src/state/navigation.rs b/compositor/src/state/navigation.rs index da7a7ca..48d0e58 100644 --- a/compositor/src/state/navigation.rs +++ b/compositor/src/state/navigation.rs @@ -71,7 +71,13 @@ impl Ferese { candidate.viewport_x().unwrap_or(start) }, progress, + dependencies: self.presentation_dependencies.save_viewport(workspace_id), + layout: match candidate { + WorkspaceLayout::Scrolling(layout) => Some(layout), + _ => None, + }, }); + self.rebuild_presentation_dependencies(); self.swipe.mark_preview_started(); } if let Some(swipe) = &mut self.focus_swipe { @@ -94,8 +100,16 @@ impl Ferese { return false; }; let current = self.focus_swipe_is_current(&swipe); + let blocked = self + .presentation_dependencies + .viewport_blocked(swipe.workspace, &self.windows); + self.presentation_dependencies + .restore_viewport(swipe.workspace, swipe.dependencies.clone()); // Release from the last input position even if no frame rendered that update. - if current && let Some(viewport) = self.viewport_animations.get_mut(&swipe.workspace) { + if current + && !blocked + && let Some(viewport) = self.viewport_animations.get_mut(&swipe.workspace) + { viewport.current = swipe.position(); viewport.velocity = swipe .release_velocity(self.swipe.release_velocity, self.swipe.unbounded_release_velocity) diff --git a/compositor/src/state/prediction.rs b/compositor/src/state/prediction.rs index f32f12e..20094dd 100644 --- a/compositor/src/state/prediction.rs +++ b/compositor/src/state/prediction.rs @@ -37,7 +37,25 @@ impl Ferese { delta: Duration, ) -> crate::presentation::WindowPresentation { let record = self.windows.record(id).expect("presentation has a window record"); - let mut bounds = overview.presented_bounds(id, geometry.visual); + let mut visual = geometry.visual; + if self.presentation_dependencies.reflow_blocked(id, &self.windows) { + let predicted_x_velocity = visual.velocity.x; + visual.velocity = Default::default(); + if !geometry.is_zooming() + && let Some((workspace, _)) = record.world_x.as_ref() + && !self + .presentation_dependencies + .viewport_blocked(*workspace, &self.windows) + && let Some(viewport) = self.viewport_animations.get(workspace) + { + visual.velocity.x = if delta.is_zero() { + -viewport.velocity + } else { + predicted_x_velocity + }; + } + } + let mut bounds = overview.presented_bounds(id, visual); bounds.current.x += offset.0; bounds.current.y += offset.1; if let Some(workspace) = self.workspaces.workspace_for_window(id) @@ -113,18 +131,17 @@ impl Ferese { .flat_map(move |output| self.output_workspaces.assigned_workspaces(output)) .filter_map(move |id| self.workspaces.workspace(id)) .flat_map(move |workspace| { - let blocked = block_resizes - && workspace - .layout - .window_ids() - .chain(workspace.floating.iter().copied()) - .any(|id| self.windows.record(id).is_some_and(|record| record.resize.is_some())); - workspace .layout .window_ids() .chain(workspace.floating.iter().copied()) - .filter_map(move |id| Some((id, self.windows.record(id)?, blocked))) + .filter_map(move |id| { + Some(( + id, + self.windows.record(id)?, + block_resizes && self.presentation_dependencies.reflow_blocked(id, &self.windows), + )) + }) }) } @@ -252,10 +269,16 @@ impl Ferese { .as_ref() .is_some_and(|swipe| swipe.workspace == *workspace); - Some((*world, *viewport, held)) + Some(( + *world, + *viewport, + held || self + .presentation_dependencies + .viewport_blocked(*workspace, &self.windows), + )) }); let width = record.coupled_width.as_ref().map(|(_, width)| *width); - let geometry = if delta.is_zero() || blocked { + let geometry = if delta.is_zero() { original } else { predict_geometry( @@ -265,6 +288,7 @@ impl Ferese { self.viewport_spring_config, world, width, + blocked, ) }; @@ -304,6 +328,7 @@ fn predict_geometry( viewport_spring: SpringConfig, world: Option<(AnimatedValue, AnimatedValue, bool)>, width: Option, + reflow_held: bool, ) -> WindowGeometry { let mut predicted = original; let zooming = predicted.is_zooming(); @@ -312,21 +337,32 @@ fn predict_geometry( predicted.visual.velocity.width = 0.0; } - predicted.advance(delta, spring, true); + if !reflow_held { + predicted.advance(delta, spring, true); + } else { + predicted.visual.velocity = Default::default(); + } predicted.visual.target.width = original.visual.target.width; if let Some((mut world, mut viewport, held)) = world && !zooming { - world.advance(delta, spring); + if !reflow_held { + world.advance(delta, spring); + } else { + world.velocity = 0.0; + } if !held { viewport.advance(delta, viewport_spring); + } else { + viewport.velocity = 0.0; } - predicted.visual.current.x = world.current - viewport.current; - predicted.visual.velocity.x = world.velocity - viewport.velocity; + super::animation::sync_scrolling_coordinates(&mut predicted, &mut world, &viewport, false); } - if let Some(mut width) = width { + if let Some(mut width) = width + && !reflow_held + { width.advance(delta, viewport_spring); predicted.visual.current.width = width.current; predicted.visual.velocity.width = width.velocity; @@ -625,6 +661,7 @@ mod tests { id, crate::resize_transaction::ResizeTransaction::new(9.into(), Duration::ZERO), ); + state.rebuild_presentation_dependencies(); let blocked = state.sample_frame(&output, Duration::from_millis(16)); assert_eq!(blocked.windows[&id].geometry, geometry); assert!(state.windows.transaction(&id).is_some()); @@ -726,6 +763,7 @@ mod tests { SpringConfig::default(), None, None, + false, ) }; @@ -741,7 +779,8 @@ mod tests { #[test] fn scrolling_coordinates_and_coupled_width_use_the_same_forecast_time() { - let geometry = WindowGeometry::new(Rect::new(500., 0., 400., 300.), None); + let mut geometry = WindowGeometry::new(Rect::new(500., 0., 400., 300.), None); + geometry.set_logical_target(Rect::new(400., 0., 500., 300.), Duration::ZERO); let mut world = AnimatedValue::new(500.); world.set_target(600.); let mut viewport = AnimatedValue::new(0.); @@ -757,6 +796,7 @@ mod tests { spring, Some((world, viewport, false)), Some(width), + false, ); world.advance_with_policy(delta, spring, CrossingPolicy::NoCrossing); viewport.advance_with_policy(delta, spring, CrossingPolicy::NoCrossing); diff --git a/compositor/src/state/presentation_dependencies.rs b/compositor/src/state/presentation_dependencies.rs new file mode 100644 index 0000000..de5039d --- /dev/null +++ b/compositor/src/state/presentation_dependencies.rs @@ -0,0 +1,479 @@ +use super::*; +use smithay::utils::Serial; + +#[derive(Clone, Copy, Debug, PartialEq)] +struct ClientCommit { + window: WindowId, + serial: Serial, +} + +#[derive(Clone, Copy)] +struct ViewportCommit { + commit: ClientCommit, + width: (f64, f64), +} + +#[derive(Clone)] +struct ViewportStep { + target: ferese_layout::ViewportTarget, + inputs: Vec, +} + +#[derive(Clone, Default)] +pub(super) struct ViewportDependencies { + steps: Vec, + waits: Vec, + // A pathological burst must not retain an unbounded history. The fallback + // preserves the linked wait until commit/deadline or an absolute retarget. + overflow: bool, +} + +const MAX_VIEWPORT_STEPS: usize = 32; + +/// The buffer-dependent properties in this path. Translation has its own +/// inputs; it does not inherit a tile's resize/reflow wait by membership. +#[derive(Default)] +pub(super) struct PresentationDependencies { + reflow: HashMap>, + viewport: HashMap, + // A release tick consumes waiting wall time without stepping held springs. + paused_reflow: HashSet, + paused_viewport: HashSet, +} + +impl PresentationDependencies { + pub(super) fn save_viewport(&self, workspace: WorkspaceId) -> Option { + self.viewport.get(&workspace).cloned() + } + + pub(super) fn restore_viewport(&mut self, workspace: WorkspaceId, saved: Option) { + if let Some(saved) = saved { + self.viewport.insert(workspace, saved); + } else { + self.viewport.remove(&workspace); + } + } + + #[cfg(test)] + pub(super) fn wait_for_viewport(&mut self, workspace: WorkspaceId, id: WindowId, serial: Serial) { + self.viewport.insert( + workspace, + ViewportDependencies { + waits: vec![ClientCommit { window: id, serial }], + ..Default::default() + }, + ); + } + + pub(super) fn needs_tick(&self) -> bool { + !self.paused_reflow.is_empty() || !self.paused_viewport.is_empty() + } + + fn pending( + wait: ClientCommit, + windows: &window_registry::WindowRegistry, + ) -> bool { + windows + .transaction(&wait.window) + .is_some_and(|transaction| transaction.serial() == wait.serial) + } + + pub(super) fn reflow_blocked( + &self, + id: WindowId, + windows: &window_registry::WindowRegistry, + ) -> bool { + self.reflow + .get(&id) + .is_some_and(|waits| waits.iter().any(|wait| Self::pending(*wait, windows))) + } + + pub(super) fn viewport_blocked( + &self, + workspace: WorkspaceId, + windows: &window_registry::WindowRegistry, + ) -> bool { + self.viewport + .get(&workspace) + .is_some_and(|waits| waits.waits.iter().any(|wait| Self::pending(*wait, windows))) + } + + pub(super) fn holds( + &mut self, + windows: &window_registry::WindowRegistry, + ) -> (HashSet, HashSet) { + let reflow = self + .reflow + .keys() + .copied() + .filter(|id| self.reflow_blocked(*id, windows)) + .collect::>(); + let viewport = self + .viewport + .keys() + .copied() + .filter(|workspace| self.viewport_blocked(*workspace, windows)) + .collect::>(); + let mut held_reflow = std::mem::replace(&mut self.paused_reflow, reflow.clone()); + let mut held_viewport = std::mem::replace(&mut self.paused_viewport, viewport.clone()); + held_reflow.extend(reflow); + held_viewport.extend(viewport); + self.reflow.retain(|_, waits| { + waits.retain(|wait| Self::pending(*wait, windows)); + !waits.is_empty() + }); + self.viewport.retain(|_, waits| { + waits.waits.retain(|wait| Self::pending(*wait, windows)); + for step in &mut waits.steps { + step.inputs.retain(|input| Self::pending(input.commit, windows)); + } + if waits.steps.iter().all(|step| step.inputs.is_empty()) { + waits.steps.clear(); + } + !waits.steps.is_empty() || !waits.waits.is_empty() + }); + (held_reflow, held_viewport) + } +} + +fn viewport_dependencies( + layout: &ferese_layout::ScrollingLayout, + pending: &[(WindowId, crate::resize_transaction::ResizeTransaction)], + mut previous: ViewportDependencies, +) -> ViewportDependencies { + let Some(target) = layout.viewport_target() else { + return ViewportDependencies::default(); + }; + let live = |input: &ViewportCommit| { + pending + .iter() + .any(|(window, transaction)| *window == input.commit.window && transaction.serial() == input.commit.serial) + }; + for step in &mut previous.steps { + for input in &mut step.inputs { + if let Some((_, transaction)) = pending.iter().find(|(window, transaction)| { + *window == input.commit.window && transaction.column_width() == Some(input.width) + }) { + input.commit.serial = transaction.serial(); + } + } + step.inputs.retain(live); + } + // Fold any prefix with no current configure inputs to the numeric start + // already stored in the next request. No obsolete serial owns a recipe. + if let Some(first) = previous.steps.iter().position(|step| !step.inputs.is_empty()) { + previous.steps.drain(..first); + } else { + previous.steps.clear(); + previous.overflow = false; + } + let inputs = pending + .iter() + .filter_map(|(window, transaction)| { + let width = transaction.column_width()?; + transaction.width_changes().then_some(ViewportCommit { + commit: ClientCommit { + window: *window, + serial: transaction.serial(), + }, + width, + }) + }) + .collect::>(); + if previous.steps.last().is_none_or(|step| step.target != *target) { + if !target.uses_previous_target() { + previous.steps.clear(); + previous.overflow = false; + } + if previous.steps.len() < MAX_VIEWPORT_STEPS { + previous.steps.push(ViewportStep { + target: target.clone(), + inputs: inputs.clone(), + }); + } else { + previous.overflow = true; + } + } else if let Some(step) = previous.steps.last_mut() { + // A replacement binds this request to its latest configure, including + // same-width height changes. Older recipe inputs retire above. + step.inputs = inputs.clone(); + } + let mut owners = previous + .steps + .iter() + .flat_map(|step| step.inputs.iter().map(|input| input.commit)) + .collect::>(); + owners.extend(inputs.iter().map(|input| input.commit)); + owners.sort_by_key(|owner| owner.window.0); + owners.dedup(); + let evaluate = |only: Option, exclude: Option| { + let mut start = None; + for step in &previous.steps { + let widths = step + .inputs + .iter() + .filter(|input| only.is_none_or(|only| input.commit == only) && exclude != Some(input.commit)) + .map(|input| (input.commit.window, input.width.0, input.width.1)) + .collect::>(); + start = Some(step.target.with_held_widths(&widths, start)); + } + start.unwrap_or(layout.viewport_x()) + }; + let held = evaluate(None, None); + previous.waits = owners + .into_iter() + .filter(|owner| { + previous.overflow + || (evaluate(Some(*owner), None) - layout.viewport_x()).abs() > 0.001 + || (held - evaluate(None, Some(*owner))).abs() > 0.001 + }) + .collect(); + // Keep a bounded recipe while any of its inputs is pending: a partial + // commit can expose a page-packing dependency previously masked by others. + if previous.steps.iter().all(|step| step.inputs.is_empty()) { + return ViewportDependencies::default(); + } + previous +} + +impl Ferese { + pub(super) fn release_presentation_dependencies(&mut self) { + let paused_reflow = self.presentation_dependencies.paused_reflow.clone(); + let paused_viewport = self.presentation_dependencies.paused_viewport.clone(); + self.rebuild_presentation_dependencies(); + self.presentation_dependencies.paused_reflow.extend(paused_reflow); + self.presentation_dependencies.paused_viewport.extend(paused_viewport); + } + + pub(super) fn rebuild_presentation_dependencies(&mut self) { + let mut reflow = HashMap::new(); + let mut viewport = HashMap::new(); + let mut previous_viewport = std::mem::take(&mut self.presentation_dependencies.viewport); + for (&id, _) in self.windows.records() { + let Some(workspace_id) = self.workspaces.workspace_for_window(id) else { + continue; + }; + let Some(workspace) = self.workspaces.workspace(workspace_id) else { + continue; + }; + let own_column = match &workspace.layout { + WorkspaceLayout::Scrolling(layout) => { + layout.columns().iter().position(|column| column.windows.contains(&id)) + } + WorkspaceLayout::Tree(_) => None, + }; + let mut waits = Vec::new(); + for slow in self.windows.resizing() { + if self.workspaces.workspace_for_window(*slow) != Some(workspace_id) { + continue; + } + let transaction = *self.windows.transaction(slow).unwrap(); + let commit = ClientCommit { + window: *slow, + serial: transaction.serial(), + }; + let linked = id == *slow + || match &workspace.layout { + WorkspaceLayout::Scrolling(layout) => own_column.is_some_and(|own| { + layout + .columns() + .iter() + .position(|column| column.windows.contains(slow)) + .is_some_and(|source| source == own || (source < own && transaction.width_changes())) + }), + WorkspaceLayout::Tree(_) => workspace.layout.contains(id) && workspace.layout.contains(*slow), + }; + if linked { + waits.push(commit); + } + } + if !waits.is_empty() { + reflow.insert(id, waits); + } + } + for workspace in self.workspaces.iter() { + let layout = self + .focus_swipe + .as_ref() + .filter(|swipe| swipe.workspace == workspace.id) + .and_then(|swipe| swipe.layout.as_ref()) + .or_else(|| match &workspace.layout { + WorkspaceLayout::Scrolling(layout) => Some(layout), + _ => None, + }); + let Some(layout) = layout else { continue }; + let waits = viewport_dependencies( + layout, + &self + .windows + .resizing() + .filter(|id| { + self.workspaces.workspace_for_window(**id) == Some(workspace.id) && layout.contains(**id) + }) + .map(|id| (*id, *self.windows.transaction(id).unwrap())) + .collect::>(), + previous_viewport.remove(&workspace.id).unwrap_or_default(), + ); + if !waits.steps.is_empty() || !waits.waits.is_empty() { + viewport.insert(workspace.id, waits); + } + } + // A retarget uses the new dependency set immediately. A dependency that + // disappeared must not freeze a newly independent target for one frame. + self.presentation_dependencies + .paused_reflow + .retain(|id| reflow.contains_key(id)); + self.presentation_dependencies + .paused_viewport + .retain(|id| viewport.get(id).is_some_and(|waits| !waits.waits.is_empty())); + self.presentation_dependencies.reflow = reflow; + self.presentation_dependencies.viewport = viewport; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resize_transaction::ResizeTransaction; + + #[test] + fn viewport_recipe_handles_cancellation_joint_pages_and_bounded_bursts() { + use ferese_layout::{ColumnWidth, ScrollingLayout}; + let bounds = Rect::new(0.0, 0.0, 200.0, 100.0); + let gaps = GapConfig { + inner: 0.0, + outer: 0.0, + smart: false, + }; + let constraints = HashMap::new(); + let a = WindowId(1); + let b = WindowId(2); + let c = WindowId(3); + let mut layout = ScrollingLayout::with_default_width(ColumnWidth::Fixed(100.0)); + layout.set_focus_strategy(ViewportFocusStrategy::Center); + layout.insert(a, None).unwrap(); + layout.insert(b, Some(a)).unwrap(); + layout.set_column_width(a, ColumnWidth::Fixed(150.0)).unwrap(); + layout.set_column_width(b, ColumnWidth::Fixed(300.0)).unwrap(); + layout + .geometry_with_constraints(bounds, gaps, &constraints, Some(b)) + .unwrap(); + let pending = [( + a, + ResizeTransaction::new(10.into(), Duration::ZERO).with_column_width(Some(100.0), Some(150.0)), + )]; + let first = viewport_dependencies(&layout, &pending, Default::default()); + assert_eq!(first.waits.len(), 1); + layout.slide_focus_from(b, a).unwrap(); + layout + .geometry_with_constraints(bounds, gaps, &constraints, Some(a)) + .unwrap(); + assert_eq!(layout.viewport_x(), 50.0); + let canceled = viewport_dependencies(&layout, &pending, first); + assert!( + canceled.waits.is_empty(), + "relative target cancels pending prefix width" + ); + let mut layout = ScrollingLayout::with_default_width(ColumnWidth::Fixed(40.0)); + layout.set_focus_strategy(ViewportFocusStrategy::Paged); + layout.insert(a, None).unwrap(); + layout.insert(b, Some(a)).unwrap(); + layout.insert(c, Some(b)).unwrap(); + layout + .geometry_with_constraints(bounds, gaps, &constraints, Some(c)) + .unwrap(); + let pending = [ + ( + a, + ResizeTransaction::new(10.into(), Duration::ZERO).with_column_width(Some(100.0), Some(40.0)), + ), + ( + b, + ResizeTransaction::new(20.into(), Duration::ZERO).with_column_width(Some(100.0), Some(40.0)), + ), + ]; + assert!(!layout.viewport_depends_on_width(a, 100.0, 40.0)); + assert!(!layout.viewport_depends_on_width(b, 100.0, 40.0)); + let both = viewport_dependencies(&layout, &pending, Default::default()); + assert_eq!(both.waits.len(), 2, "joint held widths select another page"); + let partial = viewport_dependencies(&layout, &pending[1..], both); + assert!(partial.waits.is_empty(), "one remaining held width fits this page"); + assert!(viewport_dependencies(&layout, &[], partial).steps.is_empty()); + // Independent absolute requests reset history. Relative request storage + // is capped even if a client keeps replacing its configure. + layout.set_focus_strategy(ViewportFocusStrategy::Center); + layout.set_column_width(a, ColumnWidth::Fixed(150.0)).unwrap(); + layout.set_column_width(b, ColumnWidth::Fixed(300.0)).unwrap(); + layout + .geometry_with_constraints(bounds, gaps, &constraints, Some(b)) + .unwrap(); + let pending = [( + a, + ResizeTransaction::new(30.into(), Duration::ZERO).with_column_width(Some(100.0), Some(150.0)), + )]; + let mut graph = viewport_dependencies(&layout, &pending, Default::default()); + for index in 0..80 { + let (from, to) = if index % 2 == 0 { (b, a) } else { (a, b) }; + layout.slide_focus_from(from, to).unwrap(); + layout + .geometry_with_constraints(bounds, gaps, &constraints, Some(to)) + .unwrap(); + graph = viewport_dependencies(&layout, &pending, graph); + assert!(graph.steps.len() <= MAX_VIEWPORT_STEPS); + } + assert!(graph.overflow); + assert!(!graph.waits.is_empty(), "bounded fallback must not free linked input"); + layout.center_window(a, bounds, gaps, &constraints).unwrap(); + graph = viewport_dependencies(&layout, &pending, graph); + assert_eq!(graph.steps.len(), 1); + assert!(!graph.overflow); + } + + #[test] + fn properties_wait_for_only_current_configures_and_consume_release_time() { + let mut windows = window_registry::WindowRegistry::default(); + let a = WindowId(1); + let b = WindowId(2); + let neighbor = WindowId(3); + windows.register("a", a); + windows.register("b", b); + windows.register("neighbor", neighbor); + windows.set_transaction(a, ResizeTransaction::new(10.into(), Duration::ZERO)); + windows.set_transaction(b, ResizeTransaction::new(20.into(), Duration::from_millis(100))); + let commit = |window, serial| ClientCommit { + window, + serial: Serial::from(serial), + }; + let workspace = WorkspaceId(1); + let mut dependencies = PresentationDependencies::default(); + dependencies.reflow.insert(a, vec![commit(a, 10)]); + dependencies.reflow.insert(neighbor, vec![commit(a, 10), commit(b, 20)]); + dependencies.wait_for_viewport(workspace, b, 20.into()); + assert!(dependencies.reflow_blocked(neighbor, &windows)); + assert!(!dependencies.reflow_blocked(b, &windows)); + assert!(dependencies.viewport_blocked(workspace, &windows)); + dependencies.holds(&windows); + // A replacement cannot be released by the older committed serial. + windows.set_transaction(b, ResizeTransaction::new(21.into(), Duration::from_millis(150))); + assert!(!windows.transaction(&b).unwrap().accepts(Some(20.into()))); + assert!(!dependencies.viewport_blocked(workspace, &windows)); + dependencies.wait_for_viewport(workspace, b, 21.into()); + dependencies.reflow.insert(neighbor, vec![commit(a, 10), commit(b, 21)]); + windows.expire_transactions(Duration::from_millis(300)); + assert!(windows.transaction(&a).is_none()); + assert!(windows.transaction(&b).is_some()); + assert!(dependencies.reflow_blocked(neighbor, &windows)); + let (held, _) = dependencies.holds(&windows); + assert!(held.contains(&a), "release tick must not charge waiting time"); + let (held, _) = dependencies.holds(&windows); + assert!(!held.contains(&a)); + // Unmap removes the serial owner; both properties retire on the tick. + windows.remove(&"b"); + assert!(!dependencies.viewport_blocked(workspace, &windows)); + let (_, held) = dependencies.holds(&windows); + assert!(held.contains(&workspace)); + assert!(dependencies.holds(&windows).1.is_empty()); + assert!(!dependencies.needs_tick()); + assert!(dependencies.reflow.is_empty() && dependencies.viewport.is_empty()); + } +} diff --git a/compositor/src/state/windows.rs b/compositor/src/state/windows.rs index 9fdd427..e201acc 100644 --- a/compositor/src/state/windows.rs +++ b/compositor/src/state/windows.rs @@ -432,7 +432,7 @@ impl Ferese { self.focus_history.remove(id); #[cfg(feature = "resize-metrics")] self.resize_metrics - .end(id, self.start_time.elapsed(), crate::resize_metrics::End::Cancelled); + .end(id, self.presentation_now(), crate::resize_metrics::End::Cancelled); self.render.remove_window(id); self.capture_render.remove_window(id); self.window_stack.remove(id); @@ -554,11 +554,12 @@ impl Ferese { { #[cfg(feature = "resize-metrics")] { - self.measure_resize_pauses(self.start_time.elapsed()); + self.measure_resize_pauses(self.presentation_now()); self.resize_metrics - .end(id, self.start_time.elapsed(), crate::resize_metrics::End::Commit); + .end(id, self.presentation_now(), crate::resize_metrics::End::Commit); } self.windows.clear_transaction(&id); + self.release_presentation_dependencies(); } let Some(size) = client_size(window) else { @@ -611,7 +612,7 @@ impl Ferese { self.windows.set_geometry(id, WindowGeometry::new(rect, Some(size))); #[cfg(feature = "resize-metrics")] self.resize_metrics - .end(id, self.start_time.elapsed(), crate::resize_metrics::End::Cancelled); + .end(id, self.presentation_now(), crate::resize_metrics::End::Cancelled); self.windows.clear_transaction(&id); self.relayout(); } @@ -650,7 +651,7 @@ impl Ferese { self.windows.set_geometry(id, WindowGeometry::new(rect, Some(size))); #[cfg(feature = "resize-metrics")] self.resize_metrics - .end(id, self.start_time.elapsed(), crate::resize_metrics::End::Cancelled); + .end(id, self.presentation_now(), crate::resize_metrics::End::Cancelled); self.windows.clear_transaction(&id); self.render.clear_snapshot(&id); self.relayout(); diff --git a/crates/ferese-layout/src/lib.rs b/crates/ferese-layout/src/lib.rs index 888ff99..7b722c3 100644 --- a/crates/ferese-layout/src/lib.rs +++ b/crates/ferese-layout/src/lib.rs @@ -4,7 +4,7 @@ use std::fmt; mod scrolling; -pub use scrolling::{Column, ColumnWidth, ScrollingLayout, ViewportFocusStrategy}; +pub use scrolling::{Column, ColumnWidth, ScrollingLayout, ViewportFocusStrategy, ViewportTarget}; const MIN_SPLIT_RATIO: f64 = 0.05; const MAX_SPLIT_RATIO: f64 = 0.95; diff --git a/crates/ferese-layout/src/scrolling.rs b/crates/ferese-layout/src/scrolling.rs index 4b0dafb..91014ff 100644 --- a/crates/ferese-layout/src/scrolling.rs +++ b/crates/ferese-layout/src/scrolling.rs @@ -69,6 +69,86 @@ impl Column { } } +// The last calculation that selected the viewport target. Keep it separate +// from pending focus requests so an unrelated relayout cannot lose provenance. +#[derive(Clone, Debug, PartialEq)] +pub struct ViewportTarget { + columns: Vec<(Vec, f64)>, + inner: f64, + viewport_width: f64, + strategy: ViewportFocusStrategy, + start: f64, + request: ViewportRequest, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum ViewportRequest { + Reveal(usize), + WidthCycle(usize), + Slide(usize, usize), + Center(usize, Option), +} + +impl ViewportTarget { + fn target(&self, replacements: &HashMap, start: Option) -> f64 { + let start = start.unwrap_or(self.start); + let mut x = 0.0; + let positions = self + .columns + .iter() + .enumerate() + .map(|(index, (_, width))| { + let width = replacements.get(&index).copied().unwrap_or(*width); + let position = (x, width); + x += width + self.inner; + position + }) + .collect::>(); + let mut layout = ScrollingLayout { + viewport_x: start, + focus_strategy: self.strategy, + ..Default::default() + }; + match self.request { + ViewportRequest::Reveal(active) => layout.reveal_column(&positions, self.viewport_width, active), + ViewportRequest::WidthCycle(active) => { + layout.active_column = Some(active); + layout.retarget_after_width_cycle(&positions, self.viewport_width); + } + ViewportRequest::Slide(from, to) => { + let (last, width) = positions.last().copied().unwrap_or_default(); + layout.viewport_x = (start + positions[to].0 - positions[from].0) + .clamp(0.0, (last + width - self.viewport_width).max(0.0)); + } + ViewportRequest::Center(active, maximum) => { + let width = maximum.map_or(positions[active].1, |maximum| positions[active].1.min(maximum)); + layout.viewport_x = positions[active].0 + width / 2.0 - self.viewport_width / 2.0 + } + } + layout.viewport_x + } + + /// Re-evaluate the same target with pending allocated widths held back. + /// A start-relative request can also use the previous held target. + pub fn with_held_widths(&self, widths: &[(WindowId, f64, f64)], start: Option) -> f64 { + let replacements = widths + .iter() + .filter_map(|(window, source, target)| { + self.columns + .iter() + .position(|(windows, width)| windows.contains(window) && (*width - target).abs() < 0.001) + .map(|index| (index, *source)) + }) + .collect::>(); + self.target(&replacements, start) + } + + pub fn uses_previous_target(&self) -> bool { + matches!(self.request, ViewportRequest::Slide(..)) + || (matches!(self.request, ViewportRequest::Reveal(..)) && self.strategy == ViewportFocusStrategy::Minimal) + } +} + #[derive(Clone, Debug, PartialEq)] pub struct ScrollingLayout { columns: Vec, @@ -80,6 +160,8 @@ pub struct ScrollingLayout { swipe_focus_pending: Option<(WindowId, WindowId)>, width_cycle_pending: bool, last_viewport_width: Option, + allocated_widths: HashMap, + viewport_basis: Option, } impl Default for ScrollingLayout { @@ -94,6 +176,8 @@ impl Default for ScrollingLayout { swipe_focus_pending: None, width_cycle_pending: false, last_viewport_width: None, + allocated_widths: HashMap::new(), + viewport_basis: None, } } } @@ -137,6 +221,48 @@ impl ScrollingLayout { self.viewport_x } + pub fn allocated_column_width(&self, window: WindowId) -> Option { + self.allocated_widths.get(&window).copied() + } + + /// Whether this target changes when the pending column width is held back. + /// Allocated widths, rather than cell-grid/native buffer sizes, own the strip. + pub fn viewport_depends_on_width(&self, window: WindowId, source: f64, target: f64) -> bool { + (source - target).abs() > 0.001 + && self + .viewport_basis + .as_ref() + .map(|basis| basis.with_held_widths(&[(window, source, target)], None)) + .is_some_and(|held| (held - self.viewport_x).abs() > 0.001) + } + + pub fn viewport_target(&self) -> Option<&ViewportTarget> { + self.viewport_basis.as_ref() + } + + fn record_viewport_basis( + &mut self, + positions: &[(f64, f64)], + inner: f64, + viewport_width: f64, + request: ViewportRequest, + ) { + let next = ViewportTarget { + columns: self + .columns + .iter() + .zip(positions) + .map(|(column, (_, width))| (column.windows.clone(), *width)) + .collect(), + inner, + viewport_width, + strategy: self.focus_strategy, + start: self.viewport_x, + request, + }; + self.viewport_basis = Some(next); + } + pub fn contains(&self, window: WindowId) -> bool { self.window_location(window).is_some() } @@ -313,6 +439,43 @@ impl ScrollingLayout { let viewport_center = bounds.x + bounds.width / 2.0; let window_center = rect.x + rect.width / 2.0; self.viewport_x += window_center - viewport_center; + let active = self.window_location(window).unwrap().0; + let inner = finite_nonnegative(gaps.inner); + let positions = self + .columns + .iter() + .scan(0.0, |x, column| { + let width = self.allocated_widths[&column.windows[0]]; + let position = (*x, width); + *x += width + inner; + Some(position) + }) + .collect::>(); + let viewport_width = self.last_viewport_width.unwrap(); + self.record_viewport_basis( + &positions, + inner, + viewport_width, + ViewportRequest::Center( + active, + constraints + .get(&window) + .copied() + .map(normalized_constraints) + .unwrap_or_default() + .max_width + .map(|maximum| { + maximum.max( + constraints + .get(&window) + .copied() + .map(normalized_constraints) + .unwrap_or_default() + .min_width, + ) + }), + ), + ); self.geometry_with_constraints(bounds, gaps, constraints, Some(window))?; Ok(self.viewport_x != before) @@ -445,15 +608,35 @@ impl ScrollingLayout { column_positions.push((next_column_x, width)); next_column_x += width + inner; } + self.allocated_widths = self + .columns + .iter() + .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); if self.width_cycle_pending { + if let Some(active) = self.active_column { + self.record_viewport_basis( + &column_positions, + inner, + viewport_width, + ViewportRequest::WidthCycle(active), + ); + } self.retarget_after_width_cycle(&column_positions, viewport_width); self.width_cycle_pending = false; self.reveal_pending = None; self.swipe_focus_pending = None; } else if let Some((previous, focused)) = self.swipe_focus_pending.take() { if let (Some((from, _)), Some((to, _))) = (self.window_location(previous), self.window_location(focused)) { + self.record_viewport_basis( + &column_positions, + inner, + viewport_width, + ViewportRequest::Slide(from, to), + ); let last_end = column_positions .last() .map(|(start, width)| start + width) @@ -470,6 +653,12 @@ impl ScrollingLayout { .flatten() }); if let Some((column, _)) = reveal.and_then(|window| self.window_location(window)) { + self.record_viewport_basis( + &column_positions, + inner, + viewport_width, + ViewportRequest::Reveal(column), + ); self.reveal_column(&column_positions, viewport_width, column); } } @@ -723,6 +912,121 @@ mod tests { WindowId(id) } + #[test] + fn explicit_center_replays_maximum_even_when_only_held_width_crosses_it() { + let bounds = Rect::new(0.0, 0.0, 200.0, 100.0); + let gaps = GapConfig { + inner: 0.0, + outer: 0.0, + smart: false, + }; + let id = WindowId(1); + let mut layout = ScrollingLayout::with_default_width(ColumnWidth::Fixed(100.0)); + layout.insert(id, None).unwrap(); + let constraints = HashMap::from([( + id, + SizeConstraints { + max_width: Some(200.0), + ..Default::default() + }, + )]); + layout.center_window(id, bounds, gaps, &constraints).unwrap(); + assert_eq!(layout.viewport_x(), -50.0); + assert_eq!( + layout + .viewport_target() + .unwrap() + .with_held_widths(&[(id, 300.0, 100.0)], None), + 0.0 + ); + let constraints = HashMap::from([( + id, + SizeConstraints { + max_width: Some(40.0), + ..Default::default() + }, + )]); + layout.set_column_width(id, ColumnWidth::Fixed(150.0)).unwrap(); + layout.center_window(id, bounds, gaps, &constraints).unwrap(); + assert!(!layout.viewport_depends_on_width(id, 100.0, 150.0)); + } + + #[test] + fn viewport_dependencies_follow_allocated_widths_and_target_derivation() { + let bounds = Rect::new(0.0, 0.0, 200.0, 100.0); + let gaps = GapConfig { + inner: 0.0, + outer: 0.0, + smart: false, + }; + let a = WindowId(1); + let b = WindowId(2); + let c = WindowId(3); + for strategy in [ + ViewportFocusStrategy::Minimal, + ViewportFocusStrategy::Center, + ViewportFocusStrategy::Paged, + ] { + let mut layout = ScrollingLayout::with_default_width(ColumnWidth::Fixed(100.0)); + layout.set_focus_strategy(strategy); + layout.insert(a, None).unwrap(); + layout.insert(b, Some(a)).unwrap(); + layout.insert(c, Some(b)).unwrap(); + layout + .geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(c)) + .unwrap(); + layout.set_column_width(b, ColumnWidth::Fixed(150.0)).unwrap(); + layout + .geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(c)) + .unwrap(); + assert!(layout.viewport_depends_on_width(b, 100.0, 150.0), "{strategy:?}"); + assert!( + !layout.viewport_depends_on_width(b, 150.0, 150.0), + "height-only size change" + ); + // An unrelated relayout does not erase the last calculation. + layout + .geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(c)) + .unwrap(); + assert!(layout.viewport_depends_on_width(b, 100.0, 150.0)); + layout.focus_and_reveal(a).unwrap(); + layout + .geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(a)) + .unwrap(); + assert!( + !layout.viewport_depends_on_width(b, 100.0, 150.0), + "independent reversal {strategy:?}" + ); + layout.center_window(b, bounds, gaps, &HashMap::new()).unwrap(); + assert!( + layout.viewport_depends_on_width(b, 100.0, 150.0), + "explicit centering {strategy:?}" + ); + assert!( + !layout.viewport_depends_on_width(b, 150.0, 100.0), + "obsolete width generation" + ); + } + let mut layout = ScrollingLayout::with_default_width(ColumnWidth::Fixed(100.0)); + layout.insert(a, None).unwrap(); + layout.insert(b, Some(a)).unwrap(); + layout.insert(c, Some(b)).unwrap(); + let constraints = HashMap::from([( + b, + SizeConstraints { + min_width: 150.0, + max_width: Some(40.0), + ..Default::default() + }, + )]); + let result = layout + .geometry_with_constraints(bounds, gaps, &constraints, Some(c)) + .unwrap(); + assert_eq!(layout.allocated_column_width(b), Some(150.0)); + assert_eq!(result.geometry[&b].width, 150.0, "minimum wins inconsistent maximum"); + assert!(layout.viewport_depends_on_width(b, 100.0, 150.0)); + } + #[test] fn inserts_new_columns_after_focus() { let mut layout = ScrollingLayout::default(); diff --git a/docs/nested-resize-testing.md b/docs/nested-resize-testing.md index baa928c..277d1e7 100644 --- a/docs/nested-resize-testing.md +++ b/docs/nested-resize-testing.md @@ -34,3 +34,35 @@ up to about 200 ms. Repeat on the current build. A nested result does not establish direct-session resize or frame-pacing behavior. + +## Controlled slow client + +Build the compositor and control tool, then run on a Wayland host: + +```sh +cargo build --release --locked -p ferese -p feresectl +FERESE_TEST_RESIZE=1 FERESE_TEST_BINARY=target/release/ferese \ + FERESE_TEST_CTL=target/release/feresectl \ + python3 scripts/tests/test_resize_dependencies_isolated.py +``` + +The test creates a private nested desktop and two SHM clients. One acknowledges +a resize immediately but withholds its new buffer until instructed. It checks +that an independent first-column viewport target moves both windows together +before the 300 ms deadline, while the slow client's raster and presented size +stay held, then commits an actually resized raster. This is a state/coordinate +check on the selected host renderer, not a smoothness or hardware benchmark. + +For deterministic commit, supersession, multiple-client, timeout, unmap, +workspace, swipe-cancel, stacked-column, prediction, hit-testing, snapshot and +capture privacy coverage without a host desktop: + +```sh +cargo test --locked -p ferese resize_dependencies_protocol_and_pixels -- --ignored +``` + +That test requires an offscreen EGL device, including a software device. Layout +and dependency unit tests run without EGL in the ordinary workspace test suite. +Viewport dependencies retain at most 32 relative target recipes; a longer burst +falls back to a conservative linked wait until commit, deadline, or an absolute +retarget. Existing resize snapshots and their handoff lifetime are reused. diff --git a/scripts/tests/fixtures/slow-resize.c b/scripts/tests/fixtures/slow-resize.c new file mode 100644 index 0000000..75f7a48 --- /dev/null +++ b/scripts/tests/fixtures/slow-resize.c @@ -0,0 +1,122 @@ +/* Controlled resize client: acknowledge immediately, attach only on command. + * Sync markers mean the server has processed the preceding ack/commit. */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include "xdg-shell-client-protocol.h" + +static struct wl_display *display; +static struct wl_compositor *compositor; +static struct wl_shm *shm; +static struct xdg_wm_base *wm; +static struct wl_surface *surface; +static struct xdg_surface *xdg; +static struct wl_buffer *buffer; +static int width = 64, height = 64, holding; +static uint32_t serial; + +struct marker { const char *kind; uint32_t serial; int width, height; }; +static void done(void *data, struct wl_callback *callback, uint32_t time) { + (void)time; + struct marker *m = data; + printf("%s %u %d %d\n", m->kind, m->serial, m->width, m->height); + fflush(stdout); + wl_callback_destroy(callback); + free(m); +} +static const struct wl_callback_listener sync_listener = {.done = done}; +static void mark(const char *kind) { + struct marker *m = malloc(sizeof(*m)); + if (!m) exit(3); + *m = (struct marker){kind, serial, width, height}; + wl_callback_add_listener(wl_display_sync(display), &sync_listener, m); +} +static void attach(void) { + size_t bytes = (size_t)width * height * 4; + int fd = memfd_create("slow-resize", MFD_CLOEXEC); + if (fd < 0 || ftruncate(fd, bytes)) exit(3); + uint32_t *pixels = mmap(NULL, bytes, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + if (pixels == MAP_FAILED) exit(4); + for (size_t i = 0; i < bytes / 4; i++) pixels[i] = 0xff3182ce; + struct wl_shm_pool *pool = wl_shm_create_pool(shm, fd, bytes); + struct wl_buffer *next = wl_shm_pool_create_buffer(pool, 0, width, height, + width * 4, WL_SHM_FORMAT_XRGB8888); + wl_shm_pool_destroy(pool); + munmap(pixels, bytes); + close(fd); + if (buffer) wl_buffer_destroy(buffer); + buffer = next; + xdg_surface_set_window_geometry(xdg, 0, 0, width, height); + wl_surface_attach(surface, buffer, 0, 0); + wl_surface_damage(surface, 0, 0, width, height); + wl_surface_commit(surface); + mark("committed"); +} +static void configure(void *data, struct xdg_surface *shell, uint32_t value) { + (void)data; + serial = value; + xdg_surface_ack_configure(shell, serial); + if (holding) mark("held"); else attach(); +} +static const struct xdg_surface_listener surface_listener = {.configure = configure}; +static void size(void *data, struct xdg_toplevel *top, int32_t w, int32_t h, struct wl_array *states) { + (void)data; (void)top; (void)states; + if (w > 0) width = w; + if (h > 0) height = h; +} +static void close_window(void *data, struct xdg_toplevel *top) { + (void)data; (void)top; exit(0); +} +static const struct xdg_toplevel_listener top_listener = {.configure = size, .close = close_window}; +static void ping(void *data, struct xdg_wm_base *base, uint32_t value) { + (void)data; xdg_wm_base_pong(base, value); +} +static const struct xdg_wm_base_listener wm_listener = {.ping = ping}; +static void global(void *data, struct wl_registry *registry, uint32_t name, const char *interface, uint32_t version) { + (void)data; (void)version; + if (!strcmp(interface, "wl_compositor")) compositor = wl_registry_bind(registry, name, &wl_compositor_interface, 4); + else if (!strcmp(interface, "wl_shm")) shm = wl_registry_bind(registry, name, &wl_shm_interface, 1); + else if (!strcmp(interface, "xdg_wm_base")) { + wm = wl_registry_bind(registry, name, &xdg_wm_base_interface, 1); + xdg_wm_base_add_listener(wm, &wm_listener, NULL); + } +} +static void removed(void *data, struct wl_registry *registry, uint32_t name) { + (void)data; (void)registry; (void)name; +} +static const struct wl_registry_listener registry_listener = {global, removed}; +int main(int argc, char **argv) { + display = wl_display_connect(NULL); + if (!display) return 1; + wl_registry_add_listener(wl_display_get_registry(display), ®istry_listener, NULL); + if (wl_display_roundtrip(display) < 0 || !compositor || !shm || !wm) return 2; + surface = wl_compositor_create_surface(compositor); + xdg = xdg_wm_base_get_xdg_surface(wm, surface); + xdg_surface_add_listener(xdg, &surface_listener, NULL); + struct xdg_toplevel *top = xdg_surface_get_toplevel(xdg); + xdg_toplevel_add_listener(top, &top_listener, NULL); + xdg_toplevel_set_app_id(top, argc > 1 ? argv[1] : "ferese.test.slow-resize"); + xdg_toplevel_set_title(top, "Controlled slow resize"); + wl_surface_commit(surface); + for (;;) { + if (wl_display_dispatch_pending(display) < 0 || wl_display_flush(display) < 0) return 5; + struct pollfd fds[] = {{wl_display_get_fd(display), POLLIN, 0}, {STDIN_FILENO, POLLIN, 0}}; + if (poll(fds, 2, -1) < 0) return 6; + if (fds[0].revents && wl_display_dispatch(display) < 0) return 5; + if (fds[1].revents) { + char command; + if (read(STDIN_FILENO, &command, 1) != 1 || command == 'q') break; + if (command == 'h') { holding = 1; mark("holding"); } + if (command == 'c') attach(); + } + } + if (buffer) wl_buffer_destroy(buffer); + wl_display_disconnect(display); + return 0; +} diff --git a/scripts/tests/test_resize_dependencies_isolated.py b/scripts/tests/test_resize_dependencies_isolated.py new file mode 100644 index 0000000..8e91888 --- /dev/null +++ b/scripts/tests/test_resize_dependencies_isolated.py @@ -0,0 +1,158 @@ +"""Controlled ack/commit resize test on a disposable nested desktop. + +FERESE_TEST_RESIZE=1 FERESE_TEST_BINARY=target/release/ferese \ +FERESE_TEST_CTL=target/release/feresectl python3 scripts/tests/test_resize_dependencies_isolated.py +Requires a Wayland host, cc, pkg-config and wayland-scanner. This checks state +before the 300 ms deadline; it does not measure frame pacing or GPU performance. +""" +import json +import os +from pathlib import Path +import select +import subprocess +import tempfile +import time +import unittest + +REPO = Path(__file__).resolve().parents[2] + + +@unittest.skipUnless(os.environ.get("FERESE_TEST_RESIZE") == "1", "opt-in nested test") +class ResizeDependencies(unittest.TestCase): + def test_independent_translation_before_resized_buffer(self): + processes = [] + with tempfile.TemporaryDirectory(prefix="ferese-resize-") as directory: + root = Path(directory) + protocols = Path(subprocess.check_output( + ["pkg-config", "--variable=pkgdatadir", "wayland-protocols"], text=True).strip()) + xml = str(protocols / "stable/xdg-shell/xdg-shell.xml") + for kind, name in [("client-header", "xdg-shell-client-protocol.h"), + ("private-code", "xdg-shell-protocol.c")]: + subprocess.run(["wayland-scanner", kind, xml, str(root / name)], check=True) + flags = subprocess.check_output(["pkg-config", "--cflags", "--libs", "wayland-client"], text=True).split() + subprocess.run(["cc", "-Wall", "-Wextra", "-Werror", "-I", str(root), + str(REPO / "scripts/tests/fixtures/slow-resize.c"), + str(root / "xdg-shell-protocol.c"), "-o", str(root / "client"), *flags], check=True) + runtime = root / "runtime" + runtime.mkdir(mode=0o700) + config = root / "config/ferese/config.kdl" + config.parent.mkdir(parents=True) + config.write_text('layout { inner-gap 0; outer-gap 0; }\n' + 'theme { geometry { window-radius 0; border-width 0; focus-ring-width 0; }; }\n') + host = Path(os.environ["WAYLAND_DISPLAY"]) + if not host.is_absolute(): + host = Path(os.environ["XDG_RUNTIME_DIR"]) / host + env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"), + XDG_STATE_HOME=str(root / "state"), WAYLAND_DISPLAY=str(host)) + for key in ["WAYLAND_SOCKET", "FERESE_SOCKET", "FERESE_SHELL_CONTROL_SOCKET"]: + env.pop(key, None) + binary = REPO / os.environ.get("FERESE_TEST_BINARY", "target/debug/ferese") + ctl = REPO / os.environ.get("FERESE_TEST_CTL", "target/debug/feresectl") + with (root / "compositor.log").open("w") as log: + try: + compositor = subprocess.Popen([str(binary), "--backend=nested"], env=env, stdout=log, stderr=log) + processes.append(compositor) + deadline = time.monotonic() + 10 + while not (runtime / "ferese/control.sock").exists(): + if compositor.poll() is not None or time.monotonic() > deadline: + self.fail((root / "compositor.log").read_text()) + time.sleep(.02) + socket = next(p for p in runtime.glob("wayland-*") if not p.name.endswith(".lock")) + client_env = dict(env, WAYLAND_DISPLAY=str(socket)) + + def call(*args): + return json.loads(subprocess.check_output([str(ctl), *args], env=env, timeout=5)) + + def action(*args): + subprocess.run([str(ctl), *args], env=env, check=True, capture_output=True, timeout=5) + + def client(name): + process = subprocess.Popen([str(root / "client"), name], env=client_env, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=log, bufsize=0) + processes.append(process) + return process + + pending = {} + + def marker(process, kind, timeout=5): + deadline = time.monotonic() + timeout + data = pending.pop(process.pid, b"") + while True: + while b"\n" in data: + line, data = data.split(b"\n", 1) + fields = line.decode().split() + if fields[0] == kind: + pending[process.pid] = data + return list(map(int, fields[1:])) + remaining = deadline - time.monotonic() + self.assertGreater(remaining, 0, f"missing {kind} marker") + ready, _, _ = select.select([process.stdout], [], [], remaining) + self.assertTrue(ready, f"missing {kind} marker") + chunk = os.read(process.stdout.fileno(), 4096) + self.assertTrue(chunk, (root / "compositor.log").read_text()) + data += chunk + + def windows(count): + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + current = call("get-windows") + if len(current) == count and all(w["mapped"] for w in current): + return {w["app_id"]: w for w in current} + time.sleep(.02) + self.fail((root / "compositor.log").read_text()) + + first = client("ferese.test.first") + marker(first, "committed") + windows(1) + slow = client("ferese.test.slow") + marker(slow, "committed") + windows(2) + # Settle opening/focus motion before isolating one configure. + time.sleep(1) + slow.stdin.write(b"h") + marker(slow, "holding") + before = windows(2) + original = before["ferese.test.slow"] + started = time.monotonic() + action("resize", "right") + configured = marker(slow, "held", timeout=.25) + self.assertGreater(configured[1], original["capture_width"]) + # The ack has reached the compositor; no new buffer exists. + action("focus", "left") + action("center-column") + moved = None + while time.monotonic() - started < .25: + current = windows(2) + observed_elapsed = time.monotonic() - started + if observed_elapsed >= .25: + break + a, b = current["ferese.test.first"], current["ferese.test.slow"] + if abs(a["x"] - before["ferese.test.first"]["x"]) >= 2: + moved = current + break + time.sleep(.005) + self.assertIsNotNone(moved, "viewport did not move before the resize deadline") + a, b = moved["ferese.test.first"], moved["ferese.test.slow"] + self.assertEqual(b["capture_width"], original["capture_width"]) + self.assertEqual(b["width"], original["width"]) + self.assertAlmostEqual(a["x"] - before["ferese.test.first"]["x"], + b["x"] - original["x"], delta=1) + slow.stdin.write(b"c") + committed = marker(slow, "committed") + self.assertEqual(committed[1:], configured[1:]) + self.assertEqual(windows(2)["ferese.test.slow"]["capture_width"], configured[1]) + print(f"nested: movement observed at {observed_elapsed * 1000:.1f} ms; " + "held raster/size; resized SHM commit (state check, not frame pacing)") + finally: + for process in reversed(processes): + if process.poll() is None: + process.terminate() + process.wait(timeout=5) + for stream in [process.stdin, process.stdout]: + if stream is not None: + stream.close() + + +if __name__ == "__main__": + unittest.main()