From 3540cdc4be98133cd02a922e334af25bd548ba9c Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 15:20:46 +1000 Subject: [PATCH 01/47] Add linear tiled window animations --- docs/window-animations-plan.md | 200 ++++++++++++ jay-config/src/_private/client.rs | 12 + jay-config/src/_private/ipc.rs | 9 + jay-config/src/lib.rs | 33 ++ src/animation.rs | 315 +++++++++++++++++++ src/compositor.rs | 4 + src/config/handler.rs | 126 +++++--- src/main.rs | 1 + src/renderer.rs | 52 ++- src/state.rs | 100 +++++- src/tree/container.rs | 12 + src/tree/toplevel.rs | 17 + toml-config/src/config.rs | 8 + toml-config/src/config/parsers.rs | 1 + toml-config/src/config/parsers/animations.rs | 50 +++ toml-config/src/config/parsers/config.rs | 15 +- toml-config/src/lib.rs | 22 +- 17 files changed, 913 insertions(+), 64 deletions(-) create mode 100644 docs/window-animations-plan.md create mode 100644 src/animation.rs create mode 100644 toml-config/src/config/parsers/animations.rs diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md new file mode 100644 index 00000000..1c99c60e --- /dev/null +++ b/docs/window-animations-plan.md @@ -0,0 +1,200 @@ +# Window Animations Plan + +This document is the working plan for Wry's window animation system. It records +the decisions already made, the implementation phases, and the risks that must +be handled deliberately. + +## Accepted Decisions + +- The first landed slice is linear interpolation only, disabled by default. +- Animation is presentation-only. Logical layout, input hit testing, focus, and + Wayland configure state use final geometry immediately. +- Pointer drag and resize initiated by the mouse or tablet do not animate. +- Linear animations restart only for windows whose destination changes. Other + in-flight windows keep their existing timelines. +- Spawn-in uses scale and position. Spawn-out requires retained visual content + and is deferred until the freezing layer exists. +- Command-driven tile-to-float and float-to-tile transitions may animate. + Protocol drag/drop paths do not. +- The no-overlap multiphase system is a separate phase after the linear path is + working and testable. +- Content freezing will use retained per-surface texture references, not a full + offscreen snapshot as the default design. +- The multiphase animation concept is original to Wry. Hy3 is relevant only as + partial inspiration for tiling style and titlebar/grouping behavior. +- Mono mode should mostly avoid animations. Exceptions are windows entering or + exiting mono mode, where a visual transition can clarify the hierarchy change. + +## Texture Freezing Decision + +The approved freezing design is to capture a renderable surface tree at animation +start: + +- texture references +- source sample rects +- target sizes +- alpha and color metadata +- subsurface offsets and stacking order +- enough synchronization/release state to keep referenced buffers alive safely + +This is lighter than rendering every toplevel into a compositor-owned offscreen +texture, and it should handle normal GPU-backed windows without an extra full +window copy. It also gives spawn-out a path: capture the surface tree before the +toplevel is logically destroyed, then animate the retained records after the live +node is gone. + +Tradeoffs: + +- Retained references can delay buffer release. For dmabuf clients this can + increase memory pressure or throttle clients if many large windows animate. +- SHM buffers still matter for simple clients, fallback paths, some utilities, + and cursor-like surfaces. They are probably not the common case for large app + windows, but the implementation must still treat SHM texture flipping as a + correctness issue. +- The release/sync contract must be explicit. A retained texture must not be + released back to the client while the compositor may still render it. +- True offscreen snapshots remain a possible fallback for cases where retained + references cannot safely preserve the rendered content. + +## Phase 1: Linear Presentation Animations + +Goal: add the smallest correct animation layer without changing layout semantics. + +Implementation shape: + +- Add animation state owned by `State`. +- Track per-toplevel animation entries keyed by `NodeId`. +- Store logical target rect, current presentation rect, previous damaged rect, + start time, duration, and curve. +- On command-driven tiled layout geometry changes, animate from current + presentation rect to new final rect. +- On interruption, restart only the affected window from its current + presentation rect. +- Drive frames from the existing output latch/presentation event flow. +- Damage the union of previous presentation rect, current presentation rect, and + final logical rect. + +Initial scope: + +- Tiled reflow animation. +- Floating command-driven moves, tile-to-float, float-to-tile, and spawn-in are + deferred until after tiled reflow is validated. +- Cross-output and cross-scale movements snap for now. +- Linear mode may overlap windows during swaps. That is expected for the classic + interpolation mode; no-overlap is Phase 3. +- Live client buffers are rendered in Phase 1. Retained content freezing is + deferred, but animated windows must still be clipped to their presentation + bounds and must preserve the existing stretch behavior for undersized contents. +- No spawn-out. +- No content freezing. +- No multiphase no-overlap planner. + +Tests: + +- rect interpolation is direction-independent +- interruption restarts only changed windows +- unchanged in-flight windows keep their original timeline +- drag-driven floating movement bypasses animation +- damage includes old, current, and final rects + +## Phase 2: Retained Texture Freezing + +Goal: freeze visual contents during movement and enable spawn-out. + +Implementation shape: + +- Add a retained render-record tree for toplevel surfaces. +- Capture records before movement animations that require freezing. +- Capture records before destroy/unmap for spawn-out. +- Render retained records through the normal renderer primitives where possible. +- Extend event/sync handling so retained buffers remain valid until the animation + is complete. + +Open design work: + +- Exact lifetime model for retained `SurfaceBuffer` / `GfxTexture` references. +- Whether retained records participate in frame callbacks or presentation + feedback. Default assumption: no, because they are compositor animation frames, + not client commits. +- How to fall back when a buffer cannot be safely retained. + +## Phase 3: Multiphase No-Overlap Animations + +Goal: implement Wry's staged no-overlap planner while preserving the rule that +windows never overlap. + +Core rules: + +- Each phase is a discrete animation using the full curve. +- A phase performs only one action kind per window: move or scale. +- Movement and scaling are split by axis. +- No diagonal motion. +- A window or synchronized group owns its own timeline. +- New layout changes interrupt only windows/groups with changed destinations. +- Current hierarchy and target hierarchy both matter. The planner must know + whether a window is ascending toward a higher-level/toplevel position, + descending into a container, or moving between containers at the same depth. +- If some child windows require fewer phases than their parent/container + context, parent/container-space changes generally happen first so space exists + before the child moves into it. This rule can be overridden only when the + non-overlap invariant still clearly holds. +- Windows that become peers in the target hierarchy may synchronize later + phases even if they were not peers in the source hierarchy. + +Important parent/child synchronization issue: + +The planner must not let a parent container and child window animate independent +axes at the same time in a way that violates the visual rules. For example, a +parent scaling horizontally while a child scales vertically can accidentally +produce a diagonal or multi-axis motion in screen space. + +Preferred approach: + +- Plan in terms of leaf toplevel visual rectangles first. +- Treat containers as constraints and grouping boundaries, not as independently + animated visual actors. +- Derive every leaf's per-phase rect from one phase schedule so parent and child + effects cannot compose into forbidden motion. +- Add container-level grouping only after the leaf planner proves correct. +- Include hierarchy-transition metadata in the planner input: source parent, + target parent, source depth, target depth, and whether the window is ascending, + descending, or staying at the same hierarchy level. +- For mono containers, suppress ordinary in-mono focus/tab changes. Animate only + transitions into mono, out of mono, or across the mono boundary. + +Tests: + +- horizontal swaps shrink, move, then grow without overlap +- extraction from a stack creates space before moving the extracted window +- nested containers do not produce simultaneous cross-axis motion +- interruption restarts only affected phase groups +- reversing direction produces equivalent motion in reverse +- child waits for parent/container-space phases when moving upward into a + toplevel peer position +- mono-mode tab switches do not animate, while entering/exiting mono can animate + +## Configuration + +Phase 1 should expose a disabled-by-default setting for: + +- enabled/disabled +- duration +- curve preset or cubic bezier + +Initial TOML shape: + +```toml +[animations] +enabled = false +duration-ms = 160 +curve = "ease-out" +``` + +Bezier curves should be analyzed at configuration time and stored in a form that +is cheap to evaluate during rendering. + +## Existing Note + +`docs/animation-integration.md` appears to document a prior animation attempt +whose `src/animation/` implementation is not present in this checkout. Treat +this plan as the current source of truth until implementation docs are updated. diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index 8ef87476..721b5097 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -1023,6 +1023,18 @@ impl ConfigClient { self.send(&ClientMessage::SetUiDragThreshold { threshold }); } + pub fn set_animations_enabled(&self, enabled: bool) { + self.send(&ClientMessage::SetAnimationsEnabled { enabled }); + } + + pub fn set_animation_duration_ms(&self, duration_ms: u32) { + self.send(&ClientMessage::SetAnimationDurationMs { duration_ms }); + } + + pub fn set_animation_curve(&self, curve: u32) { + self.send(&ClientMessage::SetAnimationCurve { curve }); + } + pub fn set_color_management_enabled(&self, enabled: bool) { self.send(&ClientMessage::SetColorManagementEnabled { enabled }); } diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index acb5ad81..d090ba0c 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -545,6 +545,15 @@ pub enum ClientMessage<'a> { SetUiDragThreshold { threshold: i32, }, + SetAnimationsEnabled { + enabled: bool, + }, + SetAnimationDurationMs { + duration_ms: u32, + }, + SetAnimationCurve { + curve: u32, + }, SetXScalingMode { mode: XScalingMode, }, diff --git a/jay-config/src/lib.rs b/jay-config/src/lib.rs index e25710f9..44546ce0 100644 --- a/jay-config/src/lib.rs +++ b/jay-config/src/lib.rs @@ -103,6 +103,18 @@ impl Axis { } } +/// The curve used for tiled window animations. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct AnimationCurve(pub u32); + +impl AnimationCurve { + pub const LINEAR: Self = Self(0); + pub const EASE: Self = Self(1); + pub const EASE_IN: Self = Self(2); + pub const EASE_OUT: Self = Self(3); + pub const EASE_IN_OUT: Self = Self(4); +} + /// Exits the compositor. pub fn quit() { get!().quit() @@ -287,6 +299,27 @@ pub fn set_ui_drag_threshold(threshold: i32) { get!().set_ui_drag_threshold(threshold); } +/// Enables or disables tiled window animations. +/// +/// The default is `false`. +pub fn set_animations_enabled(enabled: bool) { + get!().set_animations_enabled(enabled); +} + +/// Sets the duration of tiled window animations in milliseconds. +/// +/// The default is `160`. +pub fn set_animation_duration_ms(duration_ms: u32) { + get!().set_animation_duration_ms(duration_ms); +} + +/// Sets the curve used by tiled window animations. +/// +/// The default is [`AnimationCurve::EASE_OUT`]. +pub fn set_animation_curve(curve: AnimationCurve) { + get!().set_animation_curve(curve.0); +} + /// Enables or disables the color-management protocol. /// /// The default is `false`. diff --git a/src/animation.rs b/src/animation.rs new file mode 100644 index 00000000..b0091933 --- /dev/null +++ b/src/animation.rs @@ -0,0 +1,315 @@ +use { + crate::{ + rect::Rect, + state::State, + tree::{LatchListener, NodeId, OutputNode}, + utils::{clonecell::CloneCell, event_listener::EventListener}, + }, + ahash::AHashMap, + std::{ + cell::{Cell, RefCell}, + rc::{Rc, Weak}, + }, +}; + +const DEFAULT_DURATION_MS: u32 = 160; + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum AnimationCurve { + Linear, + Ease, + EaseIn, + EaseOut, + EaseInOut, +} + +impl AnimationCurve { + pub fn from_config(value: u32) -> Self { + match value { + 0 => Self::Linear, + 1 => Self::Ease, + 2 => Self::EaseIn, + 4 => Self::EaseInOut, + _ => Self::EaseOut, + } + } + + fn sample(self, t: f64) -> f64 { + let t = t.clamp(0.0, 1.0); + match self { + Self::Linear => t, + Self::Ease => cubic_bezier(0.25, 0.1, 0.25, 1.0, t), + Self::EaseIn => cubic_bezier(0.42, 0.0, 1.0, 1.0, t), + Self::EaseOut => cubic_bezier(0.0, 0.0, 0.58, 1.0, t), + Self::EaseInOut => cubic_bezier(0.42, 0.0, 0.58, 1.0, t), + } + } +} + +pub struct AnimationState { + pub enabled: Cell, + pub duration_ms: Cell, + pub curve: Cell, + windows: RefCell>, + tick: CloneCell>>, +} + +impl Default for AnimationState { + fn default() -> Self { + Self { + enabled: Cell::new(false), + duration_ms: Cell::new(DEFAULT_DURATION_MS), + curve: Cell::new(AnimationCurve::EaseOut), + windows: Default::default(), + tick: Default::default(), + } + } +} + +impl AnimationState { + pub fn clear(&self) { + self.windows.borrow_mut().clear(); + if let Some(tick) = self.tick.take() { + tick.detach(); + } + } + + pub fn set_target( + &self, + node_id: NodeId, + old: Rect, + new: Rect, + now_nsec: u64, + duration_ms: u32, + curve: AnimationCurve, + ) -> bool { + if old == new || old.is_empty() || new.is_empty() || duration_ms == 0 { + self.windows.borrow_mut().remove(&node_id); + return false; + } + let duration_nsec = duration_ms as u64 * 1_000_000; + let mut windows = self.windows.borrow_mut(); + let from = match windows.get(&node_id) { + Some(anim) if anim.to == new => return false, + Some(anim) => anim.rect_at(now_nsec), + None => old, + }; + if from == new { + windows.remove(&node_id); + return false; + } + windows.insert( + node_id, + WindowAnimation { + from, + to: new, + start_nsec: now_nsec, + duration_nsec, + curve, + last_damage: from, + }, + ); + true + } + + pub fn visual_rect(&self, node_id: NodeId, layout: Rect, now_nsec: u64) -> Rect { + let windows = self.windows.borrow(); + match windows.get(&node_id) { + Some(anim) if !anim.done(now_nsec) => anim.rect_at(now_nsec), + _ => layout, + } + } + + fn damage_active(&self, state: &State, now_nsec: u64) -> bool { + let mut damages = vec![]; + let mut any_active = false; + { + let mut windows = self.windows.borrow_mut(); + windows.retain(|_, anim| { + let current = anim.rect_at(now_nsec); + let damage = anim.last_damage.union(current).union(anim.to); + damages.push(expand_damage_rect( + damage, + state.theme.sizes.border_width.get().max(0), + )); + anim.last_damage = current; + let active = !anim.done(now_nsec); + any_active |= active; + active + }); + } + for damage in damages { + state.damage(damage); + } + any_active + } + + pub(crate) fn tick_is_active(&self) -> bool { + self.tick.is_some() + } + + pub(crate) fn set_tick(&self, tick: Rc) { + self.tick.set(Some(tick)); + } + + pub(crate) fn clear_tick(&self) { + self.tick.take(); + } +} + +struct WindowAnimation { + from: Rect, + to: Rect, + start_nsec: u64, + duration_nsec: u64, + curve: AnimationCurve, + last_damage: Rect, +} + +impl WindowAnimation { + fn done(&self, now_nsec: u64) -> bool { + now_nsec.saturating_sub(self.start_nsec) >= self.duration_nsec + } + + fn rect_at(&self, now_nsec: u64) -> Rect { + if self.duration_nsec == 0 { + return self.to; + } + let elapsed = now_nsec.saturating_sub(self.start_nsec); + let t = (elapsed as f64 / self.duration_nsec as f64).clamp(0.0, 1.0); + let t = self.curve.sample(t); + lerp_rect(self.from, self.to, t) + } +} + +pub struct AnimationTick { + state: Weak, + slf: Weak, + latch_listeners: RefCell>>, +} + +impl AnimationTick { + pub fn new(state: &Rc, slf: &Weak) -> Self { + let slf: Weak = slf.clone(); + Self { + state: Rc::downgrade(state), + slf, + latch_listeners: Default::default(), + } + } + + pub fn attach(&self, output: &OutputNode) { + let listener = EventListener::new(self.slf.clone()); + listener.attach(&output.latch_event); + self.latch_listeners.borrow_mut().push(listener); + } + + pub fn detach(&self) { + for listener in self.latch_listeners.borrow_mut().drain(..) { + listener.detach(); + } + } +} + +impl LatchListener for AnimationTick { + fn after_latch(self: Rc, _on: &OutputNode, _tearing: bool) { + let Some(state) = self.state.upgrade() else { + self.detach(); + return; + }; + let active = state.animations.damage_active(&state, state.now_nsec()); + if !active { + self.detach(); + state.animations.clear_tick(); + } + } +} + +fn lerp_rect(from: Rect, to: Rect, t: f64) -> Rect { + fn lerp(from: i32, to: i32, t: f64) -> i32 { + (from as f64 + (to as f64 - from as f64) * t).round() as i32 + } + Rect::new_saturating( + lerp(from.x1(), to.x1(), t), + lerp(from.y1(), to.y1(), t), + lerp(from.x2(), to.x2(), t), + lerp(from.y2(), to.y2(), t), + ) +} + +pub(crate) fn expand_damage_rect(rect: Rect, width: i32) -> Rect { + Rect::new_saturating( + rect.x1().saturating_sub(width), + rect.y1().saturating_sub(width), + rect.x2().saturating_add(width), + rect.y2().saturating_add(width), + ) +} + +fn cubic_bezier(x1: f64, y1: f64, x2: f64, y2: f64, x: f64) -> f64 { + fn bezier(a: f64, b: f64, t: f64) -> f64 { + let inv = 1.0 - t; + 3.0 * inv * inv * t * a + 3.0 * inv * t * t * b + t * t * t + } + let mut lo = 0.0; + let mut hi = 1.0; + let mut t = x; + for _ in 0..12 { + let bx = bezier(x1, x2, t); + if (bx - x).abs() < 0.000_001 { + break; + } + if bx < x { + lo = t; + } else { + hi = t; + } + t = (lo + hi) * 0.5; + } + bezier(y1, y2, t) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn linear_rect_interpolation_is_symmetric() { + let a = Rect::new_sized_saturating(0, 0, 100, 100); + let b = Rect::new_sized_saturating(100, 40, 200, 80); + assert_eq!(lerp_rect(a, b, 0.25), lerp_rect(b, a, 0.75)); + } + + #[test] + fn unchanged_target_does_not_restart() { + let state = AnimationState::default(); + let id = NodeId(1); + let a = Rect::new_sized_saturating(0, 0, 100, 100); + let b = Rect::new_sized_saturating(100, 0, 100, 100); + assert!(state.set_target(id, a, b, 0, 160, AnimationCurve::Linear)); + assert!(!state.set_target(id, a, b, 80_000_000, 160, AnimationCurve::Linear)); + assert_eq!( + state.visual_rect(id, b, 80_000_000), + Rect::new_sized_saturating(50, 0, 100, 100) + ); + } + + #[test] + fn changed_target_restarts_from_current_visual_rect() { + let state = AnimationState::default(); + let id = NodeId(1); + let a = Rect::new_sized_saturating(0, 0, 100, 100); + let b = Rect::new_sized_saturating(100, 0, 100, 100); + let c = Rect::new_sized_saturating(200, 0, 100, 100); + assert!(state.set_target(id, a, b, 0, 160, AnimationCurve::Linear)); + assert!(state.set_target(id, a, c, 80_000_000, 160, AnimationCurve::Linear)); + assert_eq!( + state.visual_rect(id, c, 80_000_000), + Rect::new_sized_saturating(50, 0, 100, 100) + ); + assert_eq!( + state.visual_rect(id, c, 160_000_000), + Rect::new_sized_saturating(125, 0, 100, 100) + ); + } +} diff --git a/src/compositor.rs b/src/compositor.rs index 45d2a018..d8fb4027 100644 --- a/src/compositor.rs +++ b/src/compositor.rs @@ -360,6 +360,10 @@ fn start_compositor2( cpu_worker, ui_drag_enabled: Cell::new(true), ui_drag_threshold_squared: Cell::new(10), + animations: Default::default(), + layout_animations_requested: Default::default(), + layout_animations_active: Default::default(), + suppress_animations_for_next_layout: Default::default(), toplevels: Default::default(), const_40hz_latch: Default::default(), tray_item_ids: Default::default(), diff --git a/src/config/handler.rs b/src/config/handler.rs index 526c1cde..0e9436c5 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -658,17 +658,21 @@ impl ConfigProxyHandler { } fn handle_seat_move(&self, seat: Seat, direction: Direction) -> Result<(), CphError> { - let seat = self.get_seat(seat)?; - seat.move_focused(direction.into()); - Ok(()) + self.state.with_layout_animations(|| { + let seat = self.get_seat(seat)?; + seat.move_focused(direction.into()); + Ok(()) + }) } fn handle_window_move(&self, window: Window, direction: Direction) -> Result<(), CphError> { - let window = self.get_window(window)?; - if let Some(c) = toplevel_parent_container(&*window) { - c.move_child(window, direction.into()); - } - Ok(()) + self.state.with_layout_animations(|| { + let window = self.get_window(window)?; + if let Some(c) = toplevel_parent_container(&*window) { + c.move_child(window, direction.into()); + } + Ok(()) + }) } fn handle_get_repeat_rate(&self, seat: Seat) -> Result<(), CphError> { @@ -986,6 +990,19 @@ impl ConfigProxyHandler { self.state.set_ui_drag_threshold(threshold.max(1)); } + fn handle_set_animations_enabled(&self, enabled: bool) { + self.state.set_animations_enabled(enabled); + } + + fn handle_set_animation_duration_ms(&self, duration_ms: u32) { + self.state + .set_animation_duration_ms(duration_ms.min(10_000)); + } + + fn handle_set_animation_curve(&self, curve: u32) { + self.state.set_animation_curve(curve); + } + fn handle_set_direct_scanout_enabled( &self, device: Option, @@ -1724,9 +1741,11 @@ impl ConfigProxyHandler { } fn handle_set_seat_mono(&self, seat: Seat, mono: bool) -> Result<(), CphError> { - let seat = self.get_seat(seat)?; - seat.set_mono(mono); - Ok(()) + self.state.with_layout_animations(|| { + let seat = self.get_seat(seat)?; + seat.set_mono(mono); + Ok(()) + }) } fn handle_get_window_mono(&self, window: Window) -> Result<(), CphError> { @@ -1740,11 +1759,13 @@ impl ConfigProxyHandler { } fn handle_set_window_mono(&self, window: Window, mono: bool) -> Result<(), CphError> { - let window = self.get_window(window)?; - if let Some(c) = toplevel_parent_container(&*window) { - c.set_mono(mono.then_some(window.as_ref())); - } - Ok(()) + self.state.with_layout_animations(|| { + let window = self.get_window(window)?; + if let Some(c) = toplevel_parent_container(&*window) { + c.set_mono(mono.then_some(window.as_ref())); + } + Ok(()) + }) } fn handle_get_seat_split(&self, seat: Seat) -> Result<(), CphError> { @@ -1759,15 +1780,19 @@ impl ConfigProxyHandler { } fn handle_set_seat_split(&self, seat: Seat, axis: Axis) -> Result<(), CphError> { - let seat = self.get_seat(seat)?; - seat.set_split(axis.into()); - Ok(()) + self.state.with_layout_animations(|| { + let seat = self.get_seat(seat)?; + seat.set_split(axis.into()); + Ok(()) + }) } fn handle_seat_toggle_tab(&self, seat: Seat) -> Result<(), CphError> { - let seat = self.get_seat(seat)?; - seat.toggle_tab(); - Ok(()) + self.state.with_layout_animations(|| { + let seat = self.get_seat(seat)?; + seat.toggle_tab(); + Ok(()) + }) } fn handle_seat_make_group( @@ -1776,27 +1801,35 @@ impl ConfigProxyHandler { axis: Axis, ephemeral: bool, ) -> Result<(), CphError> { - let seat = self.get_seat(seat)?; - seat.make_group(axis.into(), ephemeral); - Ok(()) + self.state.with_layout_animations(|| { + let seat = self.get_seat(seat)?; + seat.make_group(axis.into(), ephemeral); + Ok(()) + }) } fn handle_seat_change_group_opposite(&self, seat: Seat) -> Result<(), CphError> { - let seat = self.get_seat(seat)?; - seat.change_group_opposite(); - Ok(()) + self.state.with_layout_animations(|| { + let seat = self.get_seat(seat)?; + seat.change_group_opposite(); + Ok(()) + }) } fn handle_seat_equalize(&self, seat: Seat, recursive: bool) -> Result<(), CphError> { - let seat = self.get_seat(seat)?; - seat.equalize(recursive); - Ok(()) + self.state.with_layout_animations(|| { + let seat = self.get_seat(seat)?; + seat.equalize(recursive); + Ok(()) + }) } fn handle_seat_move_tab(&self, seat: Seat, right: bool) -> Result<(), CphError> { - let seat = self.get_seat(seat)?; - seat.move_tab(right); - Ok(()) + self.state.with_layout_animations(|| { + let seat = self.get_seat(seat)?; + seat.move_tab(right); + Ok(()) + }) } fn handle_get_window_split(&self, window: Window) -> Result<(), CphError> { @@ -1811,11 +1844,13 @@ impl ConfigProxyHandler { } fn handle_set_window_split(&self, window: Window, axis: Axis) -> Result<(), CphError> { - let window = self.get_window(window)?; - if let Some(c) = toplevel_parent_container(&*window) { - c.set_split(axis.into()); - } - Ok(()) + self.state.with_layout_animations(|| { + let window = self.get_window(window)?; + if let Some(c) = toplevel_parent_container(&*window) { + c.set_split(axis.into()); + } + Ok(()) + }) } fn handle_add_shortcut( @@ -2721,8 +2756,10 @@ impl ConfigProxyHandler { dx2: i32, dy2: i32, ) -> Result<(), CphError> { - self.get_window(window)?.tl_resize(dx1, dy1, dx2, dy2); - Ok(()) + self.state.with_layout_animations(|| { + self.get_window(window)?.tl_resize(dx1, dy1, dx2, dy2); + Ok(()) + }) } fn handle_window_exists(&self, window: Window) { @@ -3193,6 +3230,13 @@ impl ConfigProxyHandler { ClientMessage::SetUiDragThreshold { threshold } => { self.handle_set_ui_drag_threshold(threshold) } + ClientMessage::SetAnimationsEnabled { enabled } => { + self.handle_set_animations_enabled(enabled) + } + ClientMessage::SetAnimationDurationMs { duration_ms } => { + self.handle_set_animation_duration_ms(duration_ms) + } + ClientMessage::SetAnimationCurve { curve } => self.handle_set_animation_curve(curve), ClientMessage::SetXScalingMode { mode } => self .handle_set_x_scaling_mode(mode) .wrn("set_x_scaling_mode")?, diff --git a/src/main.rs b/src/main.rs index 5a566f9b..161d3d99 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,6 +48,7 @@ mod leaks; mod tracy; mod acceptor; mod allocator; +mod animation; mod async_engine; mod backend; mod backends; diff --git a/src/renderer.rs b/src/renderer.rs index e601a0e0..f8174883 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -15,7 +15,7 @@ use { theme::{Color, CornerRadius}, tree::{ ContainerNode, DisplayNode, FloatNode, OutputNode, PlaceholderNode, ToplevelData, - ToplevelNodeBase, WorkspaceNode, tab_bar::TabBar, + ToplevelNode, ToplevelNodeBase, WorkspaceNode, tab_bar::TabBar, }, }, std::{ops::Deref, rc::Rc, slice}, @@ -453,6 +453,20 @@ impl Renderer<'_> { .fill_boxes2(&rd.border_rects, &c, srgb, perceptual, x, y); } + fn presentation_child_body( + &self, + container: &ContainerNode, + child: &Rc, + body: Rect, + ) -> Rect { + let abs = body.move_(container.abs_x1.get(), container.abs_y1.get()); + let visual = self + .state + .animations + .visual_rect(child.node_id(), abs, self.state.now_nsec()); + visual.move_(-container.abs_x1.get(), -container.abs_y1.get()) + } + pub fn render_container(&mut self, container: &ContainerNode, x: i32, y: i32) { self.render_container_decorations(container, x, y); @@ -465,6 +479,7 @@ impl Renderer<'_> { } } let mb = container.mono_body.get(); + let visual_mb = self.presentation_child_body(container, &child.node, mb); if self.state.theme.sizes.gap.get() != 0 { let bw = self.state.theme.sizes.border_width.get(); let border_color = self.state.theme.colors.border.get(); @@ -476,10 +491,10 @@ impl Renderer<'_> { }; if !child.node.node_is_container() { let frame = Rect::new_sized_saturating( - mb.x1() - bw, - mb.y1() - bw, - mb.width() + 2 * bw, - mb.height() + 2 * bw, + visual_mb.x1() - bw, + visual_mb.y1() - bw, + visual_mb.width() + 2 * bw, + visual_mb.height() + 2 * bw, ); self.render_rounded_frame( frame, @@ -491,14 +506,18 @@ impl Renderer<'_> { ); } } - let body = mb.move_(x, y); + let body = visual_mb.move_(x, y); let body = self.base.scale_rect(body); - let content = container.mono_content.get(); - self.stretch = if content.width() != mb.width() || content.height() != mb.height() { - Some(self.base.scale_point(mb.width(), mb.height())) - } else { - None - }; + let content = container + .mono_content + .get() + .at_point(visual_mb.x1(), visual_mb.y1()); + self.stretch = + if content.width() != visual_mb.width() || content.height() != visual_mb.height() { + Some(self.base.scale_point(visual_mb.width(), visual_mb.height())) + } else { + None + }; if self.state.theme.sizes.gap.get() != 0 && !child.node.node_is_container() { let cr = self.state.theme.corner_radius.get(); if !cr.is_zero() { @@ -524,10 +543,13 @@ impl Renderer<'_> { }; let cr = self.state.theme.corner_radius.get(); for child in container.children.iter() { - let body = child.body.get(); - if body.x1() >= container.width.get() || body.y1() >= container.height.get() { + let layout_body = child.body.get(); + if layout_body.x1() >= container.width.get() + || layout_body.y1() >= container.height.get() + { break; } + let body = self.presentation_child_body(container, &child.node, layout_body); if gap != 0 { let c = if child.border_color_is_focused.get() { &focused_border_color @@ -544,7 +566,7 @@ impl Renderer<'_> { self.render_rounded_frame(frame, c, cr, bw, x, y); } } - let content = child.content.get(); + let content = child.content.get().at_point(body.x1(), body.y1()); self.stretch = if content.width() != body.width() || content.height() != body.height() { Some(self.base.scale_point(body.width(), body.height())) diff --git a/src/state.rs b/src/state.rs index 4ae761a0..f12a1745 100644 --- a/src/state.rs +++ b/src/state.rs @@ -2,6 +2,7 @@ use { crate::{ acceptor::Acceptor, allocator::BufferObject, + animation::{AnimationCurve, AnimationState, AnimationTick, expand_damage_rect}, async_engine::{AsyncEngine, SpawnedFuture}, backend::{ Backend, BackendConnectorState, BackendConnectorStateSerials, BackendDrmDevice, @@ -102,11 +103,10 @@ use { time::Time, tree::{ ContainerNode, ContainerSplit, Direction, DisplayNode, FindTreeUsecase, FloatNode, - FoundNode, LatchListener, Node, NodeIds, NodeVisitorBase, OutputNode, PlaceholderNode, - TearingMode, TileState, ToplevelData, ToplevelIdentifier, ToplevelNode, - ToplevelNodeBase, Transform, VrrMode, WorkspaceDisplayOrder, WorkspaceNode, - WorkspaceNodeId, - WsMoveConfig, generic_node_visitor, move_ws_to_output, + FoundNode, LatchListener, Node, NodeId, NodeIds, NodeVisitorBase, OutputNode, + PlaceholderNode, TearingMode, TileState, ToplevelData, ToplevelIdentifier, + ToplevelNode, ToplevelNodeBase, Transform, VrrMode, WorkspaceDisplayOrder, + WorkspaceNode, WorkspaceNodeId, WsMoveConfig, generic_node_visitor, move_ws_to_output, }, udmabuf::UdmabufHolder, utils::{ @@ -264,6 +264,10 @@ pub struct State { pub cpu_worker: Rc, pub ui_drag_enabled: Cell, pub ui_drag_threshold_squared: Cell, + pub animations: AnimationState, + pub layout_animations_requested: Cell, + pub layout_animations_active: Cell, + pub suppress_animations_for_next_layout: Cell, pub toplevels: CopyHashMap>, pub const_40hz_latch: EventSource, pub tray_item_ids: TrayItemIds, @@ -1115,6 +1119,10 @@ impl State { self.pending_screencast_reallocs_or_reconfigures.clear(); self.pending_placeholder_render_textures.clear(); self.pending_container_tab_render_textures.clear(); + self.animations.clear(); + self.layout_animations_requested.set(false); + self.layout_animations_active.set(false); + self.suppress_animations_for_next_layout.set(false); self.render_ctx_watchers.clear(); self.workspace_watchers.clear(); self.toplevel_lists.clear(); @@ -1461,6 +1469,88 @@ impl State { self.eng.now().msec() } + pub fn queue_tiled_animation(self: &Rc, node_id: NodeId, old: Rect, new: Rect) { + if !self.animations.enabled.get() + || !self.layout_animations_active.get() + || self.suppress_animations_for_next_layout.get() + { + return; + } + let (old_output, old_scale) = { + let (x, y) = old.center(); + let (output, _, _) = self.find_closest_output(x, y); + (output.id, output.global.persistent.scale.get()) + }; + let (new_output, new_scale) = { + let (x, y) = new.center(); + let (output, _, _) = self.find_closest_output(x, y); + (output.id, output.global.persistent.scale.get()) + }; + if old_output != new_output || old_scale != new_scale { + return; + } + let now = self.now_nsec(); + let started = self.animations.set_target( + node_id, + old, + new, + now, + self.animations.duration_ms.get(), + self.animations.curve.get(), + ); + if started { + self.damage(expand_damage_rect( + old.union(new), + self.theme.sizes.border_width.get().max(0), + )); + self.ensure_animation_tick(); + } + } + + pub fn set_animations_enabled(&self, enabled: bool) { + if self.animations.enabled.replace(enabled) && !enabled { + self.animations.clear(); + self.damage(self.root.extents.get()); + } + } + + pub fn set_animation_duration_ms(&self, duration_ms: u32) { + self.animations.duration_ms.set(duration_ms); + } + + pub fn set_animation_curve(&self, curve: u32) { + self.animations + .curve + .set(AnimationCurve::from_config(curve)); + } + + pub fn with_layout_animations(&self, f: impl FnOnce() -> T) -> T { + let prev_requested = self.layout_animations_requested.replace(true); + let prev_active = self.layout_animations_active.replace(true); + let res = f(); + self.layout_animations_requested.set(prev_requested); + self.layout_animations_active.set(prev_active); + res + } + + fn ensure_animation_tick(self: &Rc) { + if self.animations.tick_is_active() { + return; + } + let outputs: Vec<_> = self.root.outputs.lock().values().cloned().collect(); + if outputs.is_empty() { + return; + } + let tick = Rc::new_cyclic(|weak| AnimationTick::new(self, weak)); + for output in &outputs { + tick.attach(output); + } + self.animations.set_tick(tick); + for output in &outputs { + self.damage(output.global.pos.get()); + } + } + pub fn output_extents_changed(&self) { self.root.update_extents(); for seat in self.globals.seats.lock().values() { diff --git a/src/tree/container.rs b/src/tree/container.rs index 61ec00d1..3a6db3a2 100644 --- a/src/tree/container.rs +++ b/src/tree/container.rs @@ -131,6 +131,7 @@ pub struct ContainerNode { pub content_height: Cell, pub sum_factors: Cell, pub layout_scheduled: Cell, + animate_next_layout: Cell, compute_render_positions_scheduled: Cell, num_children: NumCell, pub children: LinkedList, @@ -238,6 +239,7 @@ impl ContainerNode { content_height: Cell::new(0), sum_factors: Cell::new(1.0), layout_scheduled: Cell::new(false), + animate_next_layout: Cell::new(false), compute_render_positions_scheduled: Cell::new(false), num_children: NumCell::new(1), children, @@ -436,6 +438,10 @@ impl ContainerNode { } fn schedule_layout(self: &Rc) { + if self.state.layout_animations_requested.get() || self.state.layout_animations_active.get() + { + self.animate_next_layout.set(true); + } if !self.layout_scheduled.replace(true) { self.state.pending_container_layout.push(self.clone()); } @@ -656,6 +662,7 @@ impl ContainerNode { op.child.factor.set(child_factor); self.sum_factors.set(sum_factors); // log::info!("pointer_move"); + self.state.suppress_animations_for_next_layout.set(true); self.schedule_layout_immediate(); } } @@ -1761,8 +1768,13 @@ pub async fn container_layout(state: Rc) { loop { let container = state.pending_container_layout.pop().await; if container.layout_scheduled.get() { + let animate = container.animate_next_layout.replace(false) + && !state.suppress_animations_for_next_layout.get(); + let prev_active = state.layout_animations_active.replace(animate); container.perform_layout(); + state.layout_animations_active.set(prev_active); } + state.suppress_animations_for_next_layout.set(false); } } diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index 02bba848..34d637dd 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -184,6 +184,23 @@ impl ToplevelNode for T { fn tl_change_extents(self: Rc, rect: &Rect) { let data = self.tl_data(); let prev = data.desired_extents.replace(*rect); + let parent_is_mono = data + .parent + .get() + .and_then(|parent| parent.node_into_container()) + .is_some_and(|container| container.mono_child.is_some()); + if prev != *rect + && !prev.is_empty() + && !rect.is_empty() + && data.visible.get() + && !data.parent_is_float.get() + && !self.node_is_container() + && !parent_is_mono + { + data.state + .clone() + .queue_tiled_animation(data.node_id, prev, *rect); + } if prev.size() != rect.size() { for sc in data.jay_screencasts.lock().values() { sc.schedule_realloc_or_reconfigure(); diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index ba71c585..d860d656 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -266,6 +266,13 @@ pub struct UiDrag { pub threshold: Option, } +#[derive(Debug, Clone, Default)] +pub struct Animations { + pub enabled: Option, + pub duration_ms: Option, + pub curve: Option, +} + #[derive(Debug, Clone)] pub enum OutputMatch { Any(Vec), @@ -567,6 +574,7 @@ pub struct Config { pub tearing: Option, pub libei: Libei, pub ui_drag: UiDrag, + pub animations: Animations, pub xwayland: Option, pub color_management: Option, pub float: Option, diff --git a/toml-config/src/config/parsers.rs b/toml-config/src/config/parsers.rs index 4c5e337b..e353a2f8 100644 --- a/toml-config/src/config/parsers.rs +++ b/toml-config/src/config/parsers.rs @@ -8,6 +8,7 @@ use { pub mod action; mod actions; +mod animations; mod capabilities; mod clean_logs_older_than; mod client_match; diff --git a/toml-config/src/config/parsers/animations.rs b/toml-config/src/config/parsers/animations.rs new file mode 100644 index 00000000..a8abdf89 --- /dev/null +++ b/toml-config/src/config/parsers/animations.rs @@ -0,0 +1,50 @@ +use { + crate::{ + config::{ + Animations, + context::Context, + extractor::{Extractor, ExtractorError, bol, n32, opt, recover, str}, + parser::{DataType, ParseResult, Parser, UnexpectedDataType}, + }, + toml::{ + toml_span::{DespanExt, Span, Spanned}, + toml_value::Value, + }, + }, + indexmap::IndexMap, + thiserror::Error, +}; + +#[derive(Debug, Error)] +pub enum AnimationsParserError { + #[error(transparent)] + Expected(#[from] UnexpectedDataType), + #[error(transparent)] + Extract(#[from] ExtractorError), +} + +pub struct AnimationsParser<'a>(pub &'a Context<'a>); + +impl Parser for AnimationsParser<'_> { + type Value = Animations; + type Error = AnimationsParserError; + const EXPECTED: &'static [DataType] = &[DataType::Table]; + + fn parse_table( + &mut self, + span: Span, + table: &IndexMap, Spanned>, + ) -> ParseResult { + let mut ext = Extractor::new(self.0, span, table); + let (enabled, duration_ms, curve) = ext.extract(( + recover(opt(bol("enabled"))), + recover(opt(n32("duration-ms"))), + recover(opt(str("curve"))), + ))?; + Ok(Animations { + enabled: enabled.despan(), + duration_ms: duration_ms.despan(), + curve: curve.despan().map(|s| s.to_string()), + }) + } +} diff --git a/toml-config/src/config/parsers/config.rs b/toml-config/src/config/parsers/config.rs index b9d34e74..d82be95b 100644 --- a/toml-config/src/config/parsers/config.rs +++ b/toml-config/src/config/parsers/config.rs @@ -1,13 +1,14 @@ use { crate::{ config::{ - Action, Config, Libei, Theme, UiDrag, + Action, Animations, Config, Libei, Theme, UiDrag, context::Context, extractor::{Extractor, ExtractorError, arr, bol, int, opt, recover, str, val}, parser::{DataType, ParseResult, Parser, UnexpectedDataType}, parsers::{ action::ActionParser, actions::ActionsParser, + animations::AnimationsParser, clean_logs_older_than::CleanLogsOlderThanParser, client_rule::ClientRulesParser, color_management::ColorManagementParser, @@ -153,6 +154,7 @@ impl Parser for ConfigParser<'_> { fallback_output_mode_val, clean_logs_older_than_val, mouse_follows_focus, + animations_val, ), ) = ext.extract(( ( @@ -213,6 +215,7 @@ impl Parser for ConfigParser<'_> { opt(val("fallback-output-mode")), opt(val("clean-logs-older-than")), recover(opt(bol("unstable-mouse-follows-focus"))), + opt(val("animations")), ), ))?; let mut keymap = None; @@ -429,6 +432,15 @@ impl Parser for ConfigParser<'_> { } } } + let mut animations = Animations::default(); + if let Some(value) = animations_val { + match value.parse(&mut AnimationsParser(self.0)) { + Ok(v) => animations = v, + Err(e) => { + log::warn!("Could not parse animations setting: {}", self.0.error(e)); + } + } + } let mut xwayland = None; if let Some(value) = xwayland_val { match value.parse(&mut XwaylandParser(self.0)) { @@ -587,6 +599,7 @@ impl Parser for ConfigParser<'_> { tearing, libei, ui_drag, + animations, xwayland, color_management, float, diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index 391bcee9..1b057985 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -23,7 +23,7 @@ use { ahash::{AHashMap, AHashSet}, error_reporter::Report, jay_config::{ - Axis, + AnimationCurve, Axis, client::Client, config, config_dir, exec::{Command, set_env, unset_env}, @@ -37,7 +37,8 @@ use { is_reload, keyboard::Keymap, logging::{clean_logs_older_than, set_log_level}, - on_devices_enumerated, on_idle, on_unload, quit, reload, set_autotile, + on_devices_enumerated, on_idle, on_unload, quit, reload, set_animation_curve, + set_animation_duration_ms, set_animations_enabled, set_autotile, set_color_management_enabled, set_corner_radius, set_default_workspace_capture, set_explicit_sync_enabled, set_float_above_fullscreen, set_floating_titles, set_idle, set_idle_grace_period, set_middle_click_paste_enabled, set_show_bar, @@ -1649,6 +1650,23 @@ fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc Some(AnimationCurve::LINEAR), + "ease" => Some(AnimationCurve::EASE), + "ease-in" => Some(AnimationCurve::EASE_IN), + "ease-out" => Some(AnimationCurve::EASE_OUT), + "ease-in-out" => Some(AnimationCurve::EASE_IN_OUT), + _ => { + log::warn!("Unknown animation curve: {curve_name}"); + None + } + }; + if let Some(curve) = curve { + set_animation_curve(curve); + } if let Some(xwayland) = config.xwayland { if let Some(enabled) = xwayland.enabled { set_x_wayland_enabled(enabled); From fba9d65ba19a10e40a1bb69382a2f6aca25fac9b Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 15:45:32 +1000 Subject: [PATCH 02/47] Retain surface textures for animations --- docs/window-animations-plan.md | 11 ++ src/animation.rs | 139 ++++++++++++- src/ifs/wl_buffer.rs | 13 ++ src/ifs/wl_surface/x_surface/xwindow.rs | 5 + .../wl_surface/xdg_surface/xdg_toplevel.rs | 6 + src/renderer.rs | 182 +++++++++++++++++- src/state.rs | 13 +- src/tree/toplevel.rs | 15 +- 8 files changed, 365 insertions(+), 19 deletions(-) diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index 1c99c60e..ffbfe19c 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -101,6 +101,17 @@ Tests: Goal: freeze visual contents during movement and enable spawn-out. +Initial retained-record implementation status: + +- Tiled animation can retain GPU/dmabuf-backed XDG and Xwayland surface trees. +- Retained records hold both `GfxTexture` and `SurfaceBuffer` references so the + existing buffer release/sync path remains authoritative. +- Single-pixel buffers can be retained as color records. +- Async SHM textures are not retained yet because Wry's per-surface SHM + front/back textures can be reused by later commits while an animation is still + running. Those surfaces fall back to live rendering until an explicit offscreen + copy fallback exists. + Implementation shape: - Add a retained render-record tree for toplevel surfaces. diff --git a/src/animation.rs b/src/animation.rs index b0091933..f0daa804 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -1,7 +1,11 @@ use { crate::{ + cmm::{cmm_description::ColorDescription, cmm_render_intent::RenderIntent}, + gfx_api::{GfxTexture, SampleRect}, + ifs::wl_surface::{SurfaceBuffer, WlSurface}, rect::Rect, state::State, + theme::Color, tree::{LatchListener, NodeId, OutputNode}, utils::{clonecell::CloneCell, event_listener::EventListener}, }, @@ -54,6 +58,112 @@ pub struct AnimationState { tick: CloneCell>>, } +pub struct RetainedToplevel { + pub offset: (i32, i32), + pub surface: RetainedSurface, +} + +pub struct RetainedSurface { + pub offset: (i32, i32), + pub size: (i32, i32), + pub content: RetainedContent, + pub below: Vec, + pub above: Vec, +} + +pub enum RetainedContent { + Texture { + texture: Rc, + buffer: Rc, + source: SampleRect, + alpha: Option, + color_description: Rc, + render_intent: RenderIntent, + alpha_mode: crate::gfx_api::AlphaMode, + opaque: bool, + }, + Color { + color: Color, + alpha: Option, + color_description: Rc, + render_intent: RenderIntent, + }, +} + +impl RetainedToplevel { + pub fn capture_surface(surface: &WlSurface, offset: (i32, i32)) -> Option> { + Some(Rc::new(Self { + offset, + surface: RetainedSurface::capture(surface, (0, 0))?, + })) + } +} + +impl RetainedSurface { + fn capture(surface: &WlSurface, offset: (i32, i32)) -> Option { + let buffer = surface.buffer.get()?; + let size = surface.buffer_abs_pos.get().size(); + let source = *surface.buffer_points_norm.borrow(); + let color_description = surface.color_description(); + let render_intent = surface.render_intent(); + let alpha_mode = surface.alpha_mode(); + let alpha = surface.alpha(); + let content = match buffer.buffer.buf.get_stable_texture() { + Some(texture) => RetainedContent::Texture { + opaque: surface.opaque(), + texture, + buffer, + source, + alpha, + color_description, + render_intent, + alpha_mode, + }, + None => { + let color = buffer.buffer.buf.color?; + RetainedContent::Color { + color: Color::from_u32( + color_description.eotf, + alpha_mode, + color[0], + color[1], + color[2], + color[3], + ), + alpha, + color_description, + render_intent, + } + } + }; + let mut below = vec![]; + let mut above = vec![]; + if let Some(children) = surface.children.borrow().as_deref() { + for child in children.below.iter() { + if child.pending.get() { + continue; + } + let pos = child.sub_surface.position.get(); + below.push(Self::capture(&child.sub_surface.surface, pos)?); + } + for child in children.above.iter() { + if child.pending.get() { + continue; + } + let pos = child.sub_surface.position.get(); + above.push(Self::capture(&child.sub_surface.surface, pos)?); + } + } + Some(Self { + offset, + size, + content, + below, + above, + }) + } +} + impl Default for AnimationState { fn default() -> Self { Self { @@ -79,6 +189,7 @@ impl AnimationState { node_id: NodeId, old: Rect, new: Rect, + retained: Option>, now_nsec: u64, duration_ms: u32, curve: AnimationCurve, @@ -89,10 +200,10 @@ impl AnimationState { } let duration_nsec = duration_ms as u64 * 1_000_000; let mut windows = self.windows.borrow_mut(); - let from = match windows.get(&node_id) { + let (from, retained) = match windows.get(&node_id) { Some(anim) if anim.to == new => return false, - Some(anim) => anim.rect_at(now_nsec), - None => old, + Some(anim) => (anim.rect_at(now_nsec), anim.retained.clone().or(retained)), + None => (old, retained), }; if from == new { windows.remove(&node_id); @@ -107,6 +218,7 @@ impl AnimationState { duration_nsec, curve, last_damage: from, + retained, }, ); true @@ -120,6 +232,18 @@ impl AnimationState { } } + pub fn retained_snapshot( + &self, + node_id: NodeId, + now_nsec: u64, + ) -> Option> { + let windows = self.windows.borrow(); + match windows.get(&node_id) { + Some(anim) if !anim.done(now_nsec) => anim.retained.clone(), + _ => None, + } + } + fn damage_active(&self, state: &State, now_nsec: u64) -> bool { let mut damages = vec![]; let mut any_active = false; @@ -164,6 +288,7 @@ struct WindowAnimation { duration_nsec: u64, curve: AnimationCurve, last_damage: Rect, + retained: Option>, } impl WindowAnimation { @@ -286,8 +411,8 @@ mod tests { let id = NodeId(1); let a = Rect::new_sized_saturating(0, 0, 100, 100); let b = Rect::new_sized_saturating(100, 0, 100, 100); - assert!(state.set_target(id, a, b, 0, 160, AnimationCurve::Linear)); - assert!(!state.set_target(id, a, b, 80_000_000, 160, AnimationCurve::Linear)); + assert!(state.set_target(id, a, b, None, 0, 160, AnimationCurve::Linear)); + assert!(!state.set_target(id, a, b, None, 80_000_000, 160, AnimationCurve::Linear)); assert_eq!( state.visual_rect(id, b, 80_000_000), Rect::new_sized_saturating(50, 0, 100, 100) @@ -301,8 +426,8 @@ mod tests { let a = Rect::new_sized_saturating(0, 0, 100, 100); let b = Rect::new_sized_saturating(100, 0, 100, 100); let c = Rect::new_sized_saturating(200, 0, 100, 100); - assert!(state.set_target(id, a, b, 0, 160, AnimationCurve::Linear)); - assert!(state.set_target(id, a, c, 80_000_000, 160, AnimationCurve::Linear)); + assert!(state.set_target(id, a, b, None, 0, 160, AnimationCurve::Linear)); + assert!(state.set_target(id, a, c, None, 80_000_000, 160, AnimationCurve::Linear)); assert_eq!( state.visual_rect(id, c, 80_000_000), Rect::new_sized_saturating(50, 0, 100, 100) diff --git a/src/ifs/wl_buffer.rs b/src/ifs/wl_buffer.rs index 1fff5db3..678ee0c4 100644 --- a/src/ifs/wl_buffer.rs +++ b/src/ifs/wl_buffer.rs @@ -310,6 +310,19 @@ impl WlBuffer { } } + pub fn get_stable_texture(&self) -> Option> { + match &*self.storage.borrow() { + None => None, + Some(s) => match s { + WlBufferStorage::Shm { + dmabuf_buffer_params, + .. + } => dmabuf_buffer_params.tex.clone(), + WlBufferStorage::Dmabuf { tex, .. } => tex.clone(), + }, + } + } + pub fn update_texture_or_log(&self, surface: &WlSurface, sync_shm: bool) { if let Err(e) = self.update_texture(surface, sync_shm) { log::warn!("Could not update texture: {}", ErrorFmt(e)); diff --git a/src/ifs/wl_surface/x_surface/xwindow.rs b/src/ifs/wl_surface/x_surface/xwindow.rs index f1c68730..a4d3e88b 100644 --- a/src/ifs/wl_surface/x_surface/xwindow.rs +++ b/src/ifs/wl_surface/x_surface/xwindow.rs @@ -1,5 +1,6 @@ use { crate::{ + animation::RetainedToplevel, client::Client, cursor::KnownCursor, fixed::Fixed, @@ -514,6 +515,10 @@ impl ToplevelNodeBase for Xwindow { Some(self.x.surface.clone()) } + fn tl_animation_snapshot(&self) -> Option> { + RetainedToplevel::capture_surface(&self.x.surface, (0, 0)) + } + fn tl_admits_children(&self) -> bool { false } diff --git a/src/ifs/wl_surface/xdg_surface/xdg_toplevel.rs b/src/ifs/wl_surface/xdg_surface/xdg_toplevel.rs index 768a8367..740c7a50 100644 --- a/src/ifs/wl_surface/xdg_surface/xdg_toplevel.rs +++ b/src/ifs/wl_surface/xdg_surface/xdg_toplevel.rs @@ -2,6 +2,7 @@ pub mod xdg_dialog_v1; use { crate::{ + animation::RetainedToplevel, bugs, bugs::Bugs, client::{Client, ClientError}, @@ -779,6 +780,11 @@ impl ToplevelNodeBase for XdgToplevel { Some(self.xdg.surface.clone()) } + fn tl_animation_snapshot(&self) -> Option> { + let geo = self.xdg.geometry(); + RetainedToplevel::capture_surface(&self.xdg.surface, (-geo.x1(), -geo.y1())) + } + fn tl_restack_popups(&self) { self.xdg.restack_popups(); } diff --git a/src/renderer.rs b/src/renderer.rs index f8174883..5a891ac8 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -1,7 +1,8 @@ use { crate::{ + animation::{RetainedContent, RetainedSurface, RetainedToplevel}, cmm::cmm_render_intent::RenderIntent, - gfx_api::{AcquireSync, AlphaMode, GfxApiOpt, ReleaseSync, SampleRect}, + gfx_api::{AcquireSync, AlphaMode, BufferResv, GfxApiOpt, ReleaseSync, SampleRect}, ifs::wl_surface::{ SurfaceBuffer, WlSurface, x_surface::xwindow::Xwindow, @@ -467,6 +468,167 @@ impl Renderer<'_> { visual.move_(-container.abs_x1.get(), -container.abs_y1.get()) } + fn render_child_or_snapshot( + &mut self, + child: &Rc, + x: i32, + y: i32, + bounds: Option<&Rect>, + ) { + if let Some(retained) = self + .state + .animations + .retained_snapshot(child.node_id(), self.state.now_nsec()) + { + self.render_retained_toplevel(&retained, x, y, bounds); + } else { + child.node_render(self, x, y, bounds); + } + } + + fn render_retained_toplevel( + &mut self, + retained: &RetainedToplevel, + x: i32, + y: i32, + bounds: Option<&Rect>, + ) { + let (x, y) = self + .base + .scale_point(x + retained.offset.0, y + retained.offset.1); + self.render_retained_surface_scaled(&retained.surface, x, y, None, bounds); + } + + fn render_retained_surface_scaled( + &mut self, + retained: &RetainedSurface, + x: i32, + y: i32, + pos_rel: Option<(i32, i32)>, + bounds: Option<&Rect>, + ) { + let stretch = self.stretch.take(); + let corner_radius = self.corner_radius.take(); + let mut size = retained.size; + if let Some((x_rel, y_rel)) = pos_rel { + let (x, y) = self.base.scale_point(x_rel, y_rel); + let (w, h) = self.base.scale_point(x_rel + size.0, y_rel + size.1); + size = (w - x, h - y); + } else { + size = self.base.scale_point(size.0, size.1); + } + let mut stretched_source = None; + if let Some(s) = stretch { + if let RetainedContent::Texture { source, .. } = &retained.content { + let mut source = *source; + if size.0 > 0 && size.1 > 0 { + let sx = s.0 as f32 / size.0 as f32; + let sy = s.1 as f32 / size.1 as f32; + source.x2 *= sx; + source.y2 *= sy; + } + stretched_source = Some(source); + } + size = s; + } + for child in &retained.below { + let (x1, y1) = self.base.scale_point(child.offset.0, child.offset.1); + self.render_retained_surface_scaled(child, x + x1, y + y1, Some(child.offset), bounds); + } + self.corner_radius = corner_radius; + self.render_retained_content(retained, stretched_source, x, y, size, bounds); + for child in &retained.above { + let (x1, y1) = self.base.scale_point(child.offset.0, child.offset.1); + self.render_retained_surface_scaled(child, x + x1, y + y1, Some(child.offset), bounds); + } + } + + fn render_retained_content( + &mut self, + retained: &RetainedSurface, + stretched_source: Option, + x: i32, + y: i32, + size: (i32, i32), + bounds: Option<&Rect>, + ) { + let corner_radius = self.corner_radius.take(); + match &retained.content { + RetainedContent::Texture { + texture, + buffer, + source, + alpha, + color_description, + render_intent, + alpha_mode, + opaque, + } => { + let source = stretched_source.unwrap_or(*source); + if let Some(cr) = corner_radius { + self.base.render_rounded_texture( + texture, + *alpha, + x, + y, + Some(source), + Some(size), + self.base.scale, + bounds, + Some(buffer.clone() as Rc), + AcquireSync::Unnecessary, + buffer.release_sync, + color_description, + *render_intent, + *alpha_mode, + cr, + ); + } else { + self.base.render_texture( + texture, + *alpha, + x, + y, + Some(source), + Some(size), + self.base.scale, + bounds, + Some(buffer.clone() as Rc), + AcquireSync::Unnecessary, + buffer.release_sync, + *opaque, + color_description, + *render_intent, + *alpha_mode, + ); + } + } + RetainedContent::Color { + color, + alpha, + color_description, + render_intent, + } => { + if let Some(rect) = Rect::new_sized(x, y, size.0, size.1) { + let rect = match bounds { + None => rect, + Some(bounds) => rect.intersect(*bounds), + }; + if !rect.is_empty() { + self.base.sync(); + self.base.fill_scaled_boxes( + &[rect], + color, + *alpha, + &color_description.linear, + *render_intent, + ); + } + } + } + } + } + pub fn render_container(&mut self, container: &ContainerNode, x: i32, y: i32) { self.render_container_decorations(container, x, y); @@ -526,9 +688,12 @@ impl Renderer<'_> { self.corner_radius = Some(inner_cr); } } - child - .node - .node_render(self, x + content.x1(), y + content.y1(), Some(&body)); + self.render_child_or_snapshot( + &child.node, + x + content.x1(), + y + content.y1(), + Some(&body), + ); self.stretch = None; self.corner_radius = None; } else { @@ -579,9 +744,12 @@ impl Renderer<'_> { } let body = body.move_(x, y); let body = self.base.scale_rect(body); - child - .node - .node_render(self, x + content.x1(), y + content.y1(), Some(&body)); + self.render_child_or_snapshot( + &child.node, + x + content.x1(), + y + content.y1(), + Some(&body), + ); self.stretch = None; self.corner_radius = None; } diff --git a/src/state.rs b/src/state.rs index f12a1745..ddf69fd7 100644 --- a/src/state.rs +++ b/src/state.rs @@ -2,7 +2,9 @@ use { crate::{ acceptor::Acceptor, allocator::BufferObject, - animation::{AnimationCurve, AnimationState, AnimationTick, expand_damage_rect}, + animation::{ + AnimationCurve, AnimationState, AnimationTick, RetainedToplevel, expand_damage_rect, + }, async_engine::{AsyncEngine, SpawnedFuture}, backend::{ Backend, BackendConnectorState, BackendConnectorStateSerials, BackendDrmDevice, @@ -1469,7 +1471,13 @@ impl State { self.eng.now().msec() } - pub fn queue_tiled_animation(self: &Rc, node_id: NodeId, old: Rect, new: Rect) { + pub fn queue_tiled_animation( + self: &Rc, + node_id: NodeId, + old: Rect, + new: Rect, + retained: Option>, + ) { if !self.animations.enabled.get() || !self.layout_animations_active.get() || self.suppress_animations_for_next_layout.get() @@ -1494,6 +1502,7 @@ impl State { node_id, old, new, + retained, now, self.animations.duration_ms.get(), self.animations.curve.get(), diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index 34d637dd..f4678729 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -1,5 +1,6 @@ use { crate::{ + animation::RetainedToplevel, client::{Client, ClientId}, criteria::{ CritDestroyListener, CritMatcherId, @@ -197,9 +198,12 @@ impl ToplevelNode for T { && !self.node_is_container() && !parent_is_mono { - data.state - .clone() - .queue_tiled_animation(data.node_id, prev, *rect); + data.state.clone().queue_tiled_animation( + data.node_id, + prev, + *rect, + self.tl_animation_snapshot(), + ); } if prev.size() != rect.size() { for sc in data.jay_screencasts.lock().values() { @@ -316,6 +320,11 @@ pub trait ToplevelNodeBase: Node { fn tl_scanout_surface(&self) -> Option> { None } + + fn tl_animation_snapshot(&self) -> Option> { + None + } + fn tl_restack_popups(&self) { // nothing } From 7575f851fe83745c23304eeb49d869362a10520c Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 15:53:19 +1000 Subject: [PATCH 03/47] Document deferred retained scaling polish --- docs/window-animations-plan.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index ffbfe19c..aea6ab98 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -20,6 +20,9 @@ be handled deliberately. working and testable. - Content freezing will use retained per-surface texture references, not a full offscreen snapshot as the default design. +- Retained records should keep using the existing renderer behavior for now, + including clipping and edge stretch/clamp behavior for undersized contents. A + dedicated retained-tree scaling path is deferred to a later polish phase. - The multiphase animation concept is original to Wry. Hy3 is relevant only as partial inspiration for tiling style and titlebar/grouping behavior. - Mono mode should mostly avoid animations. Exceptions are windows entering or @@ -107,6 +110,8 @@ Initial retained-record implementation status: - Retained records hold both `GfxTexture` and `SurfaceBuffer` references so the existing buffer release/sync path remains authoritative. - Single-pixel buffers can be retained as color records. +- Retained records render through the same texture and stretch/clamp paths used + by live surfaces. This is the expected Phase 2 behavior. - Async SHM textures are not retained yet because Wry's per-surface SHM front/back textures can be reused by later commits while an animation is still running. Those surfaces fall back to live rendering until an explicit offscreen @@ -121,13 +126,16 @@ Implementation shape: - Extend event/sync handling so retained buffers remain valid until the animation is complete. -Open design work: +Deferred/future polish: -- Exact lifetime model for retained `SurfaceBuffer` / `GfxTexture` references. - Whether retained records participate in frame callbacks or presentation feedback. Default assumption: no, because they are compositor animation frames, not client commits. - How to fall back when a buffer cannot be safely retained. +- A distinct retained-tree scaling render path for true spawn-in/spawn-out + content scaling. If added, start with retained GPU-backed records only, keep + the animated frame as the clip boundary, and avoid live SHM scaling until there + is an explicit snapshot/copy fallback. ## Phase 3: Multiphase No-Overlap Animations From 18ffaef64d4e59ee3c89db473a508aa1ede7d302 Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 16:06:33 +1000 Subject: [PATCH 04/47] Add spawn-in window animations --- docs/window-animations-plan.md | 13 +++++--- src/animation.rs | 60 ++++++++++++++++++++++++++++++++++ src/renderer.rs | 33 +++++++++++++++---- src/state.rs | 28 ++++++++++++++++ src/tree/float.rs | 8 +++++ src/tree/toplevel.rs | 27 +++++++++++++++ 6 files changed, 157 insertions(+), 12 deletions(-) diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index aea6ab98..0cb05965 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -12,8 +12,10 @@ be handled deliberately. - Pointer drag and resize initiated by the mouse or tablet do not animate. - Linear animations restart only for windows whose destination changes. Other in-flight windows keep their existing timelines. -- Spawn-in uses scale and position. Spawn-out requires retained visual content - and is deferred until the freezing layer exists. +- Spawn-in uses scale and position for newly mapped tiled and floating app + windows. Layer-shell, overlay, override-redirect, and fullscreen surfaces do + not use this path. Spawn-out requires retained visual content after the live + node is gone and remains deferred. - Command-driven tile-to-float and float-to-tile transitions may animate. Protocol drag/drop paths do not. - The no-overlap multiphase system is a separate phase after the linear path is @@ -80,8 +82,8 @@ Implementation shape: Initial scope: - Tiled reflow animation. -- Floating command-driven moves, tile-to-float, float-to-tile, and spawn-in are - deferred until after tiled reflow is validated. +- Floating command-driven moves, tile-to-float, and float-to-tile are deferred + until after tiled reflow and spawn-in are validated. - Cross-output and cross-scale movements snap for now. - Linear mode may overlap windows during swaps. That is expected for the classic interpolation mode; no-overlap is Phase 3. @@ -89,7 +91,6 @@ Initial scope: deferred, but animated windows must still be clipped to their presentation bounds and must preserve the existing stretch behavior for undersized contents. - No spawn-out. -- No content freezing. - No multiphase no-overlap planner. Tests: @@ -107,6 +108,8 @@ Goal: freeze visual contents during movement and enable spawn-out. Initial retained-record implementation status: - Tiled animation can retain GPU/dmabuf-backed XDG and Xwayland surface trees. +- Spawn-in animation can retain GPU/dmabuf-backed XDG and Xwayland surface trees + for both tiled windows and floating child contents. - Retained records hold both `GfxTexture` and `SurfaceBuffer` references so the existing buffer release/sync path remains authoritative. - Single-pixel buffers can be retained as color records. diff --git a/src/animation.rs b/src/animation.rs index f0daa804..45930733 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -17,6 +17,8 @@ use { }; const DEFAULT_DURATION_MS: u32 = 160; +const SPAWN_IN_INITIAL_SCALE_NUMERATOR: i32 = 4; +const SPAWN_IN_INITIAL_SCALE_DENOMINATOR: i32 = 5; #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum AnimationCurve { @@ -224,6 +226,26 @@ impl AnimationState { true } + pub fn set_spawn_in( + &self, + node_id: NodeId, + target: Rect, + retained: Option>, + now_nsec: u64, + duration_ms: u32, + ) -> bool { + let start = spawn_in_start_rect(target); + self.set_target( + node_id, + start, + target, + retained, + now_nsec, + duration_ms, + AnimationCurve::Linear, + ) + } + pub fn visual_rect(&self, node_id: NodeId, layout: Rect, now_nsec: u64) -> Rect { let windows = self.windows.borrow(); match windows.get(&node_id) { @@ -350,6 +372,23 @@ impl LatchListener for AnimationTick { } } +pub(crate) fn spawn_in_start_rect(target: Rect) -> Rect { + fn scaled_dimension(value: i32) -> i32 { + let scaled = (value as i64 * SPAWN_IN_INITIAL_SCALE_NUMERATOR as i64 + / SPAWN_IN_INITIAL_SCALE_DENOMINATOR as i64) as i32; + scaled.clamp(1, value.max(1)) + } + + let width = scaled_dimension(target.width()); + let height = scaled_dimension(target.height()); + Rect::new_sized_saturating( + target.x1() + (target.width() - width) / 2, + target.y1() + (target.height() - height) / 2, + width, + height, + ) +} + fn lerp_rect(from: Rect, to: Rect, t: f64) -> Rect { fn lerp(from: i32, to: i32, t: f64) -> i32 { (from as f64 + (to as f64 - from as f64) * t).round() as i32 @@ -437,4 +476,25 @@ mod tests { Rect::new_sized_saturating(125, 0, 100, 100) ); } + + #[test] + fn spawn_in_start_rect_is_centered_and_non_empty() { + let target = Rect::new_sized_saturating(10, 20, 100, 50); + assert_eq!( + spawn_in_start_rect(target), + Rect::new_sized_saturating(20, 25, 80, 40) + ); + } + + #[test] + fn spawn_in_uses_linear_curve() { + let state = AnimationState::default(); + let id = NodeId(1); + let target = Rect::new_sized_saturating(10, 20, 100, 50); + assert!(state.set_spawn_in(id, target, None, 0, 160)); + assert_eq!( + state.visual_rect(id, target, 80_000_000), + Rect::new_sized_saturating(15, 23, 90, 45) + ); + } } diff --git a/src/renderer.rs b/src/renderer.rs index 5a891ac8..f8cfe80e 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -15,7 +15,7 @@ use { state::State, theme::{Color, CornerRadius}, tree::{ - ContainerNode, DisplayNode, FloatNode, OutputNode, PlaceholderNode, ToplevelData, + ContainerNode, DisplayNode, FloatNode, Node, OutputNode, PlaceholderNode, ToplevelData, ToplevelNode, ToplevelNodeBase, WorkspaceNode, tab_bar::TabBar, }, }, @@ -207,8 +207,13 @@ impl Renderer<'_> { if stacked.node_visible() { self.base.sync(); let pos = stacked.node_absolute_position(); - if pos.intersects(&opos) { - let (x, y) = opos.translate(pos.x1(), pos.y1()); + let visual = self.state.animations.visual_rect( + stacked.node_id(), + pos, + self.state.now_nsec(), + ); + if visual.intersects(&opos) { + let (x, y) = opos.translate(visual.x1(), visual.y1()); stacked.node_render(self, x, y, None); } } @@ -983,6 +988,10 @@ impl Renderer<'_> { _ => return, }; let pos = floating.position.get(); + let visual = + self.state + .animations + .visual_rect(floating.node_id(), pos, self.state.now_nsec()); let theme = &self.state.theme; let bw = theme.sizes.border_width.get(); let bc = if floating.active.get() { @@ -991,16 +1000,26 @@ impl Renderer<'_> { theme.colors.border.get() }; let cr = theme.corner_radius.get(); - let outer = Rect::new_sized_saturating(0, 0, pos.width(), pos.height()); + let outer = Rect::new_sized_saturating(0, 0, visual.width(), visual.height()); self.render_rounded_frame(outer, &bc, cr, bw, x, y); - let body = - Rect::new_sized_saturating(x + bw, y + bw, pos.width() - 2 * bw, pos.height() - 2 * bw); + let body = Rect::new_sized_saturating( + x + bw, + y + bw, + visual.width() - 2 * bw, + visual.height() - 2 * bw, + ); let scissor_body = self.base.scale_rect(body); + self.stretch = if pos.width() != visual.width() || pos.height() != visual.height() { + Some(self.base.scale_point(body.width(), body.height())) + } else { + None + }; if !cr.is_zero() { let inner_cr = self.scale_corner_radius(cr.expanded_by(-(bw as f32))); self.corner_radius = Some(inner_cr); } - child.node_render(self, body.x1(), body.y1(), Some(&scissor_body)); + self.render_child_or_snapshot(&child, body.x1(), body.y1(), Some(&scissor_body)); + self.stretch = None; self.corner_radius = None; } diff --git a/src/state.rs b/src/state.rs index ddf69fd7..8bb78826 100644 --- a/src/state.rs +++ b/src/state.rs @@ -4,6 +4,7 @@ use { allocator::BufferObject, animation::{ AnimationCurve, AnimationState, AnimationTick, RetainedToplevel, expand_damage_rect, + spawn_in_start_rect, }, async_engine::{AsyncEngine, SpawnedFuture}, backend::{ @@ -1516,6 +1517,33 @@ impl State { } } + pub fn queue_spawn_in_animation( + self: &Rc, + node_id: NodeId, + target: Rect, + retained: Option>, + ) { + if !self.animations.enabled.get() || target.is_empty() { + return; + } + let start = spawn_in_start_rect(target); + let now = self.now_nsec(); + let started = self.animations.set_spawn_in( + node_id, + target, + retained, + now, + self.animations.duration_ms.get(), + ); + if started { + self.damage(expand_damage_rect( + start.union(target), + self.theme.sizes.border_width.get().max(0), + )); + self.ensure_animation_tick(); + } + } + pub fn set_animations_enabled(&self, enabled: bool) { if self.animations.enabled.replace(enabled) && !enabled { self.animations.clear(); diff --git a/src/tree/float.rs b/src/tree/float.rs index dc0b44f4..747c275e 100644 --- a/src/tree/float.rs +++ b/src/tree/float.rs @@ -153,6 +153,14 @@ impl FloatNode { _ => return, }; let pos = self.position.get(); + let spawn_in_pending = { + let data = child.tl_data(); + data.spawn_in_pending.get() && data.kind.is_app_window() && !data.is_fullscreen.get() + }; + if spawn_in_pending && self.visible.get() { + self.state + .queue_spawn_in_animation(self.id.into(), pos, None); + } let theme = &self.state.theme; let bw = theme.sizes.border_width.get(); let cpos = Rect::new_sized_saturating( diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index f4678729..499fedeb 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -118,6 +118,7 @@ impl ToplevelNode for T { let parent_was_none = data.parent.set(Some(parent.clone())).is_none(); if parent_was_none { data.mapped_during_iteration.set(data.state.eng.iteration()); + data.spawn_in_pending.set(data.kind.is_app_window()); data.property_changed(TL_CHANGED_NEW); } let was_floating = data.parent_is_float.get(); @@ -185,6 +186,7 @@ impl ToplevelNode for T { fn tl_change_extents(self: Rc, rect: &Rect) { let data = self.tl_data(); let prev = data.desired_extents.replace(*rect); + let spawn_in_pending = data.spawn_in_pending.get(); let parent_is_mono = data .parent .get() @@ -205,6 +207,22 @@ impl ToplevelNode for T { self.tl_animation_snapshot(), ); } + if spawn_in_pending + && !rect.is_empty() + && data.visible.get() + && !data.is_fullscreen.get() + && data.kind.is_app_window() + && !self.node_is_container() + { + data.state.clone().queue_spawn_in_animation( + data.node_id, + *rect, + self.tl_animation_snapshot(), + ); + } + if spawn_in_pending && !rect.is_empty() { + data.spawn_in_pending.set(false); + } if prev.size() != rect.size() { for sc in data.jay_screencasts.lock().values() { sc.schedule_realloc_or_reconfigure(); @@ -403,6 +421,13 @@ impl ToplevelType { ToplevelType::XWindow { .. } => window::X_WINDOW, } } + + pub fn is_app_window(&self) -> bool { + matches!( + self, + ToplevelType::XdgToplevel(_) | ToplevelType::XWindow(_) + ) + } } pub struct ToplevelData { @@ -425,6 +450,7 @@ pub struct ToplevelData { pub title: RefCell, pub parent: CloneCell>>, pub mapped_during_iteration: Cell, + pub spawn_in_pending: Cell, pub pos: Cell, pub desired_extents: Cell, pub seat_state: NodeSeatState, @@ -488,6 +514,7 @@ impl ToplevelData { title: RefCell::new(title), parent: Default::default(), mapped_during_iteration: Cell::new(0), + spawn_in_pending: Cell::new(false), pos: Default::default(), desired_extents: Default::default(), seat_state: Default::default(), From aeaea3419f8da98defc670c5220a2396a55e402a Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 16:18:44 +1000 Subject: [PATCH 05/47] Add float tile transition animations --- docs/window-animations-plan.md | 8 ++++-- src/compositor.rs | 1 + src/config/handler.rs | 16 +++++++----- src/state.rs | 47 +++++++++++++++++++++++++++++++--- src/tree/toplevel.rs | 33 +++++++++++++++++++++++- 5 files changed, 93 insertions(+), 12 deletions(-) diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index 0cb05965..82b10a4e 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -82,8 +82,8 @@ Implementation shape: Initial scope: - Tiled reflow animation. -- Floating command-driven moves, tile-to-float, and float-to-tile are deferred - until after tiled reflow and spawn-in are validated. +- Floating command-driven moves are deferred until after tiled reflow, spawn-in, + and float/tile transitions are validated. - Cross-output and cross-scale movements snap for now. - Linear mode may overlap windows during swaps. That is expected for the classic interpolation mode; no-overlap is Phase 3. @@ -100,6 +100,8 @@ Tests: - unchanged in-flight windows keep their original timeline - drag-driven floating movement bypasses animation - damage includes old, current, and final rects +- command-driven tile-to-float and float-to-tile transitions use linear motion +- pointer/header double-click unfloat bypasses the command-animation gate ## Phase 2: Retained Texture Freezing @@ -110,6 +112,8 @@ Initial retained-record implementation status: - Tiled animation can retain GPU/dmabuf-backed XDG and Xwayland surface trees. - Spawn-in animation can retain GPU/dmabuf-backed XDG and Xwayland surface trees for both tiled windows and floating child contents. +- Tile-to-float and float-to-tile transitions retain GPU/dmabuf-backed child + contents while the presentation geometry changes. - Retained records hold both `GfxTexture` and `SurfaceBuffer` references so the existing buffer release/sync path remains authoritative. - Single-pixel buffers can be retained as color records. diff --git a/src/compositor.rs b/src/compositor.rs index d8fb4027..93600f27 100644 --- a/src/compositor.rs +++ b/src/compositor.rs @@ -363,6 +363,7 @@ fn start_compositor2( animations: Default::default(), layout_animations_requested: Default::default(), layout_animations_active: Default::default(), + layout_animation_curve_override: Default::default(), suppress_animations_for_next_layout: Default::default(), toplevels: Default::default(), const_40hz_latch: Default::default(), diff --git a/src/config/handler.rs b/src/config/handler.rs index 0e9436c5..384137c7 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -1990,9 +1990,11 @@ impl ConfigProxyHandler { } fn handle_set_seat_floating(&self, seat: Seat, floating: bool) -> Result<(), CphError> { - let seat = self.get_seat(seat)?; - seat.set_floating(floating); - Ok(()) + self.state.with_linear_layout_animations(|| { + let seat = self.get_seat(seat)?; + seat.set_floating(floating); + Ok(()) + }) } fn handle_get_window_floating(&self, window: Window) -> Result<(), CphError> { @@ -2004,9 +2006,11 @@ impl ConfigProxyHandler { } fn handle_set_window_floating(&self, window: Window, floating: bool) -> Result<(), CphError> { - let window = self.get_window(window)?; - toplevel_set_floating(&self.state, window, floating); - Ok(()) + self.state.with_linear_layout_animations(|| { + let window = self.get_window(window)?; + toplevel_set_floating(&self.state, window, floating); + Ok(()) + }) } fn handle_add_pollable(self: &Rc, fd: i32) -> Result<(), CphError> { diff --git a/src/state.rs b/src/state.rs index 8bb78826..a94c8a37 100644 --- a/src/state.rs +++ b/src/state.rs @@ -270,6 +270,7 @@ pub struct State { pub animations: AnimationState, pub layout_animations_requested: Cell, pub layout_animations_active: Cell, + pub layout_animation_curve_override: Cell>, pub suppress_animations_for_next_layout: Cell, pub toplevels: CopyHashMap>, pub const_40hz_latch: EventSource, @@ -854,7 +855,7 @@ impl State { mut height: i32, workspace: &Rc, abs_pos: Option<(i32, i32)>, - ) { + ) -> Rc { width += 2 * self.theme.sizes.border_width.get(); height += 2 * self.theme.sizes.border_width.get() + self.theme.title_plus_underline_height(); @@ -885,8 +886,9 @@ impl State { } Rect::new_sized_saturating(x1, y1, width, height) }; - FloatNode::new(self, workspace, position, node.clone()); + let float = FloatNode::new(self, workspace, position, node.clone()); self.focus_after_map(node, self.seat_queue.last().as_deref()); + float } fn focus_after_map(&self, node: Rc, seat: Option<&Rc>) { @@ -1125,6 +1127,7 @@ impl State { self.animations.clear(); self.layout_animations_requested.set(false); self.layout_animations_active.set(false); + self.layout_animation_curve_override.set(None); self.suppress_animations_for_next_layout.set(false); self.render_ctx_watchers.clear(); self.workspace_watchers.clear(); @@ -1478,6 +1481,31 @@ impl State { old: Rect, new: Rect, retained: Option>, + ) { + let curve = self + .layout_animation_curve_override + .get() + .unwrap_or_else(|| self.animations.curve.get()); + self.queue_layout_animation(node_id, old, new, retained, curve); + } + + pub fn queue_linear_layout_animation( + self: &Rc, + node_id: NodeId, + old: Rect, + new: Rect, + retained: Option>, + ) { + self.queue_layout_animation(node_id, old, new, retained, AnimationCurve::Linear); + } + + fn queue_layout_animation( + self: &Rc, + node_id: NodeId, + old: Rect, + new: Rect, + retained: Option>, + curve: AnimationCurve, ) { if !self.animations.enabled.get() || !self.layout_animations_active.get() @@ -1506,7 +1534,7 @@ impl State { retained, now, self.animations.duration_ms.get(), - self.animations.curve.get(), + curve, ); if started { self.damage(expand_damage_rect( @@ -1570,6 +1598,19 @@ impl State { res } + pub fn with_linear_layout_animations(&self, f: impl FnOnce() -> T) -> T { + let prev_requested = self.layout_animations_requested.replace(true); + let prev_active = self.layout_animations_active.replace(true); + let prev_curve = self + .layout_animation_curve_override + .replace(Some(AnimationCurve::Linear)); + let res = f(); + self.layout_animations_requested.set(prev_requested); + self.layout_animations_active.set(prev_active); + self.layout_animation_curve_override.set(prev_curve); + res + } + fn ensure_animation_tick(self: &Rc) { if self.animations.tick_is_active() { return; diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index 499fedeb..dc9927f9 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -1096,6 +1096,26 @@ pub fn toplevel_create_split(state: &Rc, tl: Rc, axis: } } +fn float_outer_for_body(state: &State, body: Rect) -> Rect { + let bw = state.theme.sizes.border_width.get(); + Rect::new_sized_saturating( + body.x1() - bw, + body.y1() - bw, + body.width() + 2 * bw, + body.height() + 2 * bw, + ) +} + +fn float_body_for_outer(state: &State, outer: Rect) -> Rect { + let bw = state.theme.sizes.border_width.get(); + Rect::new_sized_saturating( + outer.x1() + bw, + outer.y1() + bw, + outer.width() - 2 * bw, + outer.height() - 2 * bw, + ) +} + pub fn toplevel_set_floating(state: &Rc, tl: Rc, floating: bool) { let data = tl.tl_data(); if data.is_fullscreen.get() { @@ -1112,9 +1132,20 @@ pub fn toplevel_set_floating(state: &Rc, tl: Rc, floati parent.cnode_remove_child2(&*tl, true); state.map_tiled(tl); } else if let Some(ws) = data.workspace.get() { + let node_id = data.node_id; + let old_body = + state + .animations + .visual_rect(node_id, tl.node_absolute_position(), state.now_nsec()); + let old_outer = float_outer_for_body(state, old_body); + let retained = tl.tl_animation_snapshot(); parent.cnode_remove_child2(&*tl, true); let (width, height) = data.float_size(&ws); - state.map_floating(tl, width, height, &ws, None); + let floater = state.map_floating(tl, width, height, &ws, None); + let new_outer = floater.position.get(); + let new_body = float_body_for_outer(state, new_outer); + state.queue_linear_layout_animation(floater.node_id(), old_outer, new_outer, None); + state.queue_linear_layout_animation(node_id, old_body, new_body, retained); } } From d0cc5dc3c7dcc2d4f91591c0682eaead7d286cc2 Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 16:51:50 +1000 Subject: [PATCH 06/47] Animate command-driven floating changes --- docs/window-animations-plan.md | 6 ++-- src/config/handler.rs | 4 ++- src/ifs/wl_seat.rs | 3 ++ src/tree/float.rs | 65 ++++++++++++++++++++++++++-------- 4 files changed, 60 insertions(+), 18 deletions(-) diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index 82b10a4e..ad5bd329 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -82,8 +82,8 @@ Implementation shape: Initial scope: - Tiled reflow animation. -- Floating command-driven moves are deferred until after tiled reflow, spawn-in, - and float/tile transitions are validated. +- Floating command-driven moves and resizes are animated. Pointer and tablet + drag/resize paths still snap directly to the live cursor position. - Cross-output and cross-scale movements snap for now. - Linear mode may overlap windows during swaps. That is expected for the classic interpolation mode; no-overlap is Phase 3. @@ -101,6 +101,8 @@ Tests: - drag-driven floating movement bypasses animation - damage includes old, current, and final rects - command-driven tile-to-float and float-to-tile transitions use linear motion +- command-driven floating moves and resizes animate without affecting pointer + drag/resize behavior - pointer/header double-click unfloat bypasses the command-animation gate ## Phase 2: Retained Texture Freezing diff --git a/src/config/handler.rs b/src/config/handler.rs index 384137c7..138b4416 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -668,7 +668,9 @@ impl ConfigProxyHandler { fn handle_window_move(&self, window: Window, direction: Direction) -> Result<(), CphError> { self.state.with_layout_animations(|| { let window = self.get_window(window)?; - if let Some(c) = toplevel_parent_container(&*window) { + if let Some(float) = window.tl_data().float.get() { + float.move_by_direction(direction.into()); + } else if let Some(c) = toplevel_parent_container(&*window) { c.move_child(window, direction.into()); } Ok(()) diff --git a/src/ifs/wl_seat.rs b/src/ifs/wl_seat.rs index 5fba889c..74ff4eda 100644 --- a/src/ifs/wl_seat.rs +++ b/src/ifs/wl_seat.rs @@ -936,6 +936,9 @@ impl WlSeatGlobal { { c.move_child(tl, direction); self.maybe_schedule_warp_mouse_to_focus(); + } else if let Some(float) = data.float.get() { + float.move_by_direction(direction); + self.maybe_schedule_warp_mouse_to_focus(); } } diff --git a/src/tree/float.rs b/src/tree/float.rs index 747c275e..f2f96681 100644 --- a/src/tree/float.rs +++ b/src/tree/float.rs @@ -31,6 +31,9 @@ use { }; tree_id!(FloatNodeId); + +const COMMAND_MOVE_DELTA: i32 = 100; + pub struct FloatNode { pub id: FloatNodeId, pub state: Rc, @@ -371,6 +374,51 @@ impl FloatNode { y2 += y1 - pos.y1(); } let new_pos = Rect::new_saturating(x1, y1, x2, y2); + self.set_position(new_pos); + } + + pub fn move_by_direction(self: &Rc, direction: Direction) { + let (dx, dy) = match direction { + Direction::Left => (-COMMAND_MOVE_DELTA, 0), + Direction::Down => (0, COMMAND_MOVE_DELTA), + Direction::Up => (0, -COMMAND_MOVE_DELTA), + Direction::Right => (COMMAND_MOVE_DELTA, 0), + Direction::Unspecified => return, + }; + self.set_position(self.position.get().move_(dx, dy)); + } + + fn body_for_outer(&self, outer: Rect) -> Rect { + let bw = self.state.theme.sizes.border_width.get(); + Rect::new_sized_saturating( + outer.x1() + bw, + outer.y1() + bw, + outer.width() - 2 * bw, + outer.height() - 2 * bw, + ) + } + + fn queue_position_animation(&self, old_pos: Rect, new_pos: Rect) { + self.state + .clone() + .queue_tiled_animation(self.id.into(), old_pos, new_pos, None); + let Some(child) = self.child.get() else { + return; + }; + self.state.clone().queue_tiled_animation( + child.node_id(), + self.body_for_outer(old_pos), + self.body_for_outer(new_pos), + child.tl_animation_snapshot(), + ); + } + + fn set_position(self: &Rc, new_pos: Rect) { + let pos = self.position.get(); + if new_pos == pos { + return; + } + self.queue_position_animation(pos, new_pos); self.position.set(new_pos); if self.visible.get() { self.state.damage(pos); @@ -799,13 +847,7 @@ impl ContainingNode for FloatNode { let bw = theme.sizes.border_width.get(); let (x, y) = (x - bw, y - bw); let pos = self.position.get(); - if pos.position() != (x, y) { - let new_pos = pos.at_point(x, y); - self.position.set(new_pos); - self.state.damage(pos); - self.state.damage(new_pos); - self.schedule_layout(); - } + self.set_position(pos.at_point(x, y)); } fn cnode_resize_child( @@ -836,14 +878,7 @@ impl ContainingNode for FloatNode { y2 = (v + bw).max(y1 + bw + bw); } let new_pos = Rect::new_saturating(x1, y1, x2, y2); - if new_pos != pos { - self.position.set(new_pos); - if self.visible.get() { - self.state.damage(pos); - self.state.damage(new_pos); - } - self.schedule_layout(); - } + self.set_position(new_pos); } fn cnode_pinned(&self) -> bool { From fa5c28ca3d252e41725892ec1cae9401dbeddb21 Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 17:09:06 +1000 Subject: [PATCH 07/47] Add retained spawn-out animations --- docs/window-animations-plan.md | 11 +- src/animation.rs | 117 ++++++++++++++++++ src/ifs/wl_surface/x_surface.rs | 19 ++- src/ifs/wl_surface/x_surface/xwindow.rs | 6 + src/ifs/wl_surface/xdg_surface.rs | 13 ++ .../wl_surface/xdg_surface/xdg_toplevel.rs | 10 ++ src/renderer.rs | 71 ++++++++++- src/state.rs | 34 ++++- src/tree/toplevel.rs | 58 ++++++++- 9 files changed, 331 insertions(+), 8 deletions(-) diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index ad5bd329..74f6564c 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -14,8 +14,9 @@ be handled deliberately. in-flight windows keep their existing timelines. - Spawn-in uses scale and position for newly mapped tiled and floating app windows. Layer-shell, overlay, override-redirect, and fullscreen surfaces do - not use this path. Spawn-out requires retained visual content after the live - node is gone and remains deferred. + not use this path. Spawn-out uses retained visual content after the live node + is gone, when a stable retained surface tree can be captured before unmap or + destroy. - Command-driven tile-to-float and float-to-tile transitions may animate. Protocol drag/drop paths do not. - The no-overlap multiphase system is a separate phase after the linear path is @@ -90,7 +91,8 @@ Initial scope: - Live client buffers are rendered in Phase 1. Retained content freezing is deferred, but animated windows must still be clipped to their presentation bounds and must preserve the existing stretch behavior for undersized contents. -- No spawn-out. +- Spawn-out is retained-content-only. If the surface cannot be retained safely + the window snaps out instead of animating an empty frame. - No multiphase no-overlap planner. Tests: @@ -116,6 +118,9 @@ Initial retained-record implementation status: for both tiled windows and floating child contents. - Tile-to-float and float-to-tile transitions retain GPU/dmabuf-backed child contents while the presentation geometry changes. +- Spawn-out captures retained app-window contents before XDG/Xwayland unmap or + destroy, then renders a detached shrinking presentation record until the + animation completes. - Retained records hold both `GfxTexture` and `SurfaceBuffer` references so the existing buffer release/sync path remains authoritative. - Single-pixel buffers can be retained as color records. diff --git a/src/animation.rs b/src/animation.rs index 45930733..199e142b 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -57,6 +57,7 @@ pub struct AnimationState { pub duration_ms: Cell, pub curve: Cell, windows: RefCell>, + exits: RefCell>, tick: CloneCell>>, } @@ -92,6 +93,21 @@ pub enum RetainedContent { }, } +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum RetainedExitLayer { + Tiled, + Floating, +} + +pub struct RetainedExitFrame { + pub rect: Rect, + pub retained: Rc, + pub frame_inset: i32, + pub source_body_size: (i32, i32), + pub active: bool, + pub layer: RetainedExitLayer, +} + impl RetainedToplevel { pub fn capture_surface(surface: &WlSurface, offset: (i32, i32)) -> Option> { Some(Rc::new(Self { @@ -173,6 +189,7 @@ impl Default for AnimationState { duration_ms: Cell::new(DEFAULT_DURATION_MS), curve: Cell::new(AnimationCurve::EaseOut), windows: Default::default(), + exits: Default::default(), tick: Default::default(), } } @@ -181,6 +198,7 @@ impl Default for AnimationState { impl AnimationState { pub fn clear(&self) { self.windows.borrow_mut().clear(); + self.exits.borrow_mut().clear(); if let Some(tick) = self.tick.take() { tick.detach(); } @@ -246,6 +264,42 @@ impl AnimationState { ) } + pub fn set_spawn_out( + &self, + from: Rect, + frame_inset: i32, + retained: Rc, + active: bool, + layer: RetainedExitLayer, + now_nsec: u64, + duration_ms: u32, + ) -> bool { + if from.is_empty() || duration_ms == 0 { + return false; + } + let to = spawn_in_start_rect(from); + if to == from || to.is_empty() { + return false; + } + let source_body_size = body_size_for_frame(from, frame_inset); + if source_body_size.0 <= 0 || source_body_size.1 <= 0 { + return false; + } + self.exits.borrow_mut().push(ExitAnimation { + from, + to, + start_nsec: now_nsec, + duration_nsec: duration_ms as u64 * 1_000_000, + last_damage: from, + retained, + frame_inset, + source_body_size, + active, + layer, + }); + true + } + pub fn visual_rect(&self, node_id: NodeId, layout: Rect, now_nsec: u64) -> Rect { let windows = self.windows.borrow(); match windows.get(&node_id) { @@ -266,6 +320,22 @@ impl AnimationState { } } + pub fn exit_frames(&self, now_nsec: u64) -> Vec { + self.exits + .borrow() + .iter() + .filter(|exit| !exit.done(now_nsec)) + .map(|exit| RetainedExitFrame { + rect: exit.rect_at(now_nsec), + retained: exit.retained.clone(), + frame_inset: exit.frame_inset, + source_body_size: exit.source_body_size, + active: exit.active, + layer: exit.layer, + }) + .collect() + } + fn damage_active(&self, state: &State, now_nsec: u64) -> bool { let mut damages = vec![]; let mut any_active = false; @@ -283,6 +353,18 @@ impl AnimationState { any_active |= active; active }); + self.exits.borrow_mut().retain_mut(|exit| { + let current = exit.rect_at(now_nsec); + let damage = exit.last_damage.union(current).union(exit.to); + damages.push(expand_damage_rect( + damage, + state.theme.sizes.border_width.get().max(0), + )); + exit.last_damage = current; + let active = !exit.done(now_nsec); + any_active |= active; + active + }); } for damage in damages { state.damage(damage); @@ -329,6 +411,34 @@ impl WindowAnimation { } } +struct ExitAnimation { + from: Rect, + to: Rect, + start_nsec: u64, + duration_nsec: u64, + last_damage: Rect, + retained: Rc, + frame_inset: i32, + source_body_size: (i32, i32), + active: bool, + layer: RetainedExitLayer, +} + +impl ExitAnimation { + fn done(&self, now_nsec: u64) -> bool { + now_nsec.saturating_sub(self.start_nsec) >= self.duration_nsec + } + + fn rect_at(&self, now_nsec: u64) -> Rect { + if self.duration_nsec == 0 { + return self.to; + } + let elapsed = now_nsec.saturating_sub(self.start_nsec); + let t = (elapsed as f64 / self.duration_nsec as f64).clamp(0.0, 1.0); + lerp_rect(self.from, self.to, t) + } +} + pub struct AnimationTick { state: Weak, slf: Weak, @@ -389,6 +499,13 @@ pub(crate) fn spawn_in_start_rect(target: Rect) -> Rect { ) } +fn body_size_for_frame(rect: Rect, frame_inset: i32) -> (i32, i32) { + ( + rect.width().saturating_sub(2 * frame_inset), + rect.height().saturating_sub(2 * frame_inset), + ) +} + fn lerp_rect(from: Rect, to: Rect, t: f64) -> Rect { fn lerp(from: i32, to: i32, t: f64) -> i32 { (from as f64 + (to as f64 - from as f64) * t).round() as i32 diff --git a/src/ifs/wl_surface/x_surface.rs b/src/ifs/wl_surface/x_surface.rs index 1c3e295c..4f6db63c 100644 --- a/src/ifs/wl_surface/x_surface.rs +++ b/src/ifs/wl_surface/x_surface.rs @@ -1,7 +1,7 @@ use { crate::{ ifs::wl_surface::{ - SurfaceExt, WlSurface, WlSurfaceError, + PendingState, SurfaceExt, WlSurface, WlSurfaceError, x_surface::{xwayland_surface_v1::XwaylandSurfaceV1, xwindow::Xwindow}, }, leaks::Tracker, @@ -30,6 +30,22 @@ impl SurfaceExt for XSurface { win.node_layer() } + fn before_apply_commit( + self: Rc, + pending: &mut PendingState, + ) -> Result<(), WlSurfaceError> { + if pending + .buffer + .as_ref() + .is_some_and(|buffer| buffer.is_none()) + && self.surface.buffer.is_some() + && let Some(xwindow) = self.xwindow.get() + { + xwindow.queue_spawn_out(); + } + Ok(()) + } + fn after_apply_commit(self: Rc) { if let Some(xwindow) = self.xwindow.get() { xwindow.map_status_changed(); @@ -45,6 +61,7 @@ impl SurfaceExt for XSurface { } self.surface.unset_ext(); if let Some(xwindow) = self.xwindow.take() { + xwindow.queue_spawn_out(); xwindow.tl_destroy(); xwindow.data.window.set(None); xwindow.data.surface_id.set(None); diff --git a/src/ifs/wl_surface/x_surface/xwindow.rs b/src/ifs/wl_surface/x_surface/xwindow.rs index a4d3e88b..80ea8b1b 100644 --- a/src/ifs/wl_surface/x_surface/xwindow.rs +++ b/src/ifs/wl_surface/x_surface/xwindow.rs @@ -253,6 +253,11 @@ impl Xwindow { self.x.surface.buffer.is_some() && self.data.info.mapped.get() } + pub fn queue_spawn_out(&self) { + self.toplevel_data + .queue_spawn_out(self, self.tl_animation_snapshot()); + } + fn map_change(&self) -> Change { match (self.may_be_mapped(), self.is_mapped()) { (true, false) => Change::Map, @@ -275,6 +280,7 @@ impl Xwindow { match map_change { Change::None => return, Change::Unmap => { + self.queue_spawn_out(); self.data .info .pending_extents diff --git a/src/ifs/wl_surface/xdg_surface.rs b/src/ifs/wl_surface/xdg_surface.rs index 9b5130d7..ad87c951 100644 --- a/src/ifs/wl_surface/xdg_surface.rs +++ b/src/ifs/wl_surface/xdg_surface.rs @@ -226,6 +226,10 @@ pub trait XdgSurfaceExt: Debug { // nothing } + fn prepare_unmap(&self) { + // nothing + } + fn extents_changed(&self) { // nothing } @@ -664,6 +668,15 @@ impl SurfaceExt for XdgSurface { if let Some(serial) = pending.serial.take() { self.applied_serial.set(serial); } + if pending + .buffer + .as_ref() + .is_some_and(|buffer| buffer.is_none()) + && self.surface.buffer.is_some() + && let Some(ext) = self.ext.get() + { + ext.prepare_unmap(); + } Ok(()) } diff --git a/src/ifs/wl_surface/xdg_surface/xdg_toplevel.rs b/src/ifs/wl_surface/xdg_surface/xdg_toplevel.rs index 740c7a50..6a7f395f 100644 --- a/src/ifs/wl_surface/xdg_surface/xdg_toplevel.rs +++ b/src/ifs/wl_surface/xdg_surface/xdg_toplevel.rs @@ -260,6 +260,7 @@ impl XdgToplevelRequestHandler for XdgToplevel { type Error = XdgToplevelError; fn destroy(&self, _req: Destroy, _slf: &Rc) -> Result<(), Self::Error> { + self.queue_spawn_out(); self.tl_destroy(); self.xdg.unset_ext(); { @@ -399,6 +400,11 @@ impl XdgToplevelRequestHandler for XdgToplevel { } impl XdgToplevel { + fn queue_spawn_out(&self) { + self.toplevel_data + .queue_spawn_out(self, self.tl_animation_snapshot()); + } + fn map( self: &Rc, parent: Option<&XdgToplevel>, @@ -824,6 +830,10 @@ impl XdgSurfaceExt for XdgToplevel { self.after_commit(None); } + fn prepare_unmap(&self) { + self.queue_spawn_out(); + } + fn extents_changed(&self) { self.toplevel_data.pos.set(self.xdg.extents.get()); self.tl_extents_changed(); diff --git a/src/renderer.rs b/src/renderer.rs index f8cfe80e..bb44e71d 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -1,6 +1,9 @@ use { crate::{ - animation::{RetainedContent, RetainedSurface, RetainedToplevel}, + animation::{ + RetainedContent, RetainedExitFrame, RetainedExitLayer, RetainedSurface, + RetainedToplevel, + }, cmm::cmm_render_intent::RenderIntent, gfx_api::{AcquireSync, AlphaMode, BufferResv, GfxApiOpt, ReleaseSync, SampleRect}, ifs::wl_surface::{ @@ -201,6 +204,9 @@ impl Renderer<'_> { self.render_workspace(&ws, x, y); } } + let now = self.state.now_nsec(); + let exit_frames = self.state.animations.exit_frames(now); + self.render_exit_frames(&exit_frames, RetainedExitLayer::Tiled, &opos); macro_rules! render_stacked { ($stack:expr) => { for stacked in $stack.iter() { @@ -221,6 +227,7 @@ impl Renderer<'_> { }; } render_stacked!(self.state.root.stacked); + self.render_exit_frames(&exit_frames, RetainedExitLayer::Floating, &opos); // Flush RoundedFillRect ops from container/float borders so they don't // sort after (and render on top of) layer-shell CopyTexture ops. self.base.sync(); @@ -504,6 +511,68 @@ impl Renderer<'_> { self.render_retained_surface_scaled(&retained.surface, x, y, None, bounds); } + fn render_exit_frames( + &mut self, + frames: &[RetainedExitFrame], + layer: RetainedExitLayer, + output_rect: &Rect, + ) { + for frame in frames { + if frame.layer != layer || !frame.rect.intersects(output_rect) { + continue; + } + self.render_exit_frame(frame, output_rect); + } + } + + fn render_exit_frame(&mut self, frame: &RetainedExitFrame, output_rect: &Rect) { + let (x, y) = output_rect.translate(frame.rect.x1(), frame.rect.y1()); + let inset = frame.frame_inset; + if inset > 0 { + let color = if frame.active { + self.state.theme.colors.active_border.get() + } else { + self.state.theme.colors.border.get() + }; + self.render_rounded_frame( + Rect::new_sized_saturating(0, 0, frame.rect.width(), frame.rect.height()), + &color, + self.state.theme.corner_radius.get(), + inset, + x, + y, + ); + } + let body = Rect::new_sized_saturating( + x + inset, + y + inset, + frame.rect.width() - 2 * inset, + frame.rect.height() - 2 * inset, + ); + if body.is_empty() { + return; + } + let bounds = self.base.scale_rect(body); + self.stretch = if frame.source_body_size != body.size() { + Some(self.base.scale_point(body.width(), body.height())) + } else { + None + }; + if inset > 0 && !self.state.theme.corner_radius.get().is_zero() { + let inner_cr = self.scale_corner_radius( + self.state + .theme + .corner_radius + .get() + .expanded_by(-(inset as f32)), + ); + self.corner_radius = Some(inner_cr); + } + self.render_retained_toplevel(&frame.retained, body.x1(), body.y1(), Some(&bounds)); + self.stretch = None; + self.corner_radius = None; + } + fn render_retained_surface_scaled( &mut self, retained: &RetainedSurface, diff --git a/src/state.rs b/src/state.rs index a94c8a37..5ccb0883 100644 --- a/src/state.rs +++ b/src/state.rs @@ -3,8 +3,8 @@ use { acceptor::Acceptor, allocator::BufferObject, animation::{ - AnimationCurve, AnimationState, AnimationTick, RetainedToplevel, expand_damage_rect, - spawn_in_start_rect, + AnimationCurve, AnimationState, AnimationTick, RetainedExitLayer, RetainedToplevel, + expand_damage_rect, spawn_in_start_rect, }, async_engine::{AsyncEngine, SpawnedFuture}, backend::{ @@ -1572,6 +1572,36 @@ impl State { } } + pub fn queue_spawn_out_animation( + self: &Rc, + from: Rect, + frame_inset: i32, + retained: Rc, + active: bool, + layer: RetainedExitLayer, + ) { + if !self.animations.enabled.get() || from.is_empty() { + return; + } + let now = self.now_nsec(); + let started = self.animations.set_spawn_out( + from, + frame_inset, + retained, + active, + layer, + now, + self.animations.duration_ms.get(), + ); + if started { + self.damage(expand_damage_rect( + from, + self.theme.sizes.border_width.get().max(0), + )); + self.ensure_animation_tick(); + } + } + pub fn set_animations_enabled(&self, enabled: bool) { if self.animations.enabled.replace(enabled) && !enabled { self.animations.clear(); diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index dc9927f9..41db95d1 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -1,6 +1,6 @@ use { crate::{ - animation::RetainedToplevel, + animation::{RetainedExitLayer, RetainedToplevel}, client::{Client, ClientId}, criteria::{ CritDestroyListener, CritMatcherId, @@ -988,6 +988,62 @@ impl ToplevelData { self.mapped_during_iteration.get() == self.state.eng.iteration() } + pub fn queue_spawn_out(&self, node: &dyn ToplevelNode, retained: Option>) { + if !self.kind.is_app_window() + || !self.visible.get() + || self.is_fullscreen.get() + || node.node_is_container() + { + return; + } + let Some(retained) = retained else { + return; + }; + let bw = self.state.theme.sizes.border_width.get().max(0); + let now = self.state.now_nsec(); + let (outer, frame_inset, layer) = if self.parent_is_float.get() { + let Some(float) = self.float.get() else { + return; + }; + ( + self.state + .animations + .visual_rect(float.node_id(), float.position.get(), now), + bw, + RetainedExitLayer::Floating, + ) + } else { + let body = + self.state + .animations + .visual_rect(self.node_id, node.node_absolute_position(), now); + if body.is_empty() { + return; + } + if self.state.theme.sizes.gap.get() != 0 { + ( + Rect::new_sized_saturating( + body.x1() - bw, + body.y1() - bw, + body.width() + 2 * bw, + body.height() + 2 * bw, + ), + bw, + RetainedExitLayer::Tiled, + ) + } else { + (body, 0, RetainedExitLayer::Tiled) + } + }; + self.state.clone().queue_spawn_out_animation( + outer, + frame_inset, + retained, + self.active(), + layer, + ); + } + pub fn set_content_type(&self, content_type: Option) { if self.content_type.replace(content_type) != content_type { self.property_changed(TL_CHANGED_CONTENT_TY); From cf61c080b6d177ff034c00736f4b1acfdb3f1530 Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 17:19:46 +1000 Subject: [PATCH 08/47] Add custom animation curve config --- docs/window-animations-plan.md | 9 +- jay-config/src/_private/client.rs | 4 + jay-config/src/_private/ipc.rs | 6 + jay-config/src/lib.rs | 8 + src/animation.rs | 170 +++++++++++++++---- src/config/handler.rs | 9 + src/state.rs | 8 + toml-config/src/config.rs | 21 ++- toml-config/src/config/parsers/animations.rs | 57 ++++++- toml-config/src/lib.rs | 46 +++-- 10 files changed, 281 insertions(+), 57 deletions(-) diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index 74f6564c..6b2261ff 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -221,10 +221,15 @@ Initial TOML shape: enabled = false duration-ms = 160 curve = "ease-out" +# or: +curve = [0.25, 0.1, 0.25, 1.0] ``` -Bezier curves should be analyzed at configuration time and stored in a form that -is cheap to evaluate during rendering. +Bezier curves are analyzed when configuration is applied and stored as a +piecewise curve that is cheap to evaluate during rendering. Custom curves use +CSS cubic-bezier semantics: `(0, 0)` and `(1, 1)` are implicit, while the four +configured numbers are `x1`, `y1`, `x2`, and `y2`. The x control points must be +between `0` and `1`. ## Existing Note diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index 721b5097..09a96527 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -1035,6 +1035,10 @@ impl ConfigClient { self.send(&ClientMessage::SetAnimationCurve { curve }); } + pub fn set_animation_cubic_bezier(&self, x1: f32, y1: f32, x2: f32, y2: f32) { + self.send(&ClientMessage::SetAnimationCubicBezier { x1, y1, x2, y2 }); + } + pub fn set_color_management_enabled(&self, enabled: bool) { self.send(&ClientMessage::SetColorManagementEnabled { enabled }); } diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index d090ba0c..f0c8aa67 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -554,6 +554,12 @@ pub enum ClientMessage<'a> { SetAnimationCurve { curve: u32, }, + SetAnimationCubicBezier { + x1: f32, + y1: f32, + x2: f32, + y2: f32, + }, SetXScalingMode { mode: XScalingMode, }, diff --git a/jay-config/src/lib.rs b/jay-config/src/lib.rs index 44546ce0..fc8915ee 100644 --- a/jay-config/src/lib.rs +++ b/jay-config/src/lib.rs @@ -320,6 +320,14 @@ pub fn set_animation_curve(curve: AnimationCurve) { get!().set_animation_curve(curve.0); } +/// Sets a custom cubic-bezier curve used by tiled window animations. +/// +/// `x1` and `x2` must be between `0.0` and `1.0`. The curve starts at `(0, 0)` +/// and ends at `(1, 1)`. +pub fn set_animation_cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32) { + get!().set_animation_cubic_bezier(x1, y1, x2, y2); +} + /// Enables or disables the color-management protocol. /// /// The default is `false`. diff --git a/src/animation.rs b/src/animation.rs index 199e142b..92cbfa74 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -17,41 +17,113 @@ use { }; const DEFAULT_DURATION_MS: u32 = 160; +const CURVE_MAX_POINTS: usize = 33; +const CURVE_FLATNESS_EPSILON: f32 = 0.001; +const CURVE_MAX_DEPTH: u8 = 8; const SPAWN_IN_INITIAL_SCALE_NUMERATOR: i32 = 4; const SPAWN_IN_INITIAL_SCALE_DENOMINATOR: i32 = 5; -#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[derive(Copy, Clone, Debug, PartialEq)] pub enum AnimationCurve { Linear, - Ease, - EaseIn, - EaseOut, - EaseInOut, + Piecewise(PiecewiseCurve), } impl AnimationCurve { pub fn from_config(value: u32) -> Self { match value { 0 => Self::Linear, - 1 => Self::Ease, - 2 => Self::EaseIn, - 4 => Self::EaseInOut, - _ => Self::EaseOut, + 1 => Self::from_cubic_bezier(0.25, 0.1, 0.25, 1.0).unwrap(), + 2 => Self::from_cubic_bezier(0.42, 0.0, 1.0, 1.0).unwrap(), + 4 => Self::from_cubic_bezier(0.42, 0.0, 0.58, 1.0).unwrap(), + _ => Self::from_cubic_bezier(0.0, 0.0, 0.58, 1.0).unwrap(), } } + pub fn from_cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32) -> Option { + if !x1.is_finite() + || !y1.is_finite() + || !x2.is_finite() + || !y2.is_finite() + || !(0.0..=1.0).contains(&x1) + || !(0.0..=1.0).contains(&x2) + { + return None; + } + Some(Self::Piecewise(PiecewiseCurve::from_cubic_bezier( + x1, y1, x2, y2, + ))) + } + fn sample(self, t: f64) -> f64 { let t = t.clamp(0.0, 1.0); match self { Self::Linear => t, - Self::Ease => cubic_bezier(0.25, 0.1, 0.25, 1.0, t), - Self::EaseIn => cubic_bezier(0.42, 0.0, 1.0, 1.0, t), - Self::EaseOut => cubic_bezier(0.0, 0.0, 0.58, 1.0, t), - Self::EaseInOut => cubic_bezier(0.42, 0.0, 0.58, 1.0, t), + Self::Piecewise(curve) => curve.sample(t as f32) as f64, } } } +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct PiecewiseCurve { + len: u8, + points: [CurvePoint; CURVE_MAX_POINTS], +} + +impl PiecewiseCurve { + fn from_cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32) -> Self { + let mut points = Vec::with_capacity(CURVE_MAX_POINTS); + let p0 = cubic_bezier_point(x1, y1, x2, y2, 0.0); + let p1 = cubic_bezier_point(x1, y1, x2, y2, 1.0); + points.push(p0); + flatten_cubic_bezier(&mut points, (x1, y1, x2, y2), 0.0, p0, 1.0, p1, 0); + let mut array = [CurvePoint::default(); CURVE_MAX_POINTS]; + let len = points.len().min(CURVE_MAX_POINTS); + array[..len].copy_from_slice(&points[..len]); + Self { + len: len as u8, + points: array, + } + } + + fn sample(self, x: f32) -> f32 { + let len = self.len as usize; + if len <= 1 { + return x; + } + let points = &self.points[..len]; + if x <= points[0].x { + return points[0].y; + } + if x >= points[len - 1].x { + return points[len - 1].y; + } + let mut lo = 0; + let mut hi = len - 1; + while lo + 1 < hi { + let mid = (lo + hi) / 2; + if points[mid].x <= x { + lo = mid; + } else { + hi = mid; + } + } + let from = points[lo]; + let to = points[hi]; + if to.x <= from.x { + return to.y; + } + let t = (x - from.x) / (to.x - from.x); + from.y + (to.y - from.y) * t + } +} + +#[derive(Copy, Clone, Debug, Default, PartialEq)] +struct CurvePoint { + x: f32, + y: f32, +} + pub struct AnimationState { pub enabled: Cell, pub duration_ms: Cell, @@ -187,7 +259,7 @@ impl Default for AnimationState { Self { enabled: Cell::new(false), duration_ms: Cell::new(DEFAULT_DURATION_MS), - curve: Cell::new(AnimationCurve::EaseOut), + curve: Cell::new(AnimationCurve::from_config(3)), windows: Default::default(), exits: Default::default(), tick: Default::default(), @@ -527,27 +599,43 @@ pub(crate) fn expand_damage_rect(rect: Rect, width: i32) -> Rect { ) } -fn cubic_bezier(x1: f64, y1: f64, x2: f64, y2: f64, x: f64) -> f64 { - fn bezier(a: f64, b: f64, t: f64) -> f64 { +fn flatten_cubic_bezier( + points: &mut Vec, + controls: (f32, f32, f32, f32), + t0: f32, + p0: CurvePoint, + t1: f32, + p1: CurvePoint, + depth: u8, +) { + let tm = (t0 + t1) * 0.5; + let pm = cubic_bezier_point(controls.0, controls.1, controls.2, controls.3, tm); + let projected_y = if p1.x <= p0.x { + (p0.y + p1.y) * 0.5 + } else { + let tx = (pm.x - p0.x) / (p1.x - p0.x); + p0.y + (p1.y - p0.y) * tx + }; + if (pm.y - projected_y).abs() > CURVE_FLATNESS_EPSILON + && depth < CURVE_MAX_DEPTH + && points.len() + 2 < CURVE_MAX_POINTS + { + flatten_cubic_bezier(points, controls, t0, p0, tm, pm, depth + 1); + flatten_cubic_bezier(points, controls, tm, pm, t1, p1, depth + 1); + } else { + points.push(p1); + } +} + +fn cubic_bezier_point(x1: f32, y1: f32, x2: f32, y2: f32, t: f32) -> CurvePoint { + fn bezier(a: f32, b: f32, t: f32) -> f32 { let inv = 1.0 - t; 3.0 * inv * inv * t * a + 3.0 * inv * t * t * b + t * t * t } - let mut lo = 0.0; - let mut hi = 1.0; - let mut t = x; - for _ in 0..12 { - let bx = bezier(x1, x2, t); - if (bx - x).abs() < 0.000_001 { - break; - } - if bx < x { - lo = t; - } else { - hi = t; - } - t = (lo + hi) * 0.5; + CurvePoint { + x: bezier(x1, x2, t), + y: bezier(y1, y2, t), } - bezier(y1, y2, t) } #[cfg(test)] @@ -561,6 +649,26 @@ mod tests { assert_eq!(lerp_rect(a, b, 0.25), lerp_rect(b, a, 0.75)); } + #[test] + fn custom_cubic_bezier_curve_is_prepared() { + let curve = AnimationCurve::from_cubic_bezier(0.0, 0.0, 1.0, 1.0).unwrap(); + assert_eq!(curve.sample(0.0), 0.0); + assert_eq!(curve.sample(1.0), 1.0); + assert!((curve.sample(0.5) - 0.5).abs() < 0.001); + + let ease_out = AnimationCurve::from_cubic_bezier(0.0, 0.0, 0.58, 1.0).unwrap(); + let mid = ease_out.sample(0.5); + assert!(mid > 0.5); + assert!(mid < 1.0); + } + + #[test] + fn invalid_custom_cubic_bezier_curve_is_rejected() { + assert!(AnimationCurve::from_cubic_bezier(-0.1, 0.0, 0.58, 1.0).is_none()); + assert!(AnimationCurve::from_cubic_bezier(0.0, 0.0, 1.1, 1.0).is_none()); + assert!(AnimationCurve::from_cubic_bezier(0.0, f32::NAN, 0.58, 1.0).is_none()); + } + #[test] fn unchanged_target_does_not_restart() { let state = AnimationState::default(); diff --git a/src/config/handler.rs b/src/config/handler.rs index 138b4416..88a64d1d 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -1005,6 +1005,12 @@ impl ConfigProxyHandler { self.state.set_animation_curve(curve); } + fn handle_set_animation_cubic_bezier(&self, x1: f32, y1: f32, x2: f32, y2: f32) { + if !self.state.set_animation_cubic_bezier(x1, y1, x2, y2) { + log::warn!("Ignoring invalid animation cubic-bezier curve"); + } + } + fn handle_set_direct_scanout_enabled( &self, device: Option, @@ -3243,6 +3249,9 @@ impl ConfigProxyHandler { self.handle_set_animation_duration_ms(duration_ms) } ClientMessage::SetAnimationCurve { curve } => self.handle_set_animation_curve(curve), + ClientMessage::SetAnimationCubicBezier { x1, y1, x2, y2 } => { + self.handle_set_animation_cubic_bezier(x1, y1, x2, y2) + } ClientMessage::SetXScalingMode { mode } => self .handle_set_x_scaling_mode(mode) .wrn("set_x_scaling_mode")?, diff --git a/src/state.rs b/src/state.rs index 5ccb0883..adf7b2ca 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1619,6 +1619,14 @@ impl State { .set(AnimationCurve::from_config(curve)); } + pub fn set_animation_cubic_bezier(&self, x1: f32, y1: f32, x2: f32, y2: f32) -> bool { + let Some(curve) = AnimationCurve::from_cubic_bezier(x1, y1, x2, y2) else { + return false; + }; + self.animations.curve.set(curve); + true + } + pub fn with_layout_animations(&self, f: impl FnOnce() -> T) -> T { let prev_requested = self.layout_animations_requested.replace(true); let prev_active = self.layout_animations_active.replace(true); diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index d860d656..8b01c1f4 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -270,7 +270,13 @@ pub struct UiDrag { pub struct Animations { pub enabled: Option, pub duration_ms: Option, - pub curve: Option, + pub curve: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum AnimationCurveConfig { + Preset(String), + CubicBezier([f32; 4]), } #[derive(Debug, Clone)] @@ -659,3 +665,16 @@ fn default_config_parses() { let input = include_bytes!("default-config.toml"); parse_config(input, &Default::default(), |_| ()).unwrap(); } + +#[test] +fn custom_animation_curve_parses() { + let input = b" + [animations] + curve = [0.25, 0.1, 0.25, 1.0] + "; + let config = parse_config(input, &Default::default(), |_| ()).unwrap(); + assert_eq!( + config.animations.curve, + Some(AnimationCurveConfig::CubicBezier([0.25, 0.1, 0.25, 1.0])) + ); +} diff --git a/toml-config/src/config/parsers/animations.rs b/toml-config/src/config/parsers/animations.rs index a8abdf89..938ba7b9 100644 --- a/toml-config/src/config/parsers/animations.rs +++ b/toml-config/src/config/parsers/animations.rs @@ -1,13 +1,13 @@ use { crate::{ config::{ - Animations, + AnimationCurveConfig, Animations, context::Context, - extractor::{Extractor, ExtractorError, bol, n32, opt, recover, str}, + extractor::{Extractor, ExtractorError, bol, n32, opt, recover, val}, parser::{DataType, ParseResult, Parser, UnexpectedDataType}, }, toml::{ - toml_span::{DespanExt, Span, Spanned}, + toml_span::{DespanExt, Span, Spanned, SpannedExt}, toml_value::Value, }, }, @@ -21,6 +21,14 @@ pub enum AnimationsParserError { Expected(#[from] UnexpectedDataType), #[error(transparent)] Extract(#[from] ExtractorError), + #[error("Expected animation curve to be a string or an array")] + CurveType, + #[error("Cubic-bezier animation curves must contain exactly four values")] + CubicBezierLen, + #[error("Cubic-bezier animation curve entries must be finite floats or integers")] + CubicBezierValue, + #[error("Cubic-bezier x control points must be between 0 and 1")] + CubicBezierXRange, } pub struct AnimationsParser<'a>(pub &'a Context<'a>); @@ -39,12 +47,51 @@ impl Parser for AnimationsParser<'_> { let (enabled, duration_ms, curve) = ext.extract(( recover(opt(bol("enabled"))), recover(opt(n32("duration-ms"))), - recover(opt(str("curve"))), + opt(val("curve")), ))?; + let curve = match curve { + Some(curve) => Some(parse_curve(curve)?), + None => None, + }; Ok(Animations { enabled: enabled.despan(), duration_ms: duration_ms.despan(), - curve: curve.despan().map(|s| s.to_string()), + curve, }) } } + +fn parse_curve( + curve: Spanned<&Value>, +) -> Result> { + match curve.value { + Value::String(s) => Ok(AnimationCurveConfig::Preset(s.clone())), + Value::Array(values) => parse_cubic_bezier(curve.span, values), + _ => Err(AnimationsParserError::CurveType.spanned(curve.span)), + } +} + +fn parse_cubic_bezier( + span: Span, + values: &[Spanned], +) -> Result> { + if values.len() != 4 { + return Err(AnimationsParserError::CubicBezierLen.spanned(span)); + } + let mut points = [0.0; 4]; + for (idx, value) in values.iter().enumerate() { + let f = match value.value { + Value::Float(f) => f, + Value::Integer(i) => i as f64, + _ => return Err(AnimationsParserError::CubicBezierValue.spanned(value.span)), + }; + if !f.is_finite() { + return Err(AnimationsParserError::CubicBezierValue.spanned(value.span)); + } + points[idx] = f as f32; + } + if !(0.0..=1.0).contains(&points[0]) || !(0.0..=1.0).contains(&points[2]) { + return Err(AnimationsParserError::CubicBezierXRange.spanned(span)); + } + Ok(AnimationCurveConfig::CubicBezier(points)) +} diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index 1b057985..605e1fd1 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -13,9 +13,9 @@ mod toml; use { crate::{ config::{ - Action, ClientRule, Config, ConfigConnector, ConfigDrmDevice, ConfigKeymap, - ConnectorMatch, DrmDeviceMatch, Exec, Input, InputMatch, Output, OutputMatch, - SimpleCommand, Status, Theme, WindowRule, parse_config, + Action, AnimationCurveConfig, ClientRule, Config, ConfigConnector, ConfigDrmDevice, + ConfigKeymap, ConnectorMatch, DrmDeviceMatch, Exec, Input, InputMatch, Output, + OutputMatch, SimpleCommand, Status, Theme, WindowRule, parse_config, }, rules::{MatcherTemp, RuleMapper}, shortcuts::ModeState, @@ -37,8 +37,8 @@ use { is_reload, keyboard::Keymap, logging::{clean_logs_older_than, set_log_level}, - on_devices_enumerated, on_idle, on_unload, quit, reload, set_animation_curve, - set_animation_duration_ms, set_animations_enabled, set_autotile, + on_devices_enumerated, on_idle, on_unload, quit, reload, set_animation_cubic_bezier, + set_animation_curve, set_animation_duration_ms, set_animations_enabled, set_autotile, set_color_management_enabled, set_corner_radius, set_default_workspace_capture, set_explicit_sync_enabled, set_float_above_fullscreen, set_floating_titles, set_idle, set_idle_grace_period, set_middle_click_paste_enabled, set_show_bar, @@ -1652,20 +1652,30 @@ fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc Some(AnimationCurve::LINEAR), - "ease" => Some(AnimationCurve::EASE), - "ease-in" => Some(AnimationCurve::EASE_IN), - "ease-out" => Some(AnimationCurve::EASE_OUT), - "ease-in-out" => Some(AnimationCurve::EASE_IN_OUT), - _ => { - log::warn!("Unknown animation curve: {curve_name}"); - None + match config + .animations + .curve + .unwrap_or_else(|| AnimationCurveConfig::Preset("ease-out".to_string())) + { + AnimationCurveConfig::Preset(curve_name) => { + let curve = match curve_name.as_str() { + "linear" => Some(AnimationCurve::LINEAR), + "ease" => Some(AnimationCurve::EASE), + "ease-in" => Some(AnimationCurve::EASE_IN), + "ease-out" => Some(AnimationCurve::EASE_OUT), + "ease-in-out" => Some(AnimationCurve::EASE_IN_OUT), + _ => { + log::warn!("Unknown animation curve: {curve_name}"); + None + } + }; + if let Some(curve) = curve { + set_animation_curve(curve); + } + } + AnimationCurveConfig::CubicBezier([x1, y1, x2, y2]) => { + set_animation_cubic_bezier(x1, y1, x2, y2); } - }; - if let Some(curve) = curve { - set_animation_curve(curve); } if let Some(xwayland) = config.xwayland { if let Some(enabled) = xwayland.enabled { From 501c5088396d73d7e7c26cc57fa8cc6aed4845e2 Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 17:22:04 +1000 Subject: [PATCH 09/47] Test retained spawn-out animation frames --- src/animation.rs | 51 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/animation.rs b/src/animation.rs index 92cbfa74..2f62d5ab 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -642,6 +642,27 @@ fn cubic_bezier_point(x1: f32, y1: f32, x2: f32, y2: f32, t: f32) -> CurvePoint mod tests { use super::*; + use crate::cmm::cmm_manager::ColorManager; + + fn retained_for_tests() -> Rc { + let color_manager = ColorManager::new(); + Rc::new(RetainedToplevel { + offset: (0, 0), + surface: RetainedSurface { + offset: (0, 0), + size: (100, 100), + content: RetainedContent::Color { + color: Color::SOLID_BLACK, + alpha: None, + color_description: color_manager.srgb_gamma22().clone(), + render_intent: RenderIntent::Perceptual, + }, + below: vec![], + above: vec![], + }, + }) + } + #[test] fn linear_rect_interpolation_is_symmetric() { let a = Rect::new_sized_saturating(0, 0, 100, 100); @@ -669,6 +690,36 @@ mod tests { assert!(AnimationCurve::from_cubic_bezier(0.0, f32::NAN, 0.58, 1.0).is_none()); } + #[test] + fn spawn_out_frames_shrink_linearly_and_expire() { + let state = AnimationState::default(); + let retained = retained_for_tests(); + let from = Rect::new_sized_saturating(10, 20, 100, 80); + let to = spawn_in_start_rect(from); + assert!(state.set_spawn_out( + from, + 2, + retained.clone(), + true, + RetainedExitLayer::Floating, + 0, + 160 + )); + + let start = state.exit_frames(0); + assert_eq!(start.len(), 1); + assert_eq!(start[0].rect, from); + assert_eq!(start[0].source_body_size, (96, 76)); + assert!(start[0].active); + assert_eq!(start[0].layer, RetainedExitLayer::Floating); + assert!(Rc::ptr_eq(&start[0].retained, &retained)); + + let middle = state.exit_frames(80_000_000); + assert_eq!(middle.len(), 1); + assert_eq!(middle[0].rect, lerp_rect(from, to, 0.5)); + assert!(state.exit_frames(160_000_000).is_empty()); + } + #[test] fn unchanged_target_does_not_restart() { let state = AnimationState::default(); From 2115518edf0479080764e804adb960084264b79e Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 17:24:51 +1000 Subject: [PATCH 10/47] Document animation TOML settings --- toml-spec/spec/spec.generated.json | 47 +++++++++++++ toml-spec/spec/spec.generated.md | 108 ++++++++++++++++++++++++++++- toml-spec/spec/spec.yaml | 84 ++++++++++++++++++++++ 3 files changed, 238 insertions(+), 1 deletion(-) diff --git a/toml-spec/spec/spec.generated.json b/toml-spec/spec/spec.generated.json index 930ad697..b7b5ce2d 100644 --- a/toml-spec/spec/spec.generated.json +++ b/toml-spec/spec/spec.generated.json @@ -641,6 +641,49 @@ } ] }, + "AnimationCurve": { + "description": "Describes a window animation curve.\n", + "anyOf": [ + { + "type": "string", + "description": "One of the supported curve presets.\n", + "enum": [ + "linear", + "ease", + "ease-in", + "ease-out", + "ease-in-out" + ] + }, + { + "type": "array", + "description": "A custom CSS-style cubic-bezier curve as four numbers:\n`x1`, `y1`, `x2`, and `y2`.\n\nThe implicit endpoints are `(0, 0)` and `(1, 1)`. `x1` and `x2` must\nbe between `0` and `1`.\n", + "items": { + "type": "number", + "description": "" + } + } + ] + }, + "Animations": { + "description": "Describes window animation settings.\n\n- Example:\n\n ```toml\n [animations]\n enabled = true\n duration-ms = 160\n curve = [0.25, 0.1, 0.25, 1.0]\n ```\n", + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enables or disables window animations.\n\nThe default is `false`.\n" + }, + "duration-ms": { + "type": "integer", + "description": "Sets the animation duration in milliseconds.\n\nThe default is `160`.\n" + }, + "curve": { + "description": "Sets the animation curve.\n\nThe default is `ease-out`.\n", + "$ref": "#/$defs/AnimationCurve" + } + }, + "required": [] + }, "BarPosition": { "type": "string", "description": "The position of the bar.", @@ -1085,6 +1128,10 @@ "description": "Configures the ui-drag settings.\n\n- Example:\n\n ```toml\n ui-drag = { enabled = false, threshold = 20 }\n ```\n", "$ref": "#/$defs/UiDrag" }, + "animations": { + "description": "Configures window animations.\n\nAnimations are disabled by default.\n\n- Example:\n\n ```toml\n [animations]\n enabled = true\n duration-ms = 160\n curve = \"ease-out\"\n ```\n", + "$ref": "#/$defs/Animations" + }, "xwayland": { "description": "Configures the Xwayland settings.\n\n- Example:\n\n ```toml\n xwayland = { scaling-mode = \"downscaled\" }\n ```\n", "$ref": "#/$defs/Xwayland" diff --git a/toml-spec/spec/spec.generated.md b/toml-spec/spec/spec.generated.md index 43e9f20d..a1c4ff29 100644 --- a/toml-spec/spec/spec.generated.md +++ b/toml-spec/spec/spec.generated.md @@ -942,6 +942,96 @@ This table is a tagged union. The variant is determined by the `type` field. It The numbers should be integers. + +### `AnimationCurve` + +Describes a window animation curve. + +Values of this type should have one of the following forms: + +#### A string + +One of the supported curve presets. + +The string should have one of the following values: + +- `linear`: + + No easing. + +- `ease`: + + The CSS `ease` curve. + +- `ease-in`: + + The CSS `ease-in` curve. + +- `ease-out`: + + The CSS `ease-out` curve. + +- `ease-in-out`: + + The CSS `ease-in-out` curve. + + +#### An array + +A custom CSS-style cubic-bezier curve as four numbers: +`x1`, `y1`, `x2`, and `y2`. + +The implicit endpoints are `(0, 0)` and `(1, 1)`. `x1` and `x2` must +be between `0` and `1`. + +Each element of this array should be a number. + + + +### `Animations` + +Describes window animation settings. + +- Example: + + ```toml + [animations] + enabled = true + duration-ms = 160 + curve = [0.25, 0.1, 0.25, 1.0] + ``` + +Values of this type should be tables. + +The table has the following fields: + +- `enabled` (optional): + + Enables or disables window animations. + + The default is `false`. + + The value of this field should be a boolean. + +- `duration-ms` (optional): + + Sets the animation duration in milliseconds. + + The default is `160`. + + The value of this field should be a number. + + The numbers should be integers. + +- `curve` (optional): + + Sets the animation curve. + + The default is `ease-out`. + + The value of this field should be a [AnimationCurve](#types-AnimationCurve). + + ### `BarPosition` @@ -2169,6 +2259,23 @@ The table has the following fields: The value of this field should be a [UiDrag](#types-UiDrag). +- `animations` (optional): + + Configures window animations. + + Animations are disabled by default. + + - Example: + + ```toml + [animations] + enabled = true + duration-ms = 160 + curve = "ease-out" + ``` + + The value of this field should be a [Animations](#types-Animations). + - `xwayland` (optional): Configures the Xwayland settings. @@ -5670,4 +5777,3 @@ The table has the following fields: The value of this field should be a [XScalingMode](#types-XScalingMode). - diff --git a/toml-spec/spec/spec.yaml b/toml-spec/spec/spec.yaml index aa6789da..7d2abfb7 100644 --- a/toml-spec/spec/spec.yaml +++ b/toml-spec/spec/spec.yaml @@ -2942,6 +2942,22 @@ Config: ```toml ui-drag = { enabled = false, threshold = 20 } ``` + animations: + ref: Animations + required: false + description: | + Configures window animations. + + Animations are disabled by default. + + - Example: + + ```toml + [animations] + enabled = true + duration-ms = 160 + curve = "ease-out" + ``` xwayland: ref: Xwayland required: false @@ -3655,6 +3671,74 @@ UiDrag: The default is `10`. +Animations: + kind: table + description: | + Describes window animation settings. + + - Example: + + ```toml + [animations] + enabled = true + duration-ms = 160 + curve = [0.25, 0.1, 0.25, 1.0] + ``` + fields: + enabled: + kind: boolean + required: false + description: | + Enables or disables window animations. + + The default is `false`. + duration-ms: + kind: number + integer_only: true + required: false + description: | + Sets the animation duration in milliseconds. + + The default is `160`. + curve: + ref: AnimationCurve + required: false + description: | + Sets the animation curve. + + The default is `ease-out`. + + +AnimationCurve: + kind: variable + description: | + Describes a window animation curve. + variants: + - kind: string + description: | + One of the supported curve presets. + values: + - value: linear + description: No easing. + - value: ease + description: The CSS `ease` curve. + - value: ease-in + description: The CSS `ease-in` curve. + - value: ease-out + description: The CSS `ease-out` curve. + - value: ease-in-out + description: The CSS `ease-in-out` curve. + - kind: array + items: + kind: number + description: | + A custom CSS-style cubic-bezier curve as four numbers: + `x1`, `y1`, `x2`, and `y2`. + + The implicit endpoints are `(0, 0)` and `(1, 1)`. `x1` and `x2` must + be between `0` and `1`. + + Xwayland: kind: table description: | From 41d2fef1775568dd106047859cbb0046f5dc3d22 Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 18:23:33 +1000 Subject: [PATCH 11/47] Add multiphase no-overlap planner groundwork --- docs/window-animations-plan.md | 19 ++ src/animation.rs | 2 + src/animation/multiphase.rs | 608 +++++++++++++++++++++++++++++++++ 3 files changed, 629 insertions(+) create mode 100644 src/animation/multiphase.rs diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index 6b2261ff..d46b3bdb 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -30,6 +30,15 @@ be handled deliberately. partial inspiration for tiling style and titlebar/grouping behavior. - Mono mode should mostly avoid animations. Exceptions are windows entering or exiting mono mode, where a visual transition can clarify the hierarchy change. +- Multiphase shrink steps should not normally need to reduce a tiled window far + below roughly one quarter of the relevant full size. The implementation may + enforce a conservative sanity minimum, and pathological cases may fall back. +- If the no-overlap planner cannot produce a legal sequence, only the affected + group should fall back to linear animation. This is expected to be rare for + valid tiling layouts. +- When entering mono mode, the active child should animate to the mono geometry. + Inactive siblings may snap invisible. Floats may overlap normally and do not + need the no-overlap planner. ## Texture Freezing Decision @@ -188,12 +197,22 @@ Preferred approach: animated visual actors. - Derive every leaf's per-phase rect from one phase schedule so parent and child effects cannot compose into forbidden motion. +- Build the planner as pure geometry first. Live integration should collect + eligible leaf `(old, new)` rects across a command-driven layout pass, then + submit planner-produced phases as a batch. Per-node `tl_change_extents` calls + are too incremental to plan safely by themselves. - Add container-level grouping only after the leaf planner proves correct. - Include hierarchy-transition metadata in the planner input: source parent, target parent, source depth, target depth, and whether the window is ascending, descending, or staying at the same hierarchy level. - For mono containers, suppress ordinary in-mono focus/tab changes. Animate only transitions into mono, out of mono, or across the mono boundary. +- When entering mono, the active child animates to the full mono area and + inactive siblings snap invisible. When exiting mono, ordinary tiled geometry + may animate from the mono child where that produces a clear hierarchy + transition. +- If a legal no-overlap sequence cannot be found for a group, fall back to the + linear animator for that group only. Float windows are outside this invariant. Tests: diff --git a/src/animation.rs b/src/animation.rs index 2f62d5ab..847b8f6d 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -16,6 +16,8 @@ use { }, }; +pub mod multiphase; + const DEFAULT_DURATION_MS: u32 = 160; const CURVE_MAX_POINTS: usize = 33; const CURVE_FLATNESS_EPSILON: f32 = 0.001; diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs new file mode 100644 index 00000000..3130bb80 --- /dev/null +++ b/src/animation/multiphase.rs @@ -0,0 +1,608 @@ +use {crate::rect::Rect, crate::tree::NodeId}; + +const MIN_SHRINK_DENOMINATOR: i32 = 4; +const PHASE_VALIDATION_SAMPLES: [f64; 5] = [0.0, 0.25, 0.5, 0.75, 1.0]; + +#[derive(Clone, Debug)] +pub struct MultiphaseRequest { + pub bounds: Rect, + pub windows: Vec, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct MultiphaseWindow { + pub node_id: NodeId, + pub from: Rect, + pub to: Rect, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MultiphasePlan { + pub phases: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MultiphasePhase { + pub action: PhaseAction, + pub steps: Vec, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct MultiphaseStep { + pub node_id: NodeId, + pub from: Rect, + pub to: Rect, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct PhaseAction { + pub kind: PhaseKind, + pub axis: PhaseAxis, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum PhaseKind { + Move, + Scale, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum PhaseAxis { + Horizontal, + Vertical, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum MultiphaseError { + EmptyBounds, + EmptyWindow, + DuplicateWindow, + InitialOverlap, + FinalOverlap, + NoPlan, +} + +pub fn plan_no_overlap(request: &MultiphaseRequest) -> Result { + validate_request(request)?; + if request + .windows + .iter() + .all(|window| window.from == window.to) + { + return Ok(MultiphasePlan { phases: vec![] }); + } + if let Some(plan) = plan_forward(request) { + return Ok(plan); + } + let reversed = reverse_request(request); + if let Some(plan) = plan_forward(&reversed) { + return Ok(reverse_plan(plan)); + } + Err(MultiphaseError::NoPlan) +} + +fn validate_request(request: &MultiphaseRequest) -> Result<(), MultiphaseError> { + if request.bounds.is_empty() { + return Err(MultiphaseError::EmptyBounds); + } + for (idx, window) in request.windows.iter().enumerate() { + if window.from.is_empty() || window.to.is_empty() { + return Err(MultiphaseError::EmptyWindow); + } + for other in &request.windows[..idx] { + if other.node_id == window.node_id { + return Err(MultiphaseError::DuplicateWindow); + } + } + } + if overlaps(request.windows.iter().map(|window| window.from)) { + return Err(MultiphaseError::InitialOverlap); + } + if overlaps(request.windows.iter().map(|window| window.to)) { + return Err(MultiphaseError::FinalOverlap); + } + Ok(()) +} + +fn plan_forward(request: &MultiphaseRequest) -> Option { + for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { + if let Some(plan) = plan_axis_crossing_lanes(request, axis) { + return Some(plan); + } + } + plan_horizontal_space_then_vertical_growth(request) +} + +fn plan_axis_crossing_lanes( + request: &MultiphaseRequest, + axis: PhaseAxis, +) -> Option { + if request.windows.len() != 2 { + return None; + } + let orth_min = request + .windows + .iter() + .map(|window| orth_start(window.from, axis)) + .min()?; + let orth_max = request + .windows + .iter() + .map(|window| orth_end(window.from, axis)) + .max()?; + if request.windows.iter().any(|window| { + main_size(window.from, axis) != main_size(window.to, axis) + || orth_start(window.from, axis) != orth_min + || orth_end(window.from, axis) != orth_max + || orth_start(window.to, axis) != orth_min + || orth_end(window.to, axis) != orth_max + || main_start(window.from, axis) == main_start(window.to, axis) + }) { + return None; + } + let lane_size = (orth_max - orth_min) / request.windows.len() as i32; + if lane_size < sane_min_size(orth_max - orth_min) { + return None; + } + + let mut windows = request.windows.clone(); + windows.sort_by_key(|window| window.node_id.0); + let mut phase1 = vec![]; + let mut phase2 = vec![]; + let mut phase3 = vec![]; + for (idx, window) in windows.iter().enumerate() { + let lane_start = orth_min + lane_size * idx as i32; + let lane_end = if idx + 1 == windows.len() { + orth_max + } else { + lane_start + lane_size + }; + let lane_from = with_orth_interval(window.from, axis, lane_start, lane_end); + let lane_to = with_main_interval( + lane_from, + axis, + main_start(window.to, axis), + main_end(window.to, axis), + ); + push_step(&mut phase1, window.node_id, window.from, lane_from); + push_step(&mut phase2, window.node_id, lane_from, lane_to); + push_step(&mut phase3, window.node_id, lane_to, window.to); + } + build_validated_plan( + request, + [ + (PhaseKind::Scale, axis.other(), phase1), + (PhaseKind::Move, axis, phase2), + (PhaseKind::Scale, axis.other(), phase3), + ], + ) +} + +fn plan_horizontal_space_then_vertical_growth( + request: &MultiphaseRequest, +) -> Option { + if request.windows.len() < 2 { + return None; + } + let min_width = sane_min_size(request.bounds.width()); + let min_height = sane_min_size(request.bounds.height()); + let mut phase1 = vec![]; + let mut phase2 = vec![]; + let mut phase3 = vec![]; + for window in &request.windows { + if window.to.width() < min_width || window.to.height() < min_height { + return None; + } + let x_changes = window.from.x1() != window.to.x1() || window.from.x2() != window.to.x2(); + let y_changes = window.from.y1() != window.to.y1() || window.from.y2() != window.to.y2(); + if x_changes && window.from.width() == window.to.width() { + let after_move = Rect::new_sized_saturating( + window.to.x1(), + window.from.y1(), + window.to.width(), + window.from.height(), + ); + push_step(&mut phase2, window.node_id, window.from, after_move); + if y_changes { + push_step(&mut phase3, window.node_id, after_move, window.to); + } + } else if x_changes { + let after_x_scale = Rect::new_sized_saturating( + window.to.x1(), + window.from.y1(), + window.to.width(), + window.from.height(), + ); + push_step(&mut phase1, window.node_id, window.from, after_x_scale); + if y_changes { + push_step(&mut phase3, window.node_id, after_x_scale, window.to); + } + } else if y_changes { + push_step(&mut phase3, window.node_id, window.from, window.to); + } + } + if phase1.is_empty() || phase2.is_empty() || phase3.is_empty() { + return None; + } + build_validated_plan( + request, + [ + (PhaseKind::Scale, PhaseAxis::Horizontal, phase1), + (PhaseKind::Move, PhaseAxis::Horizontal, phase2), + (PhaseKind::Scale, PhaseAxis::Vertical, phase3), + ], + ) +} + +fn build_validated_plan( + request: &MultiphaseRequest, + phases: [(PhaseKind, PhaseAxis, Vec); N], +) -> Option { + let phases: Vec<_> = phases + .into_iter() + .filter_map(|(kind, axis, steps)| { + (!steps.is_empty()).then_some(MultiphasePhase { + action: PhaseAction { kind, axis }, + steps, + }) + }) + .collect(); + if phases.iter().any(|phase| { + phase + .steps + .iter() + .any(|step| classify_step(*step) != Some(phase.action)) + }) { + return None; + } + let plan = MultiphasePlan { phases }; + validate_plan_samples(request, &plan).then_some(plan) +} + +fn validate_plan_samples(request: &MultiphaseRequest, plan: &MultiphasePlan) -> bool { + let mut current: Vec<_> = request + .windows + .iter() + .map(|window| (window.node_id, window.from)) + .collect(); + if overlaps(current.iter().map(|(_, rect)| *rect)) { + return false; + } + for phase in &plan.phases { + for t in PHASE_VALIDATION_SAMPLES { + let rects = current.iter().map(|(node_id, rect)| { + phase + .steps + .iter() + .find(|step| step.node_id == *node_id) + .map(|step| super::lerp_rect(step.from, step.to, t)) + .unwrap_or(*rect) + }); + if overlaps(rects) { + return false; + } + } + for step in &phase.steps { + let Some((_, rect)) = current + .iter_mut() + .find(|(node_id, _)| *node_id == step.node_id) + else { + return false; + }; + *rect = step.to; + } + if overlaps(current.iter().map(|(_, rect)| *rect)) { + return false; + } + } + request.windows.iter().all(|window| { + current + .iter() + .find(|(node_id, _)| *node_id == window.node_id) + .is_some_and(|(_, rect)| *rect == window.to) + }) +} + +fn classify_step(step: MultiphaseStep) -> Option { + let same_x = step.from.x1() == step.to.x1() && step.from.x2() == step.to.x2(); + let same_y = step.from.y1() == step.to.y1() && step.from.y2() == step.to.y2(); + let same_size = step.from.size() == step.to.size(); + match (same_x, same_y, same_size) { + (false, true, true) => Some(PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }), + (true, false, true) => Some(PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Vertical, + }), + (false, true, false) => Some(PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal, + }), + (true, false, false) => Some(PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }), + _ => None, + } +} + +fn reverse_request(request: &MultiphaseRequest) -> MultiphaseRequest { + MultiphaseRequest { + bounds: request.bounds, + windows: request + .windows + .iter() + .map(|window| MultiphaseWindow { + node_id: window.node_id, + from: window.to, + to: window.from, + }) + .collect(), + } +} + +fn reverse_plan(plan: MultiphasePlan) -> MultiphasePlan { + MultiphasePlan { + phases: plan + .phases + .into_iter() + .rev() + .map(|phase| MultiphasePhase { + action: phase.action, + steps: phase + .steps + .into_iter() + .map(|step| MultiphaseStep { + node_id: step.node_id, + from: step.to, + to: step.from, + }) + .collect(), + }) + .collect(), + } +} + +fn overlaps(rects: impl IntoIterator) -> bool { + let rects: Vec<_> = rects.into_iter().collect(); + for (idx, rect) in rects.iter().enumerate() { + if rects[idx + 1..].iter().any(|other| rect.intersects(other)) { + return true; + } + } + false +} + +fn push_step(steps: &mut Vec, node_id: NodeId, from: Rect, to: Rect) { + if from != to { + steps.push(MultiphaseStep { node_id, from, to }); + } +} + +fn sane_min_size(size: i32) -> i32 { + (size / MIN_SHRINK_DENOMINATOR).max(1) +} + +fn main_start(rect: Rect, axis: PhaseAxis) -> i32 { + match axis { + PhaseAxis::Horizontal => rect.x1(), + PhaseAxis::Vertical => rect.y1(), + } +} + +fn main_end(rect: Rect, axis: PhaseAxis) -> i32 { + match axis { + PhaseAxis::Horizontal => rect.x2(), + PhaseAxis::Vertical => rect.y2(), + } +} + +fn main_size(rect: Rect, axis: PhaseAxis) -> i32 { + main_end(rect, axis) - main_start(rect, axis) +} + +fn orth_start(rect: Rect, axis: PhaseAxis) -> i32 { + main_start(rect, axis.other()) +} + +fn orth_end(rect: Rect, axis: PhaseAxis) -> i32 { + main_end(rect, axis.other()) +} + +fn with_main_interval(rect: Rect, axis: PhaseAxis, start: i32, end: i32) -> Rect { + match axis { + PhaseAxis::Horizontal => Rect::new_saturating(start, rect.y1(), end, rect.y2()), + PhaseAxis::Vertical => Rect::new_saturating(rect.x1(), start, rect.x2(), end), + } +} + +fn with_orth_interval(rect: Rect, axis: PhaseAxis, start: i32, end: i32) -> Rect { + with_main_interval(rect, axis.other(), start, end) +} + +impl PhaseAxis { + fn other(self) -> Self { + match self { + Self::Horizontal => Self::Vertical, + Self::Vertical => Self::Horizontal, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn id(raw: u32) -> NodeId { + NodeId(raw) + } + + fn rect(x1: i32, y1: i32, x2: i32, y2: i32) -> Rect { + Rect::new_saturating(x1, y1, x2, y2) + } + + fn request(windows: Vec) -> MultiphaseRequest { + MultiphaseRequest { + bounds: rect(0, 0, 400, 100), + windows, + } + } + + fn actions(plan: &MultiphasePlan) -> Vec { + plan.phases.iter().map(|phase| phase.action).collect() + } + + #[test] + fn horizontal_swap_shrinks_moves_then_grows_without_overlap() { + let req = request(vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 100, 100), + to: rect(100, 0, 200, 100), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(100, 0, 200, 100), + to: rect(0, 0, 100, 100), + }, + ]); + let plan = plan_no_overlap(&req).unwrap(); + assert_eq!( + actions(&plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical + }, + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical + }, + ] + ); + assert_eq!(plan.phases[0].steps[0].to, rect(0, 0, 100, 50)); + assert_eq!(plan.phases[0].steps[1].to, rect(100, 50, 200, 100)); + assert!(validate_plan_samples(&req, &plan)); + } + + #[test] + fn horizontal_swap_reverse_uses_equivalent_lanes() { + let req = request(vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(100, 0, 200, 100), + to: rect(0, 0, 100, 100), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(0, 0, 100, 100), + to: rect(100, 0, 200, 100), + }, + ]); + let plan = plan_no_overlap(&req).unwrap(); + assert_eq!(plan.phases[0].steps[0].to, rect(100, 0, 200, 50)); + assert_eq!(plan.phases[0].steps[1].to, rect(0, 50, 100, 100)); + assert!(validate_plan_samples(&req, &plan)); + } + + #[test] + fn stack_extraction_creates_space_before_moving_child() { + let req = request(vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 200, 100), + to: rect(0, 0, 100, 100), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(200, 0, 400, 50), + to: rect(100, 0, 300, 100), + }, + MultiphaseWindow { + node_id: id(3), + from: rect(200, 50, 400, 100), + to: rect(300, 0, 400, 100), + }, + ]); + let plan = plan_no_overlap(&req).unwrap(); + assert_eq!( + actions(&plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal + }, + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical + }, + ] + ); + assert_eq!(plan.phases[0].steps[0].to, rect(0, 0, 100, 100)); + assert_eq!(plan.phases[0].steps[1].to, rect(300, 50, 400, 100)); + assert_eq!(plan.phases[1].steps[0].to, rect(100, 0, 300, 50)); + assert_eq!(plan.phases[2].steps[0].to, rect(100, 0, 300, 100)); + assert_eq!(plan.phases[2].steps[1].to, rect(300, 0, 400, 100)); + assert!(validate_plan_samples(&req, &plan)); + } + + #[test] + fn stack_extraction_reverse_replays_phases_in_reverse() { + let req = request(vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 100, 100), + to: rect(0, 0, 200, 100), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(100, 0, 300, 100), + to: rect(200, 0, 400, 50), + }, + MultiphaseWindow { + node_id: id(3), + from: rect(300, 0, 400, 100), + to: rect(200, 50, 400, 100), + }, + ]); + let plan = plan_no_overlap(&req).unwrap(); + assert_eq!( + actions(&plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical + }, + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal + }, + ] + ); + assert!(validate_plan_samples(&req, &plan)); + } + + #[test] + fn unsupported_diagonal_motion_falls_back_to_linear() { + let req = request(vec![MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 100, 100), + to: rect(100, 100, 200, 200), + }]); + assert_eq!(plan_no_overlap(&req), Err(MultiphaseError::NoPlan)); + } +} From b50e8d5683dbf36170be3c52c61d39c83d90081b Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 18:27:01 +1000 Subject: [PATCH 12/47] Batch layout animation candidates --- src/compositor.rs | 1 + src/state.rs | 53 +++++++++++++++++++++++++++++++++++++++---- src/tree/container.rs | 6 +++++ 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/compositor.rs b/src/compositor.rs index 93600f27..fdb0f282 100644 --- a/src/compositor.rs +++ b/src/compositor.rs @@ -364,6 +364,7 @@ fn start_compositor2( layout_animations_requested: Default::default(), layout_animations_active: Default::default(), layout_animation_curve_override: Default::default(), + layout_animation_batch: Default::default(), suppress_animations_for_next_layout: Default::default(), toplevels: Default::default(), const_40hz_latch: Default::default(), diff --git a/src/state.rs b/src/state.rs index adf7b2ca..303a63c5 100644 --- a/src/state.rs +++ b/src/state.rs @@ -157,6 +157,14 @@ use { uapi::{OwnedFd, c}, }; +pub(crate) struct LayoutAnimationCandidate { + node_id: NodeId, + old: Rect, + new: Rect, + retained: Option>, + curve: AnimationCurve, +} + pub struct State { pub pid: c::pid_t, pub kb_ctx: KbvmContext, @@ -271,6 +279,7 @@ pub struct State { pub layout_animations_requested: Cell, pub layout_animations_active: Cell, pub layout_animation_curve_override: Cell>, + pub(crate) layout_animation_batch: RefCell>>, pub suppress_animations_for_next_layout: Cell, pub toplevels: CopyHashMap>, pub const_40hz_latch: EventSource, @@ -1526,25 +1535,59 @@ impl State { if old_output != new_output || old_scale != new_scale { return; } - let now = self.now_nsec(); - let started = self.animations.set_target( + let candidate = LayoutAnimationCandidate { node_id, old, new, retained, - now, - self.animations.duration_ms.get(), curve, + }; + if let Some(batch) = self.layout_animation_batch.borrow_mut().as_mut() { + batch.push(candidate); + return; + } + self.start_layout_animation_candidate(candidate, self.now_nsec()); + } + + fn start_layout_animation_candidate( + self: &Rc, + candidate: LayoutAnimationCandidate, + now_nsec: u64, + ) { + let started = self.animations.set_target( + candidate.node_id, + candidate.old, + candidate.new, + candidate.retained, + now_nsec, + self.animations.duration_ms.get(), + candidate.curve, ); if started { self.damage(expand_damage_rect( - old.union(new), + candidate.old.union(candidate.new), self.theme.sizes.border_width.get().max(0), )); self.ensure_animation_tick(); } } + pub fn begin_layout_animation_batch(&self) { + self.layout_animation_batch + .borrow_mut() + .get_or_insert_with(Vec::new); + } + + pub fn finish_layout_animation_batch(self: &Rc) { + let Some(candidates) = self.layout_animation_batch.borrow_mut().take() else { + return; + }; + let now = self.now_nsec(); + for candidate in candidates { + self.start_layout_animation_candidate(candidate, now); + } + } + pub fn queue_spawn_in_animation( self: &Rc, node_id: NodeId, diff --git a/src/tree/container.rs b/src/tree/container.rs index 3a6db3a2..8670125c 100644 --- a/src/tree/container.rs +++ b/src/tree/container.rs @@ -1771,7 +1771,13 @@ pub async fn container_layout(state: Rc) { let animate = container.animate_next_layout.replace(false) && !state.suppress_animations_for_next_layout.get(); let prev_active = state.layout_animations_active.replace(animate); + if animate { + state.begin_layout_animation_batch(); + } container.perform_layout(); + if animate { + state.finish_layout_animation_batch(); + } state.layout_animations_active.set(prev_active); } state.suppress_animations_for_next_layout.set(false); From 13722429b42a422fc2a641c403f69530d7066c63 Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 18:37:00 +1000 Subject: [PATCH 13/47] Run planned multiphase layout animations --- src/animation.rs | 191 +++++++++++++++++++++++++++++++++++++++++++++-- src/state.rs | 92 ++++++++++++++++++++++- 2 files changed, 274 insertions(+), 9 deletions(-) diff --git a/src/animation.rs b/src/animation.rs index 847b8f6d..fa7a58a7 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -131,6 +131,7 @@ pub struct AnimationState { pub duration_ms: Cell, pub curve: Cell, windows: RefCell>, + phased: RefCell>, exits: RefCell>, tick: CloneCell>>, } @@ -263,6 +264,7 @@ impl Default for AnimationState { duration_ms: Cell::new(DEFAULT_DURATION_MS), curve: Cell::new(AnimationCurve::from_config(3)), windows: Default::default(), + phased: Default::default(), exits: Default::default(), tick: Default::default(), } @@ -272,6 +274,7 @@ impl Default for AnimationState { impl AnimationState { pub fn clear(&self) { self.windows.borrow_mut().clear(); + self.phased.borrow_mut().clear(); self.exits.borrow_mut().clear(); if let Some(tick) = self.tick.take() { tick.detach(); @@ -290,20 +293,39 @@ impl AnimationState { ) -> bool { if old == new || old.is_empty() || new.is_empty() || duration_ms == 0 { self.windows.borrow_mut().remove(&node_id); + self.phased.borrow_mut().remove(&node_id); return false; } let duration_nsec = duration_ms as u64 * 1_000_000; - let mut windows = self.windows.borrow_mut(); - let (from, retained) = match windows.get(&node_id) { - Some(anim) if anim.to == new => return false, - Some(anim) => (anim.rect_at(now_nsec), anim.retained.clone().or(retained)), - None => (old, retained), - }; + let mut from = old; + let mut retained = retained; + { + let phased = self.phased.borrow(); + if let Some(anim) = phased.get(&node_id) { + if anim.final_rect == new { + return false; + } + from = anim.rect_at(now_nsec); + retained = anim.retained.clone().or(retained); + } + } + { + let windows = self.windows.borrow(); + if let Some(anim) = windows.get(&node_id) { + if anim.to == new { + return false; + } + from = anim.rect_at(now_nsec); + retained = anim.retained.clone().or(retained); + } + } if from == new { - windows.remove(&node_id); + self.windows.borrow_mut().remove(&node_id); + self.phased.borrow_mut().remove(&node_id); return false; } - windows.insert( + self.phased.borrow_mut().remove(&node_id); + self.windows.borrow_mut().insert( node_id, WindowAnimation { from, @@ -318,6 +340,47 @@ impl AnimationState { true } + pub fn set_phased_target( + &self, + node_id: NodeId, + phases: Vec<(Rect, Rect)>, + retained: Option>, + now_nsec: u64, + duration_ms: u32, + curve: AnimationCurve, + ) -> bool { + if phases.is_empty() || duration_ms == 0 { + return false; + } + let Some((from, _)) = phases.first().copied() else { + return false; + }; + let Some((_, final_rect)) = phases.last().copied() else { + return false; + }; + if from.is_empty() || final_rect.is_empty() || from == final_rect { + return false; + } + let segments: Vec<_> = phases + .into_iter() + .map(|(from, to)| PhasedSegment { from, to }) + .collect(); + self.windows.borrow_mut().remove(&node_id); + self.phased.borrow_mut().insert( + node_id, + PhasedWindowAnimation { + segments, + start_nsec: now_nsec, + duration_nsec: duration_ms as u64 * 1_000_000, + curve, + last_damage: from, + final_rect, + retained, + }, + ); + true + } + pub fn set_spawn_in( &self, node_id: NodeId, @@ -375,6 +438,13 @@ impl AnimationState { } pub fn visual_rect(&self, node_id: NodeId, layout: Rect, now_nsec: u64) -> Rect { + let phased = self.phased.borrow(); + if let Some(anim) = phased.get(&node_id) + && !anim.done(now_nsec) + { + return anim.rect_at(now_nsec); + } + drop(phased); let windows = self.windows.borrow(); match windows.get(&node_id) { Some(anim) if !anim.done(now_nsec) => anim.rect_at(now_nsec), @@ -387,6 +457,13 @@ impl AnimationState { node_id: NodeId, now_nsec: u64, ) -> Option> { + let phased = self.phased.borrow(); + if let Some(anim) = phased.get(&node_id) + && !anim.done(now_nsec) + { + return anim.retained.clone(); + } + drop(phased); let windows = self.windows.borrow(); match windows.get(&node_id) { Some(anim) if !anim.done(now_nsec) => anim.retained.clone(), @@ -427,6 +504,18 @@ impl AnimationState { any_active |= active; active }); + self.phased.borrow_mut().retain(|_, anim| { + let current = anim.rect_at(now_nsec); + let damage = anim.last_damage.union(current).union(anim.final_rect); + damages.push(expand_damage_rect( + damage, + state.theme.sizes.border_width.get().max(0), + )); + anim.last_damage = current; + let active = !anim.done(now_nsec); + any_active |= active; + active + }); self.exits.borrow_mut().retain_mut(|exit| { let current = exit.rect_at(now_nsec); let damage = exit.last_damage.union(current).union(exit.to); @@ -485,6 +574,45 @@ impl WindowAnimation { } } +struct PhasedWindowAnimation { + segments: Vec, + start_nsec: u64, + duration_nsec: u64, + curve: AnimationCurve, + last_damage: Rect, + final_rect: Rect, + retained: Option>, +} + +struct PhasedSegment { + from: Rect, + to: Rect, +} + +impl PhasedWindowAnimation { + fn done(&self, now_nsec: u64) -> bool { + let total_duration = self + .duration_nsec + .saturating_mul(self.segments.len() as u64); + now_nsec.saturating_sub(self.start_nsec) >= total_duration + } + + fn rect_at(&self, now_nsec: u64) -> Rect { + if self.duration_nsec == 0 { + return self.final_rect; + } + let elapsed = now_nsec.saturating_sub(self.start_nsec); + let phase = (elapsed / self.duration_nsec) as usize; + let Some(segment) = self.segments.get(phase) else { + return self.final_rect; + }; + let phase_elapsed = elapsed % self.duration_nsec; + let t = (phase_elapsed as f64 / self.duration_nsec as f64).clamp(0.0, 1.0); + let t = self.curve.sample(t); + lerp_rect(segment.from, segment.to, t) + } +} + struct ExitAnimation { from: Rect, to: Rect, @@ -722,6 +850,53 @@ mod tests { assert!(state.exit_frames(160_000_000).is_empty()); } + #[test] + fn phased_animation_uses_full_duration_per_phase() { + let state = AnimationState::default(); + let id = NodeId(1); + let a = Rect::new_sized_saturating(0, 0, 100, 100); + let b = Rect::new_sized_saturating(0, 0, 100, 50); + let c = Rect::new_sized_saturating(100, 0, 100, 50); + assert!(state.set_phased_target( + id, + vec![(a, b), (b, c)], + None, + 0, + 100, + AnimationCurve::Linear + )); + assert_eq!(state.visual_rect(id, c, 0), a); + assert_eq!(state.visual_rect(id, c, 50_000_000), lerp_rect(a, b, 0.5)); + assert_eq!(state.visual_rect(id, c, 100_000_000), b); + assert_eq!(state.visual_rect(id, c, 150_000_000), lerp_rect(b, c, 0.5)); + assert_eq!(state.visual_rect(id, c, 200_000_000), c); + } + + #[test] + fn linear_retarget_interrupts_phased_animation_from_current_rect() { + let state = AnimationState::default(); + let id = NodeId(1); + let a = Rect::new_sized_saturating(0, 0, 100, 100); + let b = Rect::new_sized_saturating(0, 0, 100, 50); + let c = Rect::new_sized_saturating(100, 0, 100, 50); + let d = Rect::new_sized_saturating(100, 100, 100, 50); + assert!(state.set_phased_target( + id, + vec![(a, b), (b, c)], + None, + 0, + 100, + AnimationCurve::Linear + )); + let current = lerp_rect(a, b, 0.5); + assert!(state.set_target(id, a, d, None, 50_000_000, 100, AnimationCurve::Linear)); + assert_eq!(state.visual_rect(id, d, 50_000_000), current); + assert_eq!( + state.visual_rect(id, d, 100_000_000), + lerp_rect(current, d, 0.5) + ); + } + #[test] fn unchanged_target_does_not_restart() { let state = AnimationState::default(); diff --git a/src/state.rs b/src/state.rs index 303a63c5..4273ac08 100644 --- a/src/state.rs +++ b/src/state.rs @@ -4,7 +4,9 @@ use { allocator::BufferObject, animation::{ AnimationCurve, AnimationState, AnimationTick, RetainedExitLayer, RetainedToplevel, - expand_damage_rect, spawn_in_start_rect, + expand_damage_rect, + multiphase::{MultiphaseRequest, MultiphaseWindow, plan_no_overlap}, + spawn_in_start_rect, }, async_engine::{AsyncEngine, SpawnedFuture}, backend::{ @@ -157,6 +159,7 @@ use { uapi::{OwnedFd, c}, }; +#[derive(Clone)] pub(crate) struct LayoutAnimationCandidate { node_id: NodeId, old: Rect, @@ -1583,11 +1586,98 @@ impl State { return; }; let now = self.now_nsec(); + if self.start_multiphase_layout_animation(&candidates, now) { + return; + } for candidate in candidates { self.start_layout_animation_candidate(candidate, now); } } + fn start_multiphase_layout_animation( + self: &Rc, + candidates: &[LayoutAnimationCandidate], + now_nsec: u64, + ) -> bool { + if candidates.len() < 2 { + return false; + } + let windows: Vec<_> = candidates + .iter() + .map(|candidate| MultiphaseWindow { + node_id: candidate.node_id, + from: self + .animations + .visual_rect(candidate.node_id, candidate.old, now_nsec), + to: candidate.new, + }) + .collect(); + let Some(first) = windows.first() else { + return false; + }; + let mut bounds = first.from.union(first.to); + for window in &windows[1..] { + bounds = bounds.union(window.from).union(window.to); + } + let Ok(plan) = plan_no_overlap(&MultiphaseRequest { bounds, windows }) else { + return false; + }; + if plan.phases.is_empty() { + return false; + } + let mut entries = vec![]; + for candidate in candidates { + let mut current = + self.animations + .visual_rect(candidate.node_id, candidate.old, now_nsec); + let mut damage = current.union(candidate.new); + let mut phases = vec![]; + for phase in &plan.phases { + match phase + .steps + .iter() + .find(|step| step.node_id == candidate.node_id) + { + Some(step) => { + phases.push((step.from, step.to)); + damage = damage.union(step.from).union(step.to); + current = step.to; + } + None => phases.push((current, current)), + } + } + if current != candidate.new { + return false; + } + let retained = self + .animations + .retained_snapshot(candidate.node_id, now_nsec) + .or_else(|| candidate.retained.clone()); + entries.push((candidate.clone(), phases, damage, retained)); + } + let mut started_any = false; + for (candidate, phases, damage, retained) in entries { + if self.animations.set_phased_target( + candidate.node_id, + phases, + retained, + now_nsec, + self.animations.duration_ms.get(), + candidate.curve, + ) { + started_any = true; + self.damage(expand_damage_rect( + damage, + self.theme.sizes.border_width.get().max(0), + )); + } + } + if started_any { + self.ensure_animation_tick(); + } + started_any + } + pub fn queue_spawn_in_animation( self: &Rc, node_id: NodeId, From a516b2e7215f16f0f2cba346260c373f98fdf084 Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 18:42:45 +1000 Subject: [PATCH 14/47] Mirror stack extraction multiphase planning --- docs/window-animations-plan.md | 8 ++ src/animation/multiphase.rs | 148 ++++++++++++++++++++++++++------- 2 files changed, 128 insertions(+), 28 deletions(-) diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index d46b3bdb..a4e39525 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -214,6 +214,14 @@ Preferred approach: - If a legal no-overlap sequence cannot be found for a group, fall back to the linear animator for that group only. Float windows are outside this invariant. +Current pure planner status: + +- Two-window same-axis swaps use shrink lanes, move, then grow. +- Stack extraction/return patterns are covered in both horizontal and vertical + orientations: peer/container space scales first, the extracted child moves + only after space exists, and orthogonal growth happens in the final phase. +- Every produced plan is sampled for overlap at each phase before it is accepted. + Tests: - horizontal swaps shrink, move, then grow without overlap diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index 3130bb80..05c8617c 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -110,7 +110,8 @@ fn plan_forward(request: &MultiphaseRequest) -> Option { return Some(plan); } } - plan_horizontal_space_then_vertical_growth(request) + plan_space_then_orthogonal_growth(request, PhaseAxis::Horizontal) + .or_else(|| plan_space_then_orthogonal_growth(request, PhaseAxis::Vertical)) } fn plan_axis_crossing_lanes( @@ -178,12 +179,14 @@ fn plan_axis_crossing_lanes( ) } -fn plan_horizontal_space_then_vertical_growth( +fn plan_space_then_orthogonal_growth( request: &MultiphaseRequest, + axis: PhaseAxis, ) -> Option { if request.windows.len() < 2 { return None; } + let orth_axis = axis.other(); let min_width = sane_min_size(request.bounds.width()); let min_height = sane_min_size(request.bounds.height()); let mut phase1 = vec![]; @@ -193,31 +196,33 @@ fn plan_horizontal_space_then_vertical_growth( if window.to.width() < min_width || window.to.height() < min_height { return None; } - let x_changes = window.from.x1() != window.to.x1() || window.from.x2() != window.to.x2(); - let y_changes = window.from.y1() != window.to.y1() || window.from.y2() != window.to.y2(); - if x_changes && window.from.width() == window.to.width() { - let after_move = Rect::new_sized_saturating( - window.to.x1(), - window.from.y1(), - window.to.width(), - window.from.height(), + let main_changes = main_start(window.from, axis) != main_start(window.to, axis) + || main_end(window.from, axis) != main_end(window.to, axis); + let orth_changes = orth_start(window.from, axis) != orth_start(window.to, axis) + || orth_end(window.from, axis) != orth_end(window.to, axis); + if main_changes && main_size(window.from, axis) == main_size(window.to, axis) { + let after_move = with_main_interval( + window.from, + axis, + main_start(window.to, axis), + main_end(window.to, axis), ); push_step(&mut phase2, window.node_id, window.from, after_move); - if y_changes { + if orth_changes { push_step(&mut phase3, window.node_id, after_move, window.to); } - } else if x_changes { - let after_x_scale = Rect::new_sized_saturating( - window.to.x1(), - window.from.y1(), - window.to.width(), - window.from.height(), + } else if main_changes { + let after_main_scale = with_main_interval( + window.from, + axis, + main_start(window.to, axis), + main_end(window.to, axis), ); - push_step(&mut phase1, window.node_id, window.from, after_x_scale); - if y_changes { - push_step(&mut phase3, window.node_id, after_x_scale, window.to); + push_step(&mut phase1, window.node_id, window.from, after_main_scale); + if orth_changes { + push_step(&mut phase3, window.node_id, after_main_scale, window.to); } - } else if y_changes { + } else if orth_changes { push_step(&mut phase3, window.node_id, window.from, window.to); } } @@ -227,9 +232,9 @@ fn plan_horizontal_space_then_vertical_growth( build_validated_plan( request, [ - (PhaseKind::Scale, PhaseAxis::Horizontal, phase1), - (PhaseKind::Move, PhaseAxis::Horizontal, phase2), - (PhaseKind::Scale, PhaseAxis::Vertical, phase3), + (PhaseKind::Scale, axis, phase1), + (PhaseKind::Move, axis, phase2), + (PhaseKind::Scale, orth_axis, phase3), ], ) } @@ -444,10 +449,12 @@ mod tests { } fn request(windows: Vec) -> MultiphaseRequest { - MultiphaseRequest { - bounds: rect(0, 0, 400, 100), - windows, - } + let bounds = windows + .iter() + .map(|window| window.from.union(window.to)) + .reduce(|bounds, rect| bounds.union(rect)) + .unwrap_or_else(|| rect(0, 0, 1, 1)); + MultiphaseRequest { bounds, windows } } fn actions(plan: &MultiphasePlan) -> Vec { @@ -596,6 +603,91 @@ mod tests { assert!(validate_plan_samples(&req, &plan)); } + #[test] + fn vertical_stack_extraction_creates_space_before_moving_child() { + let req = request(vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 100, 200), + to: rect(0, 0, 100, 100), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(0, 200, 50, 400), + to: rect(0, 100, 100, 300), + }, + MultiphaseWindow { + node_id: id(3), + from: rect(50, 200, 100, 400), + to: rect(0, 300, 100, 400), + }, + ]); + let plan = plan_no_overlap(&req).unwrap(); + assert_eq!( + actions(&plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical + }, + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Vertical + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal + }, + ] + ); + assert_eq!(plan.phases[0].steps[0].to, rect(0, 0, 100, 100)); + assert_eq!(plan.phases[0].steps[1].to, rect(50, 300, 100, 400)); + assert_eq!(plan.phases[1].steps[0].to, rect(0, 100, 50, 300)); + assert_eq!(plan.phases[2].steps[0].to, rect(0, 100, 100, 300)); + assert_eq!(plan.phases[2].steps[1].to, rect(0, 300, 100, 400)); + assert!(validate_plan_samples(&req, &plan)); + } + + #[test] + fn vertical_stack_extraction_reverse_replays_phases_in_reverse() { + let req = request(vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 100, 100), + to: rect(0, 0, 100, 200), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(0, 100, 100, 300), + to: rect(0, 200, 50, 400), + }, + MultiphaseWindow { + node_id: id(3), + from: rect(0, 300, 100, 400), + to: rect(50, 200, 100, 400), + }, + ]); + let plan = plan_no_overlap(&req).unwrap(); + assert_eq!( + actions(&plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal + }, + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Vertical + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical + }, + ] + ); + assert!(validate_plan_samples(&req, &plan)); + } + #[test] fn unsupported_diagonal_motion_falls_back_to_linear() { let req = request(vec![MultiphaseWindow { From 4ee2c324e12fea54f76d616dad6e50e3db7f35bd Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 18:46:10 +1000 Subject: [PATCH 15/47] Fallback layout animations by motion group --- docs/window-animations-plan.md | 3 ++ src/animation/multiphase.rs | 75 ++++++++++++++++++++++++++++++++++ src/state.rs | 64 +++++++++++++++++------------ 3 files changed, 115 insertions(+), 27 deletions(-) diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index a4e39525..0252820c 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -221,6 +221,9 @@ Current pure planner status: orientations: peer/container space scales first, the extracted child moves only after space exists, and orthogonal growth happens in the final phase. - Every produced plan is sampled for overlap at each phase before it is accepted. +- Live layout batches are partitioned by overlapping motion bounds, so unrelated + groups can still use multiphase animation when another group falls back to + linear motion. Tests: diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index 05c8617c..ffaea6d1 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -81,6 +81,33 @@ pub fn plan_no_overlap(request: &MultiphaseRequest) -> Result Vec> { + let mut groups = vec![]; + let mut seen = vec![false; windows.len()]; + for start in 0..windows.len() { + if seen[start] { + continue; + } + seen[start] = true; + let mut group = vec![]; + let mut pending = vec![start]; + while let Some(idx) = pending.pop() { + group.push(idx); + let bounds = motion_bounds(windows[idx]); + for other in 0..windows.len() { + if seen[other] || !bounds.intersects(&motion_bounds(windows[other])) { + continue; + } + seen[other] = true; + pending.push(other); + } + } + group.sort_unstable(); + groups.push(group); + } + groups +} + fn validate_request(request: &MultiphaseRequest) -> Result<(), MultiphaseError> { if request.bounds.is_empty() { return Err(MultiphaseError::EmptyBounds); @@ -380,6 +407,10 @@ fn overlaps(rects: impl IntoIterator) -> bool { false } +fn motion_bounds(window: MultiphaseWindow) -> Rect { + window.from.union(window.to) +} + fn push_step(steps: &mut Vec, node_id: NodeId, from: Rect, to: Rect) { if from != to { steps.push(MultiphaseStep { node_id, from, to }); @@ -697,4 +728,48 @@ mod tests { }]); assert_eq!(plan_no_overlap(&req), Err(MultiphaseError::NoPlan)); } + + #[test] + fn motion_groups_split_disjoint_layout_changes() { + let windows = vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 100, 100), + to: rect(100, 0, 200, 100), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(100, 0, 200, 100), + to: rect(0, 0, 100, 100), + }, + MultiphaseWindow { + node_id: id(3), + from: rect(300, 0, 400, 100), + to: rect(400, 0, 500, 100), + }, + ]; + assert_eq!(partition_motion_groups(&windows), vec![vec![0, 1], vec![2]]); + } + + #[test] + fn motion_groups_are_transitive() { + let windows = vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 100, 100), + to: rect(80, 0, 180, 100), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(170, 0, 270, 100), + to: rect(250, 0, 350, 100), + }, + MultiphaseWindow { + node_id: id(3), + from: rect(90, 0, 180, 100), + to: rect(180, 0, 260, 100), + }, + ]; + assert_eq!(partition_motion_groups(&windows), vec![vec![0, 1, 2]]); + } } diff --git a/src/state.rs b/src/state.rs index 4273ac08..835678f8 100644 --- a/src/state.rs +++ b/src/state.rs @@ -5,7 +5,9 @@ use { animation::{ AnimationCurve, AnimationState, AnimationTick, RetainedExitLayer, RetainedToplevel, expand_damage_rect, - multiphase::{MultiphaseRequest, MultiphaseWindow, plan_no_overlap}, + multiphase::{ + MultiphaseRequest, MultiphaseWindow, partition_motion_groups, plan_no_overlap, + }, spawn_in_start_rect, }, async_engine::{AsyncEngine, SpawnedFuture}, @@ -1586,51 +1588,59 @@ impl State { return; }; let now = self.now_nsec(); - if self.start_multiphase_layout_animation(&candidates, now) { - return; - } - for candidate in candidates { - self.start_layout_animation_candidate(candidate, now); - } - } - - fn start_multiphase_layout_animation( - self: &Rc, - candidates: &[LayoutAnimationCandidate], - now_nsec: u64, - ) -> bool { - if candidates.len() < 2 { - return false; - } let windows: Vec<_> = candidates .iter() .map(|candidate| MultiphaseWindow { node_id: candidate.node_id, from: self .animations - .visual_rect(candidate.node_id, candidate.old, now_nsec), + .visual_rect(candidate.node_id, candidate.old, now), to: candidate.new, }) .collect(); - let Some(first) = windows.first() else { + for group in partition_motion_groups(&windows) { + if self.start_multiphase_layout_animation(&candidates, &windows, &group, now) { + continue; + } + for idx in group { + self.start_layout_animation_candidate(candidates[idx].clone(), now); + } + } + } + + fn start_multiphase_layout_animation( + self: &Rc, + candidates: &[LayoutAnimationCandidate], + windows: &[MultiphaseWindow], + group: &[usize], + now_nsec: u64, + ) -> bool { + if group.len() < 2 { + return false; + } + let request_windows: Vec<_> = group.iter().map(|&idx| windows[idx]).collect(); + let Some(first) = request_windows.first() else { return false; }; let mut bounds = first.from.union(first.to); - for window in &windows[1..] { + for window in &request_windows[1..] { bounds = bounds.union(window.from).union(window.to); } - let Ok(plan) = plan_no_overlap(&MultiphaseRequest { bounds, windows }) else { + let Ok(plan) = plan_no_overlap(&MultiphaseRequest { + bounds, + windows: request_windows, + }) else { return false; }; if plan.phases.is_empty() { return false; } let mut entries = vec![]; - for candidate in candidates { - let mut current = - self.animations - .visual_rect(candidate.node_id, candidate.old, now_nsec); - let mut damage = current.union(candidate.new); + for &idx in group { + let candidate = &candidates[idx]; + let window = windows[idx]; + let mut current = window.from; + let mut damage = current.union(window.to); let mut phases = vec![]; for phase in &plan.phases { match phase @@ -1646,7 +1656,7 @@ impl State { None => phases.push((current, current)), } } - if current != candidate.new { + if current != window.to { return false; } let retained = self From b109cdf6f25c8b7bc107fbe7a6aff8ce162cd828 Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 19:43:39 +1000 Subject: [PATCH 16/47] Validate multiphase overlap analytically --- docs/window-animations-plan.md | 4 +- src/animation/multiphase.rs | 261 ++++++++++++++++++++++++++++++--- 2 files changed, 244 insertions(+), 21 deletions(-) diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index 0252820c..a659d76a 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -220,7 +220,9 @@ Current pure planner status: - Stack extraction/return patterns are covered in both horizontal and vertical orientations: peer/container space scales first, the extracted child moves only after space exists, and orthogonal growth happens in the final phase. -- Every produced plan is sampled for overlap at each phase before it is accepted. +- Every produced plan is checked analytically for overlap over the full duration + of each phase before it is accepted. This solves the linear edge inequalities + for each pair of moving rectangles instead of relying on sampled frames. - Live layout batches are partitioned by overlapping motion bounds, so unrelated groups can still use multiphase animation when another group falls back to linear motion. diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index ffaea6d1..37b31619 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -1,7 +1,6 @@ use {crate::rect::Rect, crate::tree::NodeId}; const MIN_SHRINK_DENOMINATOR: i32 = 4; -const PHASE_VALIDATION_SAMPLES: [f64; 5] = [0.0, 0.25, 0.5, 0.75, 1.0]; #[derive(Clone, Debug)] pub struct MultiphaseRequest { @@ -288,10 +287,10 @@ fn build_validated_plan( return None; } let plan = MultiphasePlan { phases }; - validate_plan_samples(request, &plan).then_some(plan) + validate_plan_continuous(request, &plan).then_some(plan) } -fn validate_plan_samples(request: &MultiphaseRequest, plan: &MultiphasePlan) -> bool { +fn validate_plan_continuous(request: &MultiphaseRequest, plan: &MultiphasePlan) -> bool { let mut current: Vec<_> = request .windows .iter() @@ -301,26 +300,46 @@ fn validate_plan_samples(request: &MultiphaseRequest, plan: &MultiphasePlan) -> return false; } for phase in &plan.phases { - for t in PHASE_VALIDATION_SAMPLES { - let rects = current.iter().map(|(node_id, rect)| { - phase + for (idx, step) in phase.steps.iter().enumerate() { + if phase.steps[..idx] + .iter() + .any(|prev| prev.node_id == step.node_id) + { + return false; + } + let Some((_, rect)) = current.iter().find(|(node_id, _)| *node_id == step.node_id) + else { + return false; + }; + if *rect != step.from { + return false; + } + } + let motions: Vec<_> = current + .iter() + .map(|(node_id, rect)| { + let to = phase .steps .iter() .find(|step| step.node_id == *node_id) - .map(|step| super::lerp_rect(step.from, step.to, t)) - .unwrap_or(*rect) - }); - if overlaps(rects) { + .map(|step| step.to) + .unwrap_or(*rect); + RectMotion { from: *rect, to } + }) + .collect(); + for (idx, motion) in motions.iter().enumerate() { + if motions[idx + 1..] + .iter() + .any(|other| motions_overlap_during_phase(*motion, *other)) + { return false; } } for step in &phase.steps { - let Some((_, rect)) = current + let (_, rect) = current .iter_mut() .find(|(node_id, _)| *node_id == step.node_id) - else { - return false; - }; + .unwrap(); *rect = step.to; } if overlaps(current.iter().map(|(_, rect)| *rect)) { @@ -335,6 +354,122 @@ fn validate_plan_samples(request: &MultiphaseRequest, plan: &MultiphasePlan) -> }) } +#[derive(Copy, Clone)] +struct RectMotion { + from: Rect, + to: Rect, +} + +fn motions_overlap_during_phase(a: RectMotion, b: RectMotion) -> bool { + let mut interval = TimeInterval::unit(); + interval.intersect_less_than(edge_delta(a.from.x1(), a.to.x1(), b.from.x2(), b.to.x2())) + && interval.intersect_less_than(edge_delta(b.from.x1(), b.to.x1(), a.from.x2(), a.to.x2())) + && interval.intersect_less_than(edge_delta(a.from.y1(), a.to.y1(), b.from.y2(), b.to.y2())) + && interval.intersect_less_than(edge_delta(b.from.y1(), b.to.y1(), a.from.y2(), a.to.y2())) + && interval.is_non_empty() +} + +fn edge_delta(a0: i32, a1: i32, b0: i32, b1: i32) -> LinearDelta { + let from = a0 as i64 - b0 as i64; + let to = a1 as i64 - b1 as i64; + LinearDelta { + start: from, + velocity: to - from, + } +} + +#[derive(Copy, Clone)] +struct LinearDelta { + start: i64, + velocity: i64, +} + +#[derive(Copy, Clone)] +struct TimeInterval { + lower: Rational, + lower_open: bool, + upper: Rational, + upper_open: bool, +} + +impl TimeInterval { + fn unit() -> Self { + Self { + lower: Rational::new(0, 1), + lower_open: false, + upper: Rational::new(1, 1), + upper_open: false, + } + } + + fn intersect_less_than(&mut self, delta: LinearDelta) -> bool { + if delta.velocity == 0 { + return delta.start < 0; + } + let boundary = Rational::new(-delta.start, delta.velocity); + if delta.velocity > 0 { + self.tighten_upper(boundary, true); + } else { + self.tighten_lower(boundary, true); + } + self.is_non_empty() + } + + fn tighten_lower(&mut self, value: Rational, open: bool) { + match value.cmp(&self.lower) { + std::cmp::Ordering::Greater => { + self.lower = value; + self.lower_open = open; + } + std::cmp::Ordering::Equal => { + self.lower_open |= open; + } + std::cmp::Ordering::Less => {} + } + } + + fn tighten_upper(&mut self, value: Rational, open: bool) { + match value.cmp(&self.upper) { + std::cmp::Ordering::Less => { + self.upper = value; + self.upper_open = open; + } + std::cmp::Ordering::Equal => { + self.upper_open |= open; + } + std::cmp::Ordering::Greater => {} + } + } + + fn is_non_empty(&self) -> bool { + match self.lower.cmp(&self.upper) { + std::cmp::Ordering::Less => true, + std::cmp::Ordering::Equal => !self.lower_open && !self.upper_open, + std::cmp::Ordering::Greater => false, + } + } +} + +#[derive(Copy, Clone, Eq, PartialEq)] +struct Rational { + num: i64, + den: i64, +} + +impl Rational { + fn new(mut num: i64, mut den: i64) -> Self { + if den < 0 { + num = -num; + den = -den; + } + Self { num, den } + } + + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + (self.num as i128 * other.den as i128).cmp(&(other.num as i128 * self.den as i128)) + } +} + fn classify_step(step: MultiphaseStep) -> Option { let same_x = step.from.x1() == step.to.x1() && step.from.x2() == step.to.x2(); let same_y = step.from.y1() == step.to.y1() && step.from.y2() == step.to.y2(); @@ -526,7 +661,7 @@ mod tests { ); assert_eq!(plan.phases[0].steps[0].to, rect(0, 0, 100, 50)); assert_eq!(plan.phases[0].steps[1].to, rect(100, 50, 200, 100)); - assert!(validate_plan_samples(&req, &plan)); + assert!(validate_plan_continuous(&req, &plan)); } #[test] @@ -546,7 +681,7 @@ mod tests { let plan = plan_no_overlap(&req).unwrap(); assert_eq!(plan.phases[0].steps[0].to, rect(100, 0, 200, 50)); assert_eq!(plan.phases[0].steps[1].to, rect(0, 50, 100, 100)); - assert!(validate_plan_samples(&req, &plan)); + assert!(validate_plan_continuous(&req, &plan)); } #[test] @@ -591,7 +726,7 @@ mod tests { assert_eq!(plan.phases[1].steps[0].to, rect(100, 0, 300, 50)); assert_eq!(plan.phases[2].steps[0].to, rect(100, 0, 300, 100)); assert_eq!(plan.phases[2].steps[1].to, rect(300, 0, 400, 100)); - assert!(validate_plan_samples(&req, &plan)); + assert!(validate_plan_continuous(&req, &plan)); } #[test] @@ -631,7 +766,7 @@ mod tests { }, ] ); - assert!(validate_plan_samples(&req, &plan)); + assert!(validate_plan_continuous(&req, &plan)); } #[test] @@ -676,7 +811,7 @@ mod tests { assert_eq!(plan.phases[1].steps[0].to, rect(0, 100, 50, 300)); assert_eq!(plan.phases[2].steps[0].to, rect(0, 100, 100, 300)); assert_eq!(plan.phases[2].steps[1].to, rect(0, 300, 100, 400)); - assert!(validate_plan_samples(&req, &plan)); + assert!(validate_plan_continuous(&req, &plan)); } #[test] @@ -716,7 +851,7 @@ mod tests { }, ] ); - assert!(validate_plan_samples(&req, &plan)); + assert!(validate_plan_continuous(&req, &plan)); } #[test] @@ -729,6 +864,92 @@ mod tests { assert_eq!(plan_no_overlap(&req), Err(MultiphaseError::NoPlan)); } + #[test] + fn continuous_validation_rejects_narrow_mid_phase_overlap() { + let req = request(vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 10, 10), + to: rect(100, 0, 110, 10), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(13, 0, 14, 10), + to: rect(13, 0, 14, 10), + }, + ]); + let plan = MultiphasePlan { + phases: vec![MultiphasePhase { + action: PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }, + steps: vec![MultiphaseStep { + node_id: id(1), + from: rect(0, 0, 10, 10), + to: rect(100, 0, 110, 10), + }], + }], + }; + + assert!(!validate_plan_continuous(&req, &plan)); + } + + #[test] + fn continuous_validation_allows_edge_touching_motion() { + let req = request(vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 10, 10), + to: rect(10, 0, 20, 10), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(20, 0, 30, 10), + to: rect(20, 0, 30, 10), + }, + ]); + let plan = MultiphasePlan { + phases: vec![MultiphasePhase { + action: PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }, + steps: vec![MultiphaseStep { + node_id: id(1), + from: rect(0, 0, 10, 10), + to: rect(10, 0, 20, 10), + }], + }], + }; + + assert!(validate_plan_continuous(&req, &plan)); + } + + #[test] + fn continuous_validation_rejects_stale_step_start_rect() { + let req = request(vec![MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 10, 10), + to: rect(20, 0, 30, 10), + }]); + let plan = MultiphasePlan { + phases: vec![MultiphasePhase { + action: PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }, + steps: vec![MultiphaseStep { + node_id: id(1), + from: rect(5, 0, 15, 10), + to: rect(20, 0, 30, 10), + }], + }], + }; + + assert!(!validate_plan_continuous(&req, &plan)); + } + #[test] fn motion_groups_split_disjoint_layout_changes() { let windows = vec![ From a712786ecf5f39d89496ee529cbfda65dde37359 Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 19:48:13 +1000 Subject: [PATCH 17/47] Choose swap lanes by motion direction --- docs/window-animations-plan.md | 3 ++ src/animation/multiphase.rs | 69 ++++++++++++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index a659d76a..3a79fafd 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -217,6 +217,9 @@ Preferred approach: Current pure planner status: - Two-window same-axis swaps use shrink lanes, move, then grow. +- Swap lane choice follows motion direction, not node identity: right/down + moving windows take the first lane, and left/up moving windows take the second + lane. - Stack extraction/return patterns are covered in both horizontal and vertical orientations: peer/container space scales first, the extracted child moves only after space exists, and orthogonal growth happens in the final phase. diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index 37b31619..3a259359 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -173,7 +173,12 @@ fn plan_axis_crossing_lanes( } let mut windows = request.windows.clone(); - windows.sort_by_key(|window| window.node_id.0); + windows.sort_by_key(|window| lane_index_for_direction(*window, axis)); + if windows.windows(2).any(|pair| { + lane_index_for_direction(pair[0], axis) == lane_index_for_direction(pair[1], axis) + }) { + return None; + } let mut phase1 = vec![]; let mut phase2 = vec![]; let mut phase3 = vec![]; @@ -205,6 +210,15 @@ fn plan_axis_crossing_lanes( ) } +fn lane_index_for_direction(window: MultiphaseWindow, axis: PhaseAxis) -> Option { + let delta = main_start(window.to, axis) - main_start(window.from, axis); + match delta.cmp(&0) { + std::cmp::Ordering::Greater => Some(0), + std::cmp::Ordering::Less => Some(1), + std::cmp::Ordering::Equal => None, + } +} + fn plan_space_then_orthogonal_growth( request: &MultiphaseRequest, axis: PhaseAxis, @@ -627,6 +641,15 @@ mod tests { plan.phases.iter().map(|phase| phase.action).collect() } + fn step_to(plan: &MultiphasePlan, phase: usize, node_id: NodeId) -> Rect { + plan.phases[phase] + .steps + .iter() + .find(|step| step.node_id == node_id) + .unwrap() + .to + } + #[test] fn horizontal_swap_shrinks_moves_then_grows_without_overlap() { let req = request(vec![ @@ -679,8 +702,48 @@ mod tests { }, ]); let plan = plan_no_overlap(&req).unwrap(); - assert_eq!(plan.phases[0].steps[0].to, rect(100, 0, 200, 50)); - assert_eq!(plan.phases[0].steps[1].to, rect(0, 50, 100, 100)); + assert_eq!(step_to(&plan, 0, id(2)), rect(0, 0, 100, 50)); + assert_eq!(step_to(&plan, 0, id(1)), rect(100, 50, 200, 100)); + assert!(validate_plan_continuous(&req, &plan)); + } + + #[test] + fn horizontal_swap_lanes_follow_motion_direction_not_node_id() { + let req = request(vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(100, 0, 200, 100), + to: rect(0, 0, 100, 100), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(0, 0, 100, 100), + to: rect(100, 0, 200, 100), + }, + ]); + let plan = plan_no_overlap(&req).unwrap(); + assert_eq!(step_to(&plan, 0, id(2)), rect(0, 0, 100, 50)); + assert_eq!(step_to(&plan, 0, id(1)), rect(100, 50, 200, 100)); + assert!(validate_plan_continuous(&req, &plan)); + } + + #[test] + fn vertical_swap_lanes_follow_motion_direction_not_node_id() { + let req = request(vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 100, 100, 200), + to: rect(0, 0, 100, 100), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(0, 0, 100, 100), + to: rect(0, 100, 100, 200), + }, + ]); + let plan = plan_no_overlap(&req).unwrap(); + assert_eq!(step_to(&plan, 0, id(2)), rect(0, 0, 50, 100)); + assert_eq!(step_to(&plan, 0, id(1)), rect(50, 100, 100, 200)); assert!(validate_plan_continuous(&req, &plan)); } From 90c00bcdf33f6b3b54f95bef9ca57647e8a5cc7f Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 19:55:16 +1000 Subject: [PATCH 18/47] Carry hierarchy metadata into multiphase planning --- docs/window-animations-plan.md | 4 + src/animation/multiphase.rs | 179 ++++++++++++++++++++++++++++++++- src/state.rs | 53 ++++++++-- src/tree/toplevel.rs | 56 ++++++++++- 4 files changed, 276 insertions(+), 16 deletions(-) diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index 3a79fafd..fd99ac39 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -229,6 +229,10 @@ Current pure planner status: - Live layout batches are partitioned by overlapping motion bounds, so unrelated groups can still use multiphase animation when another group falls back to linear motion. +- Planner requests now carry per-window hierarchy metadata for source/target + parent, depth, sibling index, split axis, mono state, and transition kind. + The current planner records this information but does not yet use it to order + nested-container phases. Tests: diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index 3a259359..75c63493 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -13,6 +13,104 @@ pub struct MultiphaseWindow { pub node_id: NodeId, pub from: Rect, pub to: Rect, + pub hierarchy: MultiphaseWindowHierarchy, +} + +impl MultiphaseWindow { + pub fn new(node_id: NodeId, from: Rect, to: Rect) -> Self { + Self { + node_id, + from, + to, + hierarchy: Default::default(), + } + } + + pub fn with_hierarchy( + node_id: NodeId, + from: Rect, + to: Rect, + hierarchy: MultiphaseWindowHierarchy, + ) -> Self { + Self { + node_id, + from, + to, + hierarchy, + } + } +} + +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub struct MultiphaseWindowHierarchy { + pub source: MultiphaseHierarchyPosition, + pub target: MultiphaseHierarchyPosition, + pub transition: MultiphaseHierarchyTransition, +} + +impl MultiphaseWindowHierarchy { + pub fn new(source: MultiphaseHierarchyPosition, target: MultiphaseHierarchyPosition) -> Self { + let transition = if !source.parent_is_mono && target.parent_is_mono { + MultiphaseHierarchyTransition::EnteringMono + } else if source.parent_is_mono && !target.parent_is_mono { + MultiphaseHierarchyTransition::ExitingMono + } else if source.parent.is_none() || target.parent.is_none() { + MultiphaseHierarchyTransition::Unknown + } else if target.depth < source.depth { + MultiphaseHierarchyTransition::Ascending + } else if target.depth > source.depth { + MultiphaseHierarchyTransition::Descending + } else { + MultiphaseHierarchyTransition::SameLevel + }; + Self { + source, + target, + transition, + } + } + + fn reversed(self) -> Self { + Self { + source: self.target, + target: self.source, + transition: self.transition.reversed(), + } + } +} + +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub struct MultiphaseHierarchyPosition { + pub parent: Option, + pub depth: u16, + pub sibling_index: Option, + pub split_axis: Option, + pub parent_is_mono: bool, + pub mono_active: bool, +} + +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub enum MultiphaseHierarchyTransition { + #[default] + Unknown, + SameLevel, + Ascending, + Descending, + EnteringMono, + ExitingMono, +} + +impl MultiphaseHierarchyTransition { + fn reversed(self) -> Self { + match self { + Self::Unknown => Self::Unknown, + Self::SameLevel => Self::SameLevel, + Self::Ascending => Self::Descending, + Self::Descending => Self::Ascending, + Self::EnteringMono => Self::ExitingMono, + Self::ExitingMono => Self::EnteringMono, + } + } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -519,6 +617,7 @@ fn reverse_request(request: &MultiphaseRequest) -> MultiphaseRequest { node_id: window.node_id, from: window.to, to: window.from, + hierarchy: window.hierarchy.reversed(), }) .collect(), } @@ -628,6 +727,10 @@ mod tests { Rect::new_saturating(x1, y1, x2, y2) } + fn window(raw: u32, from: Rect, to: Rect) -> MultiphaseWindow { + MultiphaseWindow::new(id(raw), from, to) + } + fn request(windows: Vec) -> MultiphaseRequest { let bounds = windows .iter() @@ -653,15 +756,12 @@ mod tests { #[test] fn horizontal_swap_shrinks_moves_then_grows_without_overlap() { let req = request(vec![ - MultiphaseWindow { - node_id: id(1), - from: rect(0, 0, 100, 100), - to: rect(100, 0, 200, 100), - }, + window(1, rect(0, 0, 100, 100), rect(100, 0, 200, 100)), MultiphaseWindow { node_id: id(2), from: rect(100, 0, 200, 100), to: rect(0, 0, 100, 100), + hierarchy: Default::default(), }, ]); let plan = plan_no_overlap(&req).unwrap(); @@ -694,11 +794,13 @@ mod tests { node_id: id(1), from: rect(100, 0, 200, 100), to: rect(0, 0, 100, 100), + hierarchy: Default::default(), }, MultiphaseWindow { node_id: id(2), from: rect(0, 0, 100, 100), to: rect(100, 0, 200, 100), + hierarchy: Default::default(), }, ]); let plan = plan_no_overlap(&req).unwrap(); @@ -714,11 +816,13 @@ mod tests { node_id: id(1), from: rect(100, 0, 200, 100), to: rect(0, 0, 100, 100), + hierarchy: Default::default(), }, MultiphaseWindow { node_id: id(2), from: rect(0, 0, 100, 100), to: rect(100, 0, 200, 100), + hierarchy: Default::default(), }, ]); let plan = plan_no_overlap(&req).unwrap(); @@ -734,11 +838,13 @@ mod tests { node_id: id(1), from: rect(0, 100, 100, 200), to: rect(0, 0, 100, 100), + hierarchy: Default::default(), }, MultiphaseWindow { node_id: id(2), from: rect(0, 0, 100, 100), to: rect(0, 100, 100, 200), + hierarchy: Default::default(), }, ]); let plan = plan_no_overlap(&req).unwrap(); @@ -754,16 +860,19 @@ mod tests { node_id: id(1), from: rect(0, 0, 200, 100), to: rect(0, 0, 100, 100), + hierarchy: Default::default(), }, MultiphaseWindow { node_id: id(2), from: rect(200, 0, 400, 50), to: rect(100, 0, 300, 100), + hierarchy: Default::default(), }, MultiphaseWindow { node_id: id(3), from: rect(200, 50, 400, 100), to: rect(300, 0, 400, 100), + hierarchy: Default::default(), }, ]); let plan = plan_no_overlap(&req).unwrap(); @@ -799,16 +908,19 @@ mod tests { node_id: id(1), from: rect(0, 0, 100, 100), to: rect(0, 0, 200, 100), + hierarchy: Default::default(), }, MultiphaseWindow { node_id: id(2), from: rect(100, 0, 300, 100), to: rect(200, 0, 400, 50), + hierarchy: Default::default(), }, MultiphaseWindow { node_id: id(3), from: rect(300, 0, 400, 100), to: rect(200, 50, 400, 100), + hierarchy: Default::default(), }, ]); let plan = plan_no_overlap(&req).unwrap(); @@ -839,16 +951,19 @@ mod tests { node_id: id(1), from: rect(0, 0, 100, 200), to: rect(0, 0, 100, 100), + hierarchy: Default::default(), }, MultiphaseWindow { node_id: id(2), from: rect(0, 200, 50, 400), to: rect(0, 100, 100, 300), + hierarchy: Default::default(), }, MultiphaseWindow { node_id: id(3), from: rect(50, 200, 100, 400), to: rect(0, 300, 100, 400), + hierarchy: Default::default(), }, ]); let plan = plan_no_overlap(&req).unwrap(); @@ -884,16 +999,19 @@ mod tests { node_id: id(1), from: rect(0, 0, 100, 100), to: rect(0, 0, 100, 200), + hierarchy: Default::default(), }, MultiphaseWindow { node_id: id(2), from: rect(0, 100, 100, 300), to: rect(0, 200, 50, 400), + hierarchy: Default::default(), }, MultiphaseWindow { node_id: id(3), from: rect(0, 300, 100, 400), to: rect(50, 200, 100, 400), + hierarchy: Default::default(), }, ]); let plan = plan_no_overlap(&req).unwrap(); @@ -923,10 +1041,50 @@ mod tests { node_id: id(1), from: rect(0, 0, 100, 100), to: rect(100, 100, 200, 200), + hierarchy: Default::default(), }]); assert_eq!(plan_no_overlap(&req), Err(MultiphaseError::NoPlan)); } + #[test] + fn hierarchy_metadata_classifies_depth_and_mono_transitions() { + let source = MultiphaseHierarchyPosition { + parent: Some(id(10)), + depth: 2, + sibling_index: Some(0), + split_axis: Some(PhaseAxis::Vertical), + ..Default::default() + }; + let target = MultiphaseHierarchyPosition { + parent: Some(id(11)), + depth: 1, + sibling_index: Some(2), + split_axis: Some(PhaseAxis::Horizontal), + ..Default::default() + }; + assert_eq!( + MultiphaseWindowHierarchy::new(source, target).transition, + MultiphaseHierarchyTransition::Ascending + ); + + let entering_mono = MultiphaseWindowHierarchy::new( + source, + MultiphaseHierarchyPosition { + parent_is_mono: true, + mono_active: true, + ..target + }, + ); + assert_eq!( + entering_mono.transition, + MultiphaseHierarchyTransition::EnteringMono + ); + assert_eq!( + entering_mono.reversed().transition, + MultiphaseHierarchyTransition::ExitingMono + ); + } + #[test] fn continuous_validation_rejects_narrow_mid_phase_overlap() { let req = request(vec![ @@ -934,11 +1092,13 @@ mod tests { node_id: id(1), from: rect(0, 0, 10, 10), to: rect(100, 0, 110, 10), + hierarchy: Default::default(), }, MultiphaseWindow { node_id: id(2), from: rect(13, 0, 14, 10), to: rect(13, 0, 14, 10), + hierarchy: Default::default(), }, ]); let plan = MultiphasePlan { @@ -965,11 +1125,13 @@ mod tests { node_id: id(1), from: rect(0, 0, 10, 10), to: rect(10, 0, 20, 10), + hierarchy: Default::default(), }, MultiphaseWindow { node_id: id(2), from: rect(20, 0, 30, 10), to: rect(20, 0, 30, 10), + hierarchy: Default::default(), }, ]); let plan = MultiphasePlan { @@ -995,6 +1157,7 @@ mod tests { node_id: id(1), from: rect(0, 0, 10, 10), to: rect(20, 0, 30, 10), + hierarchy: Default::default(), }]); let plan = MultiphasePlan { phases: vec![MultiphasePhase { @@ -1020,16 +1183,19 @@ mod tests { node_id: id(1), from: rect(0, 0, 100, 100), to: rect(100, 0, 200, 100), + hierarchy: Default::default(), }, MultiphaseWindow { node_id: id(2), from: rect(100, 0, 200, 100), to: rect(0, 0, 100, 100), + hierarchy: Default::default(), }, MultiphaseWindow { node_id: id(3), from: rect(300, 0, 400, 100), to: rect(400, 0, 500, 100), + hierarchy: Default::default(), }, ]; assert_eq!(partition_motion_groups(&windows), vec![vec![0, 1], vec![2]]); @@ -1042,16 +1208,19 @@ mod tests { node_id: id(1), from: rect(0, 0, 100, 100), to: rect(80, 0, 180, 100), + hierarchy: Default::default(), }, MultiphaseWindow { node_id: id(2), from: rect(170, 0, 270, 100), to: rect(250, 0, 350, 100), + hierarchy: Default::default(), }, MultiphaseWindow { node_id: id(3), from: rect(90, 0, 180, 100), to: rect(180, 0, 260, 100), + hierarchy: Default::default(), }, ]; assert_eq!(partition_motion_groups(&windows), vec![vec![0, 1, 2]]); diff --git a/src/state.rs b/src/state.rs index 835678f8..0d120721 100644 --- a/src/state.rs +++ b/src/state.rs @@ -6,7 +6,8 @@ use { AnimationCurve, AnimationState, AnimationTick, RetainedExitLayer, RetainedToplevel, expand_damage_rect, multiphase::{ - MultiphaseRequest, MultiphaseWindow, partition_motion_groups, plan_no_overlap, + MultiphaseRequest, MultiphaseWindow, MultiphaseWindowHierarchy, + partition_motion_groups, plan_no_overlap, }, spawn_in_start_rect, }, @@ -168,6 +169,7 @@ pub(crate) struct LayoutAnimationCandidate { new: Rect, retained: Option>, curve: AnimationCurve, + hierarchy: MultiphaseWindowHierarchy, } pub struct State { @@ -1500,7 +1502,29 @@ impl State { .layout_animation_curve_override .get() .unwrap_or_else(|| self.animations.curve.get()); - self.queue_layout_animation(node_id, old, new, retained, curve); + self.queue_layout_animation( + node_id, + old, + new, + retained, + curve, + MultiphaseWindowHierarchy::default(), + ); + } + + pub fn queue_tiled_animation_with_hierarchy( + self: &Rc, + node_id: NodeId, + old: Rect, + new: Rect, + retained: Option>, + hierarchy: MultiphaseWindowHierarchy, + ) { + let curve = self + .layout_animation_curve_override + .get() + .unwrap_or_else(|| self.animations.curve.get()); + self.queue_layout_animation(node_id, old, new, retained, curve, hierarchy); } pub fn queue_linear_layout_animation( @@ -1510,7 +1534,14 @@ impl State { new: Rect, retained: Option>, ) { - self.queue_layout_animation(node_id, old, new, retained, AnimationCurve::Linear); + self.queue_layout_animation( + node_id, + old, + new, + retained, + AnimationCurve::Linear, + MultiphaseWindowHierarchy::default(), + ); } fn queue_layout_animation( @@ -1520,6 +1551,7 @@ impl State { new: Rect, retained: Option>, curve: AnimationCurve, + hierarchy: MultiphaseWindowHierarchy, ) { if !self.animations.enabled.get() || !self.layout_animations_active.get() @@ -1546,6 +1578,7 @@ impl State { new, retained, curve, + hierarchy, }; if let Some(batch) = self.layout_animation_batch.borrow_mut().as_mut() { batch.push(candidate); @@ -1590,12 +1623,14 @@ impl State { let now = self.now_nsec(); let windows: Vec<_> = candidates .iter() - .map(|candidate| MultiphaseWindow { - node_id: candidate.node_id, - from: self - .animations - .visual_rect(candidate.node_id, candidate.old, now), - to: candidate.new, + .map(|candidate| { + MultiphaseWindow::with_hierarchy( + candidate.node_id, + self.animations + .visual_rect(candidate.node_id, candidate.old, now), + candidate.new, + candidate.hierarchy, + ) }) .collect(); for group in partition_motion_groups(&windows) { diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index 41db95d1..d5ace6bb 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -1,6 +1,9 @@ use { crate::{ - animation::{RetainedExitLayer, RetainedToplevel}, + animation::{ + RetainedExitLayer, RetainedToplevel, + multiphase::{MultiphaseHierarchyPosition, MultiphaseWindowHierarchy, PhaseAxis}, + }, client::{Client, ClientId}, criteria::{ CritDestroyListener, CritMatcherId, @@ -186,6 +189,11 @@ impl ToplevelNode for T { fn tl_change_extents(self: Rc, rect: &Rect) { let data = self.tl_data(); let prev = data.desired_extents.replace(*rect); + let target_hierarchy = self.tl_multiphase_hierarchy_position(); + let hierarchy = MultiphaseWindowHierarchy::new( + data.layout_animation_position.replace(target_hierarchy), + target_hierarchy, + ); let spawn_in_pending = data.spawn_in_pending.get(); let parent_is_mono = data .parent @@ -200,11 +208,12 @@ impl ToplevelNode for T { && !self.node_is_container() && !parent_is_mono { - data.state.clone().queue_tiled_animation( + data.state.clone().queue_tiled_animation_with_hierarchy( data.node_id, prev, *rect, self.tl_animation_snapshot(), + hierarchy, ); } if spawn_in_pending @@ -314,6 +323,35 @@ pub trait ToplevelNodeBase: Node { true } + fn tl_multiphase_hierarchy_position(&self) -> MultiphaseHierarchyPosition { + let data = self.tl_data(); + let Some(parent) = data.parent.get() else { + return Default::default(); + }; + let mut position = MultiphaseHierarchyPosition { + parent: Some(parent.node_id()), + depth: multiphase_parent_depth(Some(parent.clone())), + ..Default::default() + }; + if let Some(container) = parent.node_into_container() { + position.split_axis = Some(match container.split.get() { + ContainerSplit::Horizontal => PhaseAxis::Horizontal, + ContainerSplit::Vertical => PhaseAxis::Vertical, + }); + if let Some(mono) = container.mono_child.get() { + position.parent_is_mono = true; + position.mono_active = mono.node.node_id() == data.node_id; + } + for (idx, child) in container.children.iter().enumerate() { + if child.node.node_id() == data.node_id { + position.sibling_index = Some(idx.min(u16::MAX as usize) as u16); + break; + } + } + } + position + } + fn tl_set_active(&self, active: bool) { let _ = active; } @@ -383,6 +421,18 @@ pub trait ToplevelNodeBase: Node { } } +fn multiphase_parent_depth(mut parent: Option>) -> u16 { + let mut depth = 0u16; + while let Some(node) = parent { + let Some(toplevel) = node.node_into_toplevel() else { + break; + }; + depth = depth.saturating_add(1); + parent = toplevel.tl_data().parent.get(); + } + depth +} + pub struct FullscreenedData { pub placeholder: Rc, pub workspace: Rc, @@ -453,6 +503,7 @@ pub struct ToplevelData { pub spawn_in_pending: Cell, pub pos: Cell, pub desired_extents: Cell, + pub layout_animation_position: Cell, pub seat_state: NodeSeatState, pub wants_attention: Cell, pub requested_attention: Cell, @@ -517,6 +568,7 @@ impl ToplevelData { spawn_in_pending: Cell::new(false), pos: Default::default(), desired_extents: Default::default(), + layout_animation_position: Default::default(), seat_state: Default::default(), wants_attention: Cell::new(false), requested_attention: Cell::new(false), From cc898590d24351623c14bf9b59aca0261c7ea98d Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 19:59:19 +1000 Subject: [PATCH 19/47] Report multiphase planning diagnostics --- docs/window-animations-plan.md | 4 + src/animation/multiphase.rs | 265 ++++++++++++++++++++++++++------- src/state.rs | 17 ++- 3 files changed, 230 insertions(+), 56 deletions(-) diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index fd99ac39..61c91b84 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -233,6 +233,10 @@ Current pure planner status: parent, depth, sibling index, split axis, mono state, and transition kind. The current planner records this information but does not yet use it to order nested-container phases. +- Multiphase planning has a diagnostic entry point used by live fallback logs. + It distinguishes request validation errors, missing patterns, shrink-bound + rejections, invalid phase steps, and exact validation failures such as stale + starts or phase overlap. Tests: diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index 75c63493..641e8826 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -159,8 +159,59 @@ pub enum MultiphaseError { NoPlan, } +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct MultiphasePlanDiagnostic { + pub forward: MultiphasePlanFailure, + pub reverse: Option, +} + +impl MultiphasePlanDiagnostic { + fn legacy_error(self) -> MultiphaseError { + match self.forward { + MultiphasePlanFailure::Request(error) => error, + _ => MultiphaseError::NoPlan, + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum MultiphasePlanFailure { + Request(MultiphaseError), + NoPattern, + ShrinkBound { + axis: PhaseAxis, + available: i32, + required: i32, + }, + InvalidPhaseStep { + action: PhaseAction, + node_id: NodeId, + }, + Validation(MultiphaseValidationError), +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum MultiphaseValidationError { + DuplicatePhaseStep { phase: usize, node_id: NodeId }, + UnknownPhaseStep { phase: usize, node_id: NodeId }, + StaleStepStart { phase: usize, node_id: NodeId }, + PhaseOverlap { phase: usize, a: NodeId, b: NodeId }, + FinalMismatch { node_id: NodeId }, +} + pub fn plan_no_overlap(request: &MultiphaseRequest) -> Result { - validate_request(request)?; + plan_no_overlap_with_diagnostics(request).map_err(|diagnostic| diagnostic.legacy_error()) +} + +pub fn plan_no_overlap_with_diagnostics( + request: &MultiphaseRequest, +) -> Result { + if let Err(error) = validate_request(request) { + return Err(MultiphasePlanDiagnostic { + forward: MultiphasePlanFailure::Request(error), + reverse: None, + }); + } if request .windows .iter() @@ -168,14 +219,18 @@ pub fn plan_no_overlap(request: &MultiphaseRequest) -> Result return Ok(plan), + Err(error) => error, + }; let reversed = reverse_request(request); - if let Some(plan) = plan_forward(&reversed) { - return Ok(reverse_plan(plan)); + match plan_forward(&reversed) { + Ok(plan) => Ok(reverse_plan(plan)), + Err(reverse) => Err(MultiphasePlanDiagnostic { + forward, + reverse: Some(reverse), + }), } - Err(MultiphaseError::NoPlan) } pub(crate) fn partition_motion_groups(windows: &[MultiphaseWindow]) -> Vec> { @@ -228,33 +283,48 @@ fn validate_request(request: &MultiphaseRequest) -> Result<(), MultiphaseError> Ok(()) } -fn plan_forward(request: &MultiphaseRequest) -> Option { +fn plan_forward(request: &MultiphaseRequest) -> Result { + let mut rejection = None; for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { - if let Some(plan) = plan_axis_crossing_lanes(request, axis) { - return Some(plan); + match plan_axis_crossing_lanes(request, axis) { + Ok(plan) => return Ok(plan), + Err(MultiphasePlanFailure::NoPattern) => {} + Err(error) => { + rejection.get_or_insert(error); + } } } - plan_space_then_orthogonal_growth(request, PhaseAxis::Horizontal) - .or_else(|| plan_space_then_orthogonal_growth(request, PhaseAxis::Vertical)) + for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { + match plan_space_then_orthogonal_growth(request, axis) { + Ok(plan) => return Ok(plan), + Err(MultiphasePlanFailure::NoPattern) => {} + Err(error) => { + rejection.get_or_insert(error); + } + } + } + Err(rejection.unwrap_or(MultiphasePlanFailure::NoPattern)) } fn plan_axis_crossing_lanes( request: &MultiphaseRequest, axis: PhaseAxis, -) -> Option { +) -> Result { if request.windows.len() != 2 { - return None; + return Err(MultiphasePlanFailure::NoPattern); } let orth_min = request .windows .iter() .map(|window| orth_start(window.from, axis)) - .min()?; + .min() + .ok_or(MultiphasePlanFailure::NoPattern)?; let orth_max = request .windows .iter() .map(|window| orth_end(window.from, axis)) - .max()?; + .max() + .ok_or(MultiphasePlanFailure::NoPattern)?; if request.windows.iter().any(|window| { main_size(window.from, axis) != main_size(window.to, axis) || orth_start(window.from, axis) != orth_min @@ -263,11 +333,16 @@ fn plan_axis_crossing_lanes( || orth_end(window.to, axis) != orth_max || main_start(window.from, axis) == main_start(window.to, axis) }) { - return None; + return Err(MultiphasePlanFailure::NoPattern); } let lane_size = (orth_max - orth_min) / request.windows.len() as i32; - if lane_size < sane_min_size(orth_max - orth_min) { - return None; + let required = sane_min_size(orth_max - orth_min); + if lane_size < required { + return Err(MultiphasePlanFailure::ShrinkBound { + axis: axis.other(), + available: lane_size, + required, + }); } let mut windows = request.windows.clone(); @@ -275,7 +350,7 @@ fn plan_axis_crossing_lanes( if windows.windows(2).any(|pair| { lane_index_for_direction(pair[0], axis) == lane_index_for_direction(pair[1], axis) }) { - return None; + return Err(MultiphasePlanFailure::NoPattern); } let mut phase1 = vec![]; let mut phase2 = vec![]; @@ -320,9 +395,9 @@ fn lane_index_for_direction(window: MultiphaseWindow, axis: PhaseAxis) -> Option fn plan_space_then_orthogonal_growth( request: &MultiphaseRequest, axis: PhaseAxis, -) -> Option { +) -> Result { if request.windows.len() < 2 { - return None; + return Err(MultiphasePlanFailure::NoPattern); } let orth_axis = axis.other(); let min_width = sane_min_size(request.bounds.width()); @@ -331,8 +406,19 @@ fn plan_space_then_orthogonal_growth( let mut phase2 = vec![]; let mut phase3 = vec![]; for window in &request.windows { - if window.to.width() < min_width || window.to.height() < min_height { - return None; + if window.to.width() < min_width { + return Err(MultiphasePlanFailure::ShrinkBound { + axis: PhaseAxis::Horizontal, + available: window.to.width(), + required: min_width, + }); + } + if window.to.height() < min_height { + return Err(MultiphasePlanFailure::ShrinkBound { + axis: PhaseAxis::Vertical, + available: window.to.height(), + required: min_height, + }); } let main_changes = main_start(window.from, axis) != main_start(window.to, axis) || main_end(window.from, axis) != main_end(window.to, axis); @@ -365,7 +451,7 @@ fn plan_space_then_orthogonal_growth( } } if phase1.is_empty() || phase2.is_empty() || phase3.is_empty() { - return None; + return Err(MultiphasePlanFailure::NoPattern); } build_validated_plan( request, @@ -380,7 +466,7 @@ fn plan_space_then_orthogonal_growth( fn build_validated_plan( request: &MultiphaseRequest, phases: [(PhaseKind, PhaseAxis, Vec); N], -) -> Option { +) -> Result { let phases: Vec<_> = phases .into_iter() .filter_map(|(kind, axis, steps)| { @@ -390,41 +476,58 @@ fn build_validated_plan( }) }) .collect(); - if phases.iter().any(|phase| { - phase - .steps - .iter() - .any(|step| classify_step(*step) != Some(phase.action)) - }) { - return None; + for phase in &phases { + for step in &phase.steps { + if classify_step(*step) != Some(phase.action) { + return Err(MultiphasePlanFailure::InvalidPhaseStep { + action: phase.action, + node_id: step.node_id, + }); + } + } } let plan = MultiphasePlan { phases }; - validate_plan_continuous(request, &plan).then_some(plan) + validate_plan_continuous_diagnostic(request, &plan) + .map(|_| plan) + .map_err(MultiphasePlanFailure::Validation) } fn validate_plan_continuous(request: &MultiphaseRequest, plan: &MultiphasePlan) -> bool { + validate_plan_continuous_diagnostic(request, plan).is_ok() +} + +fn validate_plan_continuous_diagnostic( + request: &MultiphaseRequest, + plan: &MultiphasePlan, +) -> Result<(), MultiphaseValidationError> { let mut current: Vec<_> = request .windows .iter() .map(|window| (window.node_id, window.from)) .collect(); - if overlaps(current.iter().map(|(_, rect)| *rect)) { - return false; - } - for phase in &plan.phases { + for (phase_idx, phase) in plan.phases.iter().enumerate() { for (idx, step) in phase.steps.iter().enumerate() { if phase.steps[..idx] .iter() .any(|prev| prev.node_id == step.node_id) { - return false; + return Err(MultiphaseValidationError::DuplicatePhaseStep { + phase: phase_idx, + node_id: step.node_id, + }); } let Some((_, rect)) = current.iter().find(|(node_id, _)| *node_id == step.node_id) else { - return false; + return Err(MultiphaseValidationError::UnknownPhaseStep { + phase: phase_idx, + node_id: step.node_id, + }); }; if *rect != step.from { - return false; + return Err(MultiphaseValidationError::StaleStepStart { + phase: phase_idx, + node_id: step.node_id, + }); } } let motions: Vec<_> = current @@ -440,11 +543,16 @@ fn validate_plan_continuous(request: &MultiphaseRequest, plan: &MultiphasePlan) }) .collect(); for (idx, motion) in motions.iter().enumerate() { - if motions[idx + 1..] + if let Some((other_idx, _)) = motions[idx + 1..] .iter() - .any(|other| motions_overlap_during_phase(*motion, *other)) + .enumerate() + .find(|(_, other)| motions_overlap_during_phase(*motion, **other)) { - return false; + return Err(MultiphaseValidationError::PhaseOverlap { + phase: phase_idx, + a: current[idx].0, + b: current[idx + 1 + other_idx].0, + }); } } for step in &phase.steps { @@ -454,16 +562,19 @@ fn validate_plan_continuous(request: &MultiphaseRequest, plan: &MultiphasePlan) .unwrap(); *rect = step.to; } - if overlaps(current.iter().map(|(_, rect)| *rect)) { - return false; - } } - request.windows.iter().all(|window| { - current + for window in &request.windows { + if !current .iter() .find(|(node_id, _)| *node_id == window.node_id) .is_some_and(|(_, rect)| *rect == window.to) - }) + { + return Err(MultiphaseValidationError::FinalMismatch { + node_id: window.node_id, + }); + } + } + Ok(()) } #[derive(Copy, Clone)] @@ -1044,6 +1155,43 @@ mod tests { hierarchy: Default::default(), }]); assert_eq!(plan_no_overlap(&req), Err(MultiphaseError::NoPlan)); + assert_eq!( + plan_no_overlap_with_diagnostics(&req).unwrap_err(), + MultiphasePlanDiagnostic { + forward: MultiphasePlanFailure::NoPattern, + reverse: Some(MultiphasePlanFailure::NoPattern), + } + ); + } + + #[test] + fn diagnostics_report_shrink_bound_rejections() { + let req = MultiphaseRequest { + bounds: rect(0, 0, 400, 100), + windows: vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 200, 100), + to: rect(0, 0, 10, 100), + hierarchy: Default::default(), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(200, 0, 400, 100), + to: rect(10, 0, 400, 100), + hierarchy: Default::default(), + }, + ], + }; + + assert!(matches!( + plan_no_overlap_with_diagnostics(&req).unwrap_err().forward, + MultiphasePlanFailure::ShrinkBound { + axis: PhaseAxis::Horizontal, + available: 10, + required: 100, + } + )); } #[test] @@ -1115,7 +1263,14 @@ mod tests { }], }; - assert!(!validate_plan_continuous(&req, &plan)); + assert_eq!( + validate_plan_continuous_diagnostic(&req, &plan), + Err(MultiphaseValidationError::PhaseOverlap { + phase: 0, + a: id(1), + b: id(2), + }) + ); } #[test] @@ -1173,7 +1328,13 @@ mod tests { }], }; - assert!(!validate_plan_continuous(&req, &plan)); + assert_eq!( + validate_plan_continuous_diagnostic(&req, &plan), + Err(MultiphaseValidationError::StaleStepStart { + phase: 0, + node_id: id(1), + }) + ); } #[test] diff --git a/src/state.rs b/src/state.rs index 0d120721..4fc15231 100644 --- a/src/state.rs +++ b/src/state.rs @@ -7,7 +7,7 @@ use { expand_damage_rect, multiphase::{ MultiphaseRequest, MultiphaseWindow, MultiphaseWindowHierarchy, - partition_motion_groups, plan_no_overlap, + partition_motion_groups, plan_no_overlap_with_diagnostics, }, spawn_in_start_rect, }, @@ -1661,11 +1661,20 @@ impl State { for window in &request_windows[1..] { bounds = bounds.union(window.from).union(window.to); } - let Ok(plan) = plan_no_overlap(&MultiphaseRequest { + let request = MultiphaseRequest { bounds, windows: request_windows, - }) else { - return false; + }; + let plan = match plan_no_overlap_with_diagnostics(&request) { + Ok(plan) => plan, + Err(diagnostic) => { + log::debug!( + "falling back to linear layout animation for group {:?}: {:?}", + group, + diagnostic + ); + return false; + } }; if plan.phases.is_empty() { return false; From a770089b88b5fa5b46a708996fd8bd6008bb2c67 Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 20:21:07 +1000 Subject: [PATCH 20/47] Exercise planner with generated split trees --- docs/window-animations-plan.md | 5 + src/animation/multiphase.rs | 324 +++++++++++++++++++++++++++++++++ 2 files changed, 329 insertions(+) diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index 61c91b84..bc8bbeab 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -223,6 +223,8 @@ Current pure planner status: - Stack extraction/return patterns are covered in both horizontal and vertical orientations: peer/container space scales first, the extracted child moves only after space exists, and orthogonal growth happens in the final phase. +- Same-axis size redistribution is handled as a single scale phase when the + exact validator proves adjacent windows stay non-overlapping. - Every produced plan is checked analytically for overlap over the full duration of each phase before it is accepted. This solves the linear edge inequalities for each pair of moving rectangles instead of relying on sampled frames. @@ -237,6 +239,9 @@ Current pure planner status: It distinguishes request validation errors, missing patterns, shrink-bound rejections, invalid phase steps, and exact validation failures such as stale starts or phase overlap. +- Planner tests now include a deterministic split-tree generator. It builds + valid weighted tiling layouts, derives leaf hierarchy metadata, mutates them + through supported transitions, and runs the real planner plus exact validator. Tests: diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index 641e8826..f33cc834 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -219,6 +219,12 @@ pub fn plan_no_overlap_with_diagnostics( { return Ok(MultiphasePlan { phases: vec![] }); } + if let Some(failure) = target_shrink_bound_failure(request) { + return Err(MultiphasePlanDiagnostic { + forward: failure, + reverse: None, + }); + } let forward = match plan_forward(request) { Ok(plan) => return Ok(plan), Err(error) => error, @@ -283,8 +289,37 @@ fn validate_request(request: &MultiphaseRequest) -> Result<(), MultiphaseError> Ok(()) } +fn target_shrink_bound_failure(request: &MultiphaseRequest) -> Option { + let min_width = sane_min_size(request.bounds.width()); + let min_height = sane_min_size(request.bounds.height()); + for window in &request.windows { + if window.to.width() < min_width { + return Some(MultiphasePlanFailure::ShrinkBound { + axis: PhaseAxis::Horizontal, + available: window.to.width(), + required: min_width, + }); + } + if window.to.height() < min_height { + return Some(MultiphasePlanFailure::ShrinkBound { + axis: PhaseAxis::Vertical, + available: window.to.height(), + required: min_height, + }); + } + } + None +} + fn plan_forward(request: &MultiphaseRequest) -> Result { let mut rejection = None; + match plan_single_action_phase(request) { + Ok(plan) => return Ok(plan), + Err(MultiphasePlanFailure::NoPattern) => {} + Err(error) => { + rejection.get_or_insert(error); + } + } for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { match plan_axis_crossing_lanes(request, axis) { Ok(plan) => return Ok(plan), @@ -306,6 +341,48 @@ fn plan_forward(request: &MultiphaseRequest) -> Result Result { + let mut action = None; + let mut steps = vec![]; + for window in &request.windows { + if window.from == window.to { + continue; + } + let step = MultiphaseStep { + node_id: window.node_id, + from: window.from, + to: window.to, + }; + let Some(step_action) = classify_step(step) else { + return Err(MultiphasePlanFailure::NoPattern); + }; + if step_action.kind == PhaseKind::Scale { + let (available, required) = match step_action.axis { + PhaseAxis::Horizontal => (window.to.width(), sane_min_size(request.bounds.width())), + PhaseAxis::Vertical => (window.to.height(), sane_min_size(request.bounds.height())), + }; + if available < required { + return Err(MultiphasePlanFailure::ShrinkBound { + axis: step_action.axis, + available, + required, + }); + } + } + if action.is_some_and(|action| action != step_action) { + return Err(MultiphasePlanFailure::NoPattern); + } + action = Some(step_action); + steps.push(step); + } + let Some(action) = action else { + return Err(MultiphasePlanFailure::NoPattern); + }; + build_validated_plan(request, [(action.kind, action.axis, steps)]) +} + fn plan_axis_crossing_lanes( request: &MultiphaseRequest, axis: PhaseAxis, @@ -842,6 +919,154 @@ mod tests { MultiphaseWindow::new(id(raw), from, to) } + #[derive(Clone)] + enum TestTree { + Leaf(u32), + Split { + id: u32, + axis: PhaseAxis, + weights: Vec, + children: Vec, + }, + } + + struct TestLeaf { + node_id: NodeId, + rect: Rect, + hierarchy: MultiphaseHierarchyPosition, + } + + fn leaf(raw: u32) -> TestTree { + TestTree::Leaf(raw) + } + + fn split(id: u32, axis: PhaseAxis, weights: &[i32], children: Vec) -> TestTree { + TestTree::Split { + id, + axis, + weights: weights.to_vec(), + children, + } + } + + fn layout_tree(tree: &TestTree, bounds: Rect) -> Vec { + let mut leaves = vec![]; + layout_tree_inner(tree, bounds, None, 0, None, None, &mut leaves); + leaves.sort_by_key(|leaf| leaf.node_id.0); + leaves + } + + fn layout_tree_inner( + tree: &TestTree, + bounds: Rect, + parent: Option, + depth: u16, + sibling_index: Option, + split_axis: Option, + leaves: &mut Vec, + ) { + match tree { + TestTree::Leaf(raw) => leaves.push(TestLeaf { + node_id: id(*raw), + rect: bounds, + hierarchy: MultiphaseHierarchyPosition { + parent, + depth, + sibling_index, + split_axis, + ..Default::default() + }, + }), + TestTree::Split { + id: split_id, + axis, + weights, + children, + } => { + assert_eq!(weights.len(), children.len()); + let rects = split_rect_by_weights(bounds, *axis, weights); + for (idx, (child, rect)) in children.iter().zip(rects).enumerate() { + layout_tree_inner( + child, + rect, + Some(id(*split_id)), + depth.saturating_add(1), + Some(idx.min(u16::MAX as usize) as u16), + Some(*axis), + leaves, + ); + } + } + } + } + + fn split_rect_by_weights(bounds: Rect, axis: PhaseAxis, weights: &[i32]) -> Vec { + let total_weight: i32 = weights.iter().sum(); + assert!(total_weight > 0); + let total_size = match axis { + PhaseAxis::Horizontal => bounds.width(), + PhaseAxis::Vertical => bounds.height(), + }; + let mut pos = match axis { + PhaseAxis::Horizontal => bounds.x1(), + PhaseAxis::Vertical => bounds.y1(), + }; + let mut remaining_size = total_size; + let mut remaining_weight = total_weight; + let mut rects = vec![]; + for (idx, weight) in weights.iter().enumerate() { + let size = if idx + 1 == weights.len() { + remaining_size + } else { + total_size * *weight / total_weight + }; + let rect = match axis { + PhaseAxis::Horizontal => { + Rect::new_sized_saturating(pos, bounds.y1(), size, bounds.height()) + } + PhaseAxis::Vertical => { + Rect::new_sized_saturating(bounds.x1(), pos, bounds.width(), size) + } + }; + rects.push(rect); + pos += size; + remaining_size -= size; + remaining_weight -= *weight; + if remaining_weight == 0 { + assert_eq!(remaining_size, 0); + } + } + rects + } + + fn generated_request(old: &TestTree, new: &TestTree, bounds: Rect) -> MultiphaseRequest { + let old_leaves = layout_tree(old, bounds); + let new_leaves = layout_tree(new, bounds); + assert_eq!(old_leaves.len(), new_leaves.len()); + let mut windows = vec![]; + for old_leaf in &old_leaves { + let new_leaf = new_leaves + .iter() + .find(|leaf| leaf.node_id == old_leaf.node_id) + .unwrap(); + windows.push(MultiphaseWindow::with_hierarchy( + old_leaf.node_id, + old_leaf.rect, + new_leaf.rect, + MultiphaseWindowHierarchy::new(old_leaf.hierarchy, new_leaf.hierarchy), + )); + } + MultiphaseRequest { bounds, windows } + } + + fn assert_generated_case_plans(old: &TestTree, new: &TestTree, bounds: Rect) { + let req = generated_request(old, new, bounds); + assert!(!overlaps(req.windows.iter().map(|window| window.from))); + assert!(!overlaps(req.windows.iter().map(|window| window.to))); + let plan = plan_no_overlap(&req).unwrap(); + assert!(validate_plan_continuous(&req, &plan)); + } + fn request(windows: Vec) -> MultiphaseRequest { let bounds = windows .iter() @@ -964,6 +1189,105 @@ mod tests { assert!(validate_plan_continuous(&req, &plan)); } + #[test] + fn generated_sibling_swaps_plan_for_both_axes() { + let bounds = rect(0, 0, 240, 240); + for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { + let old = split(10, axis, &[1, 1], vec![leaf(1), leaf(2)]); + let new = split(10, axis, &[1, 1], vec![leaf(2), leaf(1)]); + assert_generated_case_plans(&old, &new, bounds); + } + } + + #[test] + fn generated_size_redistributions_plan_as_single_axis_scale() { + let horizontal_old = split( + 10, + PhaseAxis::Horizontal, + &[1, 1, 1], + vec![leaf(1), leaf(2), leaf(3)], + ); + let horizontal_new = split( + 10, + PhaseAxis::Horizontal, + &[1, 2, 1], + vec![leaf(1), leaf(2), leaf(3)], + ); + let horizontal_req = + generated_request(&horizontal_old, &horizontal_new, rect(0, 0, 400, 100)); + let horizontal_plan = plan_no_overlap(&horizontal_req).unwrap(); + assert_eq!( + actions(&horizontal_plan), + vec![PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal, + }] + ); + assert!(validate_plan_continuous(&horizontal_req, &horizontal_plan)); + + let vertical_old = split( + 10, + PhaseAxis::Vertical, + &[1, 1, 1], + vec![leaf(1), leaf(2), leaf(3)], + ); + let vertical_new = split( + 10, + PhaseAxis::Vertical, + &[1, 2, 1], + vec![leaf(1), leaf(2), leaf(3)], + ); + let vertical_req = generated_request(&vertical_old, &vertical_new, rect(0, 0, 100, 400)); + let vertical_plan = plan_no_overlap(&vertical_req).unwrap(); + assert_eq!( + actions(&vertical_plan), + vec![PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }] + ); + assert!(validate_plan_continuous(&vertical_req, &vertical_plan)); + } + + #[test] + fn generated_stack_extractions_plan_for_both_axes_and_directions() { + let horizontal_old = split( + 10, + PhaseAxis::Horizontal, + &[1, 1], + vec![ + leaf(1), + split(11, PhaseAxis::Vertical, &[1, 1], vec![leaf(2), leaf(3)]), + ], + ); + let horizontal_new = split( + 10, + PhaseAxis::Horizontal, + &[1, 2, 1], + vec![leaf(1), leaf(2), leaf(3)], + ); + assert_generated_case_plans(&horizontal_old, &horizontal_new, rect(0, 0, 400, 100)); + assert_generated_case_plans(&horizontal_new, &horizontal_old, rect(0, 0, 400, 100)); + + let vertical_old = split( + 20, + PhaseAxis::Vertical, + &[1, 1], + vec![ + leaf(1), + split(21, PhaseAxis::Horizontal, &[1, 1], vec![leaf(2), leaf(3)]), + ], + ); + let vertical_new = split( + 20, + PhaseAxis::Vertical, + &[1, 2, 1], + vec![leaf(1), leaf(2), leaf(3)], + ); + assert_generated_case_plans(&vertical_old, &vertical_new, rect(0, 0, 100, 400)); + assert_generated_case_plans(&vertical_new, &vertical_old, rect(0, 0, 100, 400)); + } + #[test] fn stack_extraction_creates_space_before_moving_child() { let req = request(vec![ From 0b6da9d8e07583e3b819f8d517f926c37163e0a2 Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 20:38:12 +1000 Subject: [PATCH 21/47] Order nested scale phases by hierarchy --- docs/window-animations-plan.md | 10 +- src/animation/multiphase.rs | 231 ++++++++++++++++++++++++++++++--- src/tree/toplevel.rs | 21 ++- 3 files changed, 237 insertions(+), 25 deletions(-) diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index bc8bbeab..a9dedab3 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -225,6 +225,9 @@ Current pure planner status: only after space exists, and orthogonal growth happens in the final phase. - Same-axis size redistribution is handled as a single scale phase when the exact validator proves adjacent windows stay non-overlapping. +- Nested size redistribution can use hierarchy metadata to decompose two-axis + resizing into parent-axis then child-axis scale phases, but only when the + source/target ancestor split depths give a deterministic order. - Every produced plan is checked analytically for overlap over the full duration of each phase before it is accepted. This solves the linear edge inequalities for each pair of moving rectangles instead of relying on sampled frames. @@ -232,9 +235,9 @@ Current pure planner status: groups can still use multiphase animation when another group falls back to linear motion. - Planner requests now carry per-window hierarchy metadata for source/target - parent, depth, sibling index, split axis, mono state, and transition kind. - The current planner records this information but does not yet use it to order - nested-container phases. + parent, depth, sibling index, split axis, nearest ancestor split depth per + axis, mono state, and transition kind. The current planner uses this for + parent-before-child scale ordering, but not yet for full nested move planning. - Multiphase planning has a diagnostic entry point used by live fallback logs. It distinguishes request validation errors, missing patterns, shrink-bound rejections, invalid phase steps, and exact validation failures such as stale @@ -247,6 +250,7 @@ Tests: - horizontal swaps shrink, move, then grow without overlap - extraction from a stack creates space before moving the extracted window +- nested size redistribution scales the parent axis before the child axis - nested containers do not produce simultaneous cross-axis motion - interruption restarts only affected phase groups - reversing direction produces equivalent motion in reverse diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index f33cc834..828bc966 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -85,6 +85,8 @@ pub struct MultiphaseHierarchyPosition { pub depth: u16, pub sibling_index: Option, pub split_axis: Option, + pub nearest_horizontal_split_depth: Option, + pub nearest_vertical_split_depth: Option, pub parent_is_mono: bool, pub mono_active: bool, } @@ -320,6 +322,13 @@ fn plan_forward(request: &MultiphaseRequest) -> Result return Ok(plan), + Err(MultiphasePlanFailure::NoPattern) => {} + Err(error) => { + rejection.get_or_insert(error); + } + } for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { match plan_axis_crossing_lanes(request, axis) { Ok(plan) => return Ok(plan), @@ -383,6 +392,96 @@ fn plan_single_action_phase( build_validated_plan(request, [(action.kind, action.axis, steps)]) } +fn plan_hierarchy_ordered_axis_scales( + request: &MultiphaseRequest, +) -> Result { + let mut changed_axes = vec![]; + for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { + if request + .windows + .iter() + .any(|window| interval_changed(window.from, window.to, axis)) + { + changed_axes.push(axis); + } + } + let [first_axis, second_axis] = changed_axes + .try_into() + .map_err(|_| MultiphasePlanFailure::NoPattern)?; + let axes = hierarchy_scale_axis_order(request, first_axis, second_axis) + .ok_or(MultiphasePlanFailure::NoPattern)?; + let mut current: Vec<_> = request + .windows + .iter() + .map(|window| (window.node_id, window.from)) + .collect(); + let mut phases = vec![]; + for axis in axes { + let mut steps = vec![]; + for window in &request.windows { + let (_, rect) = current + .iter_mut() + .find(|(node_id, _)| *node_id == window.node_id) + .unwrap(); + let next = with_main_interval( + *rect, + axis, + main_start(window.to, axis), + main_end(window.to, axis), + ); + if next == *rect { + continue; + } + if main_size(*rect, axis) == main_size(next, axis) { + return Err(MultiphasePlanFailure::NoPattern); + } + steps.push(MultiphaseStep { + node_id: window.node_id, + from: *rect, + to: next, + }); + *rect = next; + } + if steps.is_empty() { + return Err(MultiphasePlanFailure::NoPattern); + } + phases.push((PhaseKind::Scale, axis, steps)); + } + let [first, second] = phases + .try_into() + .map_err(|_| MultiphasePlanFailure::NoPattern)?; + build_validated_plan(request, [first, second]) +} + +fn hierarchy_scale_axis_order( + request: &MultiphaseRequest, + first_axis: PhaseAxis, + second_axis: PhaseAxis, +) -> Option<[PhaseAxis; 2]> { + let first_priority = hierarchy_axis_priority(request, first_axis)?; + let second_priority = hierarchy_axis_priority(request, second_axis)?; + match first_priority.cmp(&second_priority) { + std::cmp::Ordering::Less => Some([first_axis, second_axis]), + std::cmp::Ordering::Greater => Some([second_axis, first_axis]), + std::cmp::Ordering::Equal => None, + } +} + +fn hierarchy_axis_priority(request: &MultiphaseRequest, axis: PhaseAxis) -> Option { + request + .windows + .iter() + .filter(|window| interval_changed(window.from, window.to, axis)) + .flat_map(|window| { + [ + split_depth_for_axis(window.hierarchy.source, axis), + split_depth_for_axis(window.hierarchy.target, axis), + ] + }) + .flatten() + .min() +} + fn plan_axis_crossing_lanes( request: &MultiphaseRequest, axis: PhaseAxis, @@ -847,6 +946,17 @@ fn motion_bounds(window: MultiphaseWindow) -> Rect { window.from.union(window.to) } +fn interval_changed(from: Rect, to: Rect, axis: PhaseAxis) -> bool { + main_start(from, axis) != main_start(to, axis) || main_end(from, axis) != main_end(to, axis) +} + +fn split_depth_for_axis(position: MultiphaseHierarchyPosition, axis: PhaseAxis) -> Option { + match axis { + PhaseAxis::Horizontal => position.nearest_horizontal_split_depth, + PhaseAxis::Vertical => position.nearest_vertical_split_depth, + } +} + fn push_step(steps: &mut Vec, node_id: NodeId, from: Rect, to: Rect) { if from != to { steps.push(MultiphaseStep { node_id, from, to }); @@ -951,18 +1061,37 @@ mod tests { fn layout_tree(tree: &TestTree, bounds: Rect) -> Vec { let mut leaves = vec![]; - layout_tree_inner(tree, bounds, None, 0, None, None, &mut leaves); + layout_tree_inner( + tree, + bounds, + TestHierarchy { + parent: None, + depth: 0, + sibling_index: None, + split_axis: None, + nearest_horizontal_split_depth: None, + nearest_vertical_split_depth: None, + }, + &mut leaves, + ); leaves.sort_by_key(|leaf| leaf.node_id.0); leaves } + #[derive(Copy, Clone)] + struct TestHierarchy { + parent: Option, + depth: u16, + sibling_index: Option, + split_axis: Option, + nearest_horizontal_split_depth: Option, + nearest_vertical_split_depth: Option, + } + fn layout_tree_inner( tree: &TestTree, bounds: Rect, - parent: Option, - depth: u16, - sibling_index: Option, - split_axis: Option, + hierarchy: TestHierarchy, leaves: &mut Vec, ) { match tree { @@ -970,10 +1099,12 @@ mod tests { node_id: id(*raw), rect: bounds, hierarchy: MultiphaseHierarchyPosition { - parent, - depth, - sibling_index, - split_axis, + parent: hierarchy.parent, + depth: hierarchy.depth, + sibling_index: hierarchy.sibling_index, + split_axis: hierarchy.split_axis, + nearest_horizontal_split_depth: hierarchy.nearest_horizontal_split_depth, + nearest_vertical_split_depth: hierarchy.nearest_vertical_split_depth, ..Default::default() }, }), @@ -986,15 +1117,24 @@ mod tests { assert_eq!(weights.len(), children.len()); let rects = split_rect_by_weights(bounds, *axis, weights); for (idx, (child, rect)) in children.iter().zip(rects).enumerate() { - layout_tree_inner( - child, - rect, - Some(id(*split_id)), - depth.saturating_add(1), - Some(idx.min(u16::MAX as usize) as u16), - Some(*axis), - leaves, - ); + let depth = hierarchy.depth.saturating_add(1); + let mut child_hierarchy = TestHierarchy { + parent: Some(id(*split_id)), + depth, + sibling_index: Some(idx.min(u16::MAX as usize) as u16), + split_axis: Some(*axis), + nearest_horizontal_split_depth: hierarchy.nearest_horizontal_split_depth, + nearest_vertical_split_depth: hierarchy.nearest_vertical_split_depth, + }; + match axis { + PhaseAxis::Horizontal => { + child_hierarchy.nearest_horizontal_split_depth = Some(depth); + } + PhaseAxis::Vertical => { + child_hierarchy.nearest_vertical_split_depth = Some(depth); + } + } + layout_tree_inner(child, rect, child_hierarchy, leaves); } } } @@ -1249,6 +1389,57 @@ mod tests { assert!(validate_plan_continuous(&vertical_req, &vertical_plan)); } + #[test] + fn generated_nested_size_redistribution_scales_parent_axis_first() { + let old = split( + 10, + PhaseAxis::Horizontal, + &[1, 1], + vec![ + leaf(1), + split(11, PhaseAxis::Vertical, &[1, 1], vec![leaf(2), leaf(3)]), + ], + ); + let new = split( + 10, + PhaseAxis::Horizontal, + &[1, 3], + vec![ + leaf(1), + split(11, PhaseAxis::Vertical, &[3, 1], vec![leaf(2), leaf(3)]), + ], + ); + let req = generated_request(&old, &new, rect(0, 0, 400, 100)); + let plan = plan_no_overlap(&req).unwrap(); + assert_eq!( + actions(&plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + ] + ); + assert_eq!(step_to(&plan, 0, id(1)), rect(0, 0, 100, 100)); + assert_eq!(step_to(&plan, 0, id(2)), rect(100, 0, 400, 50)); + assert_eq!(step_to(&plan, 0, id(3)), rect(100, 50, 400, 100)); + assert!(validate_plan_continuous(&req, &plan)); + } + + #[test] + fn two_axis_redistribution_without_hierarchy_still_falls_back() { + let req = request(vec![ + window(1, rect(0, 0, 200, 100), rect(0, 0, 100, 100)), + window(2, rect(200, 0, 400, 50), rect(100, 0, 400, 75)), + window(3, rect(200, 50, 400, 100), rect(100, 75, 400, 100)), + ]); + assert_eq!(plan_no_overlap(&req), Err(MultiphaseError::NoPlan)); + } + #[test] fn generated_stack_extractions_plan_for_both_axes_and_directions() { let horizontal_old = split( @@ -1525,6 +1716,8 @@ mod tests { depth: 2, sibling_index: Some(0), split_axis: Some(PhaseAxis::Vertical), + nearest_horizontal_split_depth: Some(1), + nearest_vertical_split_depth: Some(2), ..Default::default() }; let target = MultiphaseHierarchyPosition { @@ -1532,12 +1725,14 @@ mod tests { depth: 1, sibling_index: Some(2), split_axis: Some(PhaseAxis::Horizontal), + nearest_horizontal_split_depth: Some(1), ..Default::default() }; assert_eq!( MultiphaseWindowHierarchy::new(source, target).transition, MultiphaseHierarchyTransition::Ascending ); + assert_eq!(source.nearest_vertical_split_depth, Some(2)); let entering_mono = MultiphaseWindowHierarchy::new( source, diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index d5ace6bb..551f48ec 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -330,9 +330,9 @@ pub trait ToplevelNodeBase: Node { }; let mut position = MultiphaseHierarchyPosition { parent: Some(parent.node_id()), - depth: multiphase_parent_depth(Some(parent.clone())), ..Default::default() }; + populate_multiphase_ancestor_splits(&mut position, Some(parent.clone())); if let Some(container) = parent.node_into_container() { position.split_axis = Some(match container.split.get() { ContainerSplit::Horizontal => PhaseAxis::Horizontal, @@ -421,16 +421,29 @@ pub trait ToplevelNodeBase: Node { } } -fn multiphase_parent_depth(mut parent: Option>) -> u16 { +fn populate_multiphase_ancestor_splits( + position: &mut MultiphaseHierarchyPosition, + mut parent: Option>, +) { let mut depth = 0u16; while let Some(node) = parent { - let Some(toplevel) = node.node_into_toplevel() else { + let Some(toplevel) = node.clone().node_into_toplevel() else { break; }; depth = depth.saturating_add(1); + if let Some(container) = node.node_into_container() { + match container.split.get() { + ContainerSplit::Horizontal => { + position.nearest_horizontal_split_depth.get_or_insert(depth); + } + ContainerSplit::Vertical => { + position.nearest_vertical_split_depth.get_or_insert(depth); + } + } + } parent = toplevel.tl_data().parent.get(); } - depth + position.depth = depth; } pub struct FullscreenedData { From 511e188d16f66ee30bbb400132bf6daf0e3837c9 Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 21:10:27 +1000 Subject: [PATCH 22/47] Explain multiphase planning decisions --- docs/window-animations-plan.md | 4 + src/animation/multiphase.rs | 456 ++++++++++++++++++++++++++++----- 2 files changed, 398 insertions(+), 62 deletions(-) diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index a9dedab3..ffda0711 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -242,6 +242,9 @@ Current pure planner status: It distinguishes request validation errors, missing patterns, shrink-bound rejections, invalid phase steps, and exact validation failures such as stale starts or phase overlap. +- Multiphase planning also has an explained-plan entry point. Accepted plans + report the deterministic strategy, phase reasons, participating nodes, and + validation result; rejected plans report every attempted strategy and failure. - Planner tests now include a deterministic split-tree generator. It builds valid weighted tiling layouts, derives leaf hierarchy metadata, mutates them through supported transitions, and runs the real planner plus exact validator. @@ -254,6 +257,7 @@ Tests: - nested containers do not produce simultaneous cross-axis motion - interruption restarts only affected phase groups - reversing direction produces equivalent motion in reverse +- accepted and rejected plans expose deterministic strategy explanations - child waits for parent/container-space phases when moving upward into a toplevel peer position - mono-mode tab switches do not animate, while entering/exiting mono can animate diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index 828bc966..2addc788 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -120,6 +120,75 @@ pub struct MultiphasePlan { pub phases: Vec, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MultiphasePlanned { + pub plan: MultiphasePlan, + pub explanation: MultiphasePlanExplanation, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MultiphasePlanExplanation { + pub strategy: PlanStrategy, + pub phases: Vec, + pub validation: ValidationExplanation, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PhaseExplanation { + pub action: PhaseAction, + pub reason: PhaseReason, + pub nodes: Vec, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct ValidationExplanation { + pub continuous_overlap_passed: bool, + pub final_rects_matched: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PlanStrategy { + NoOp, + SingleAction, + HierarchyOrderedScales, + SwapLanes { axis: PhaseAxis }, + SpaceThenOrthogonalGrowth { axis: PhaseAxis }, + ReversedForwardPlan { original: Box }, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum PlanDirection { + Forward, + Reverse, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RejectedStrategy { + pub direction: PlanDirection, + pub strategy: PlanStrategy, + pub reason: MultiphasePlanFailure, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum PhaseReason { + SingleAction, + SameAxisRedistribution, + ShrinkIntoLanes { + lane_axis: PhaseAxis, + }, + MoveThroughFreedSpace, + GrowOutOfLanes, + CreateSpaceForAscendingChild, + MoveAscendingChildAfterSpaceExists, + OrthogonalGrowthAfterMove, + ParentAxisBeforeChildAxis { + parent_axis: PhaseAxis, + parent_depth: u16, + child_axis: PhaseAxis, + child_depth: u16, + }, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct MultiphasePhase { pub action: PhaseAction, @@ -161,10 +230,11 @@ pub enum MultiphaseError { NoPlan, } -#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct MultiphasePlanDiagnostic { pub forward: MultiphasePlanFailure, pub reverse: Option, + pub attempted: Vec, } impl MultiphasePlanDiagnostic { @@ -176,6 +246,15 @@ impl MultiphasePlanDiagnostic { } } +impl ValidationExplanation { + fn passed() -> Self { + Self { + continuous_overlap_passed: true, + final_rects_matched: true, + } + } +} + #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum MultiphasePlanFailure { Request(MultiphaseError), @@ -201,6 +280,12 @@ pub enum MultiphaseValidationError { FinalMismatch { node_id: NodeId }, } +#[derive(Clone, Debug, Eq, PartialEq)] +struct PlanForwardFailure { + reason: MultiphasePlanFailure, + attempted: Vec, +} + pub fn plan_no_overlap(request: &MultiphaseRequest) -> Result { plan_no_overlap_with_diagnostics(request).map_err(|diagnostic| diagnostic.legacy_error()) } @@ -208,10 +293,17 @@ pub fn plan_no_overlap(request: &MultiphaseRequest) -> Result Result { + plan_no_overlap_explained(request).map(|planned| planned.plan) +} + +pub fn plan_no_overlap_explained( + request: &MultiphaseRequest, +) -> Result { if let Err(error) = validate_request(request) { return Err(MultiphasePlanDiagnostic { forward: MultiphasePlanFailure::Request(error), reverse: None, + attempted: vec![], }); } if request @@ -219,25 +311,38 @@ pub fn plan_no_overlap_with_diagnostics( .iter() .all(|window| window.from == window.to) { - return Ok(MultiphasePlan { phases: vec![] }); + return Ok(MultiphasePlanned { + plan: MultiphasePlan { phases: vec![] }, + explanation: MultiphasePlanExplanation { + strategy: PlanStrategy::NoOp, + phases: vec![], + validation: ValidationExplanation::passed(), + }, + }); } if let Some(failure) = target_shrink_bound_failure(request) { return Err(MultiphasePlanDiagnostic { forward: failure, reverse: None, + attempted: vec![], }); } - let forward = match plan_forward(request) { + let forward = match plan_forward(request, PlanDirection::Forward) { Ok(plan) => return Ok(plan), Err(error) => error, }; let reversed = reverse_request(request); - match plan_forward(&reversed) { - Ok(plan) => Ok(reverse_plan(plan)), - Err(reverse) => Err(MultiphasePlanDiagnostic { - forward, - reverse: Some(reverse), - }), + match plan_forward(&reversed, PlanDirection::Reverse) { + Ok(plan) => Ok(reverse_planned(plan)), + Err(reverse) => { + let mut attempted = forward.attempted; + attempted.extend(reverse.attempted); + Err(MultiphasePlanDiagnostic { + forward: forward.reason, + reverse: Some(reverse.reason), + attempted, + }) + } } } @@ -313,46 +418,89 @@ fn target_shrink_bound_failure(request: &MultiphaseRequest) -> Option Result { +fn plan_forward( + request: &MultiphaseRequest, + direction: PlanDirection, +) -> Result { let mut rejection = None; + let mut attempted = vec![]; match plan_single_action_phase(request) { Ok(plan) => return Ok(plan), - Err(MultiphasePlanFailure::NoPattern) => {} Err(error) => { - rejection.get_or_insert(error); + record_rejection(&mut attempted, direction, PlanStrategy::SingleAction, error); + if error != MultiphasePlanFailure::NoPattern { + rejection.get_or_insert(error); + } } } match plan_hierarchy_ordered_axis_scales(request) { Ok(plan) => return Ok(plan), - Err(MultiphasePlanFailure::NoPattern) => {} Err(error) => { - rejection.get_or_insert(error); + record_rejection( + &mut attempted, + direction, + PlanStrategy::HierarchyOrderedScales, + error, + ); + if error != MultiphasePlanFailure::NoPattern { + rejection.get_or_insert(error); + } } } for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { match plan_axis_crossing_lanes(request, axis) { Ok(plan) => return Ok(plan), - Err(MultiphasePlanFailure::NoPattern) => {} Err(error) => { - rejection.get_or_insert(error); + record_rejection( + &mut attempted, + direction, + PlanStrategy::SwapLanes { axis }, + error, + ); + if error != MultiphasePlanFailure::NoPattern { + rejection.get_or_insert(error); + } } } } for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { match plan_space_then_orthogonal_growth(request, axis) { Ok(plan) => return Ok(plan), - Err(MultiphasePlanFailure::NoPattern) => {} Err(error) => { - rejection.get_or_insert(error); + record_rejection( + &mut attempted, + direction, + PlanStrategy::SpaceThenOrthogonalGrowth { axis }, + error, + ); + if error != MultiphasePlanFailure::NoPattern { + rejection.get_or_insert(error); + } } } } - Err(rejection.unwrap_or(MultiphasePlanFailure::NoPattern)) + Err(PlanForwardFailure { + reason: rejection.unwrap_or(MultiphasePlanFailure::NoPattern), + attempted, + }) +} + +fn record_rejection( + attempted: &mut Vec, + direction: PlanDirection, + strategy: PlanStrategy, + reason: MultiphasePlanFailure, +) { + attempted.push(RejectedStrategy { + direction, + strategy, + reason, + }); } fn plan_single_action_phase( request: &MultiphaseRequest, -) -> Result { +) -> Result { let mut action = None; let mut steps = vec![]; for window in &request.windows { @@ -389,12 +537,21 @@ fn plan_single_action_phase( let Some(action) = action else { return Err(MultiphasePlanFailure::NoPattern); }; - build_validated_plan(request, [(action.kind, action.axis, steps)]) + build_validated_plan( + request, + PlanStrategy::SingleAction, + [phase_draft( + action.kind, + action.axis, + steps, + single_action_reason(action), + )], + ) } fn plan_hierarchy_ordered_axis_scales( request: &MultiphaseRequest, -) -> Result { +) -> Result { let mut changed_axes = vec![]; for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { if request @@ -408,7 +565,7 @@ fn plan_hierarchy_ordered_axis_scales( let [first_axis, second_axis] = changed_axes .try_into() .map_err(|_| MultiphasePlanFailure::NoPattern)?; - let axes = hierarchy_scale_axis_order(request, first_axis, second_axis) + let order = hierarchy_scale_axis_order(request, first_axis, second_axis) .ok_or(MultiphasePlanFailure::NoPattern)?; let mut current: Vec<_> = request .windows @@ -416,7 +573,13 @@ fn plan_hierarchy_ordered_axis_scales( .map(|window| (window.node_id, window.from)) .collect(); let mut phases = vec![]; - for axis in axes { + let reason = PhaseReason::ParentAxisBeforeChildAxis { + parent_axis: order.axes[0], + parent_depth: order.depths[0], + child_axis: order.axes[1], + child_depth: order.depths[1], + }; + for axis in order.axes { let mut steps = vec![]; for window in &request.windows { let (_, rect) = current @@ -445,28 +608,44 @@ fn plan_hierarchy_ordered_axis_scales( if steps.is_empty() { return Err(MultiphasePlanFailure::NoPattern); } - phases.push((PhaseKind::Scale, axis, steps)); + phases.push(phase_draft(PhaseKind::Scale, axis, steps, reason)); } let [first, second] = phases .try_into() .map_err(|_| MultiphasePlanFailure::NoPattern)?; - build_validated_plan(request, [first, second]) + build_validated_plan( + request, + PlanStrategy::HierarchyOrderedScales, + [first, second], + ) } fn hierarchy_scale_axis_order( request: &MultiphaseRequest, first_axis: PhaseAxis, second_axis: PhaseAxis, -) -> Option<[PhaseAxis; 2]> { +) -> Option { let first_priority = hierarchy_axis_priority(request, first_axis)?; let second_priority = hierarchy_axis_priority(request, second_axis)?; match first_priority.cmp(&second_priority) { - std::cmp::Ordering::Less => Some([first_axis, second_axis]), - std::cmp::Ordering::Greater => Some([second_axis, first_axis]), + std::cmp::Ordering::Less => Some(HierarchyScaleAxisOrder { + axes: [first_axis, second_axis], + depths: [first_priority, second_priority], + }), + std::cmp::Ordering::Greater => Some(HierarchyScaleAxisOrder { + axes: [second_axis, first_axis], + depths: [second_priority, first_priority], + }), std::cmp::Ordering::Equal => None, } } +#[derive(Copy, Clone)] +struct HierarchyScaleAxisOrder { + axes: [PhaseAxis; 2], + depths: [u16; 2], +} + fn hierarchy_axis_priority(request: &MultiphaseRequest, axis: PhaseAxis) -> Option { request .windows @@ -485,7 +664,7 @@ fn hierarchy_axis_priority(request: &MultiphaseRequest, axis: PhaseAxis) -> Opti fn plan_axis_crossing_lanes( request: &MultiphaseRequest, axis: PhaseAxis, -) -> Result { +) -> Result { if request.windows.len() != 2 { return Err(MultiphasePlanFailure::NoPattern); } @@ -551,10 +730,28 @@ fn plan_axis_crossing_lanes( } build_validated_plan( request, + PlanStrategy::SwapLanes { axis }, [ - (PhaseKind::Scale, axis.other(), phase1), - (PhaseKind::Move, axis, phase2), - (PhaseKind::Scale, axis.other(), phase3), + phase_draft( + PhaseKind::Scale, + axis.other(), + phase1, + PhaseReason::ShrinkIntoLanes { + lane_axis: axis.other(), + }, + ), + phase_draft( + PhaseKind::Move, + axis, + phase2, + PhaseReason::MoveThroughFreedSpace, + ), + phase_draft( + PhaseKind::Scale, + axis.other(), + phase3, + PhaseReason::GrowOutOfLanes, + ), ], ) } @@ -571,7 +768,7 @@ fn lane_index_for_direction(window: MultiphaseWindow, axis: PhaseAxis) -> Option fn plan_space_then_orthogonal_growth( request: &MultiphaseRequest, axis: PhaseAxis, -) -> Result { +) -> Result { if request.windows.len() < 2 { return Err(MultiphasePlanFailure::NoPattern); } @@ -631,24 +828,71 @@ fn plan_space_then_orthogonal_growth( } build_validated_plan( request, + PlanStrategy::SpaceThenOrthogonalGrowth { axis }, [ - (PhaseKind::Scale, axis, phase1), - (PhaseKind::Move, axis, phase2), - (PhaseKind::Scale, orth_axis, phase3), + phase_draft( + PhaseKind::Scale, + axis, + phase1, + PhaseReason::CreateSpaceForAscendingChild, + ), + phase_draft( + PhaseKind::Move, + axis, + phase2, + PhaseReason::MoveAscendingChildAfterSpaceExists, + ), + phase_draft( + PhaseKind::Scale, + orth_axis, + phase3, + PhaseReason::OrthogonalGrowthAfterMove, + ), ], ) } +struct MultiphasePhaseDraft { + action: PhaseAction, + steps: Vec, + reason: PhaseReason, +} + +fn phase_draft( + kind: PhaseKind, + axis: PhaseAxis, + steps: Vec, + reason: PhaseReason, +) -> MultiphasePhaseDraft { + MultiphasePhaseDraft { + action: PhaseAction { kind, axis }, + steps, + reason, + } +} + fn build_validated_plan( request: &MultiphaseRequest, - phases: [(PhaseKind, PhaseAxis, Vec); N], -) -> Result { + strategy: PlanStrategy, + phases: [MultiphasePhaseDraft; N], +) -> Result { + let mut explanations = vec![]; let phases: Vec<_> = phases .into_iter() - .filter_map(|(kind, axis, steps)| { - (!steps.is_empty()).then_some(MultiphasePhase { - action: PhaseAction { kind, axis }, - steps, + .filter_map(|draft| { + if draft.steps.is_empty() { + return None; + } + let mut nodes: Vec<_> = draft.steps.iter().map(|step| step.node_id).collect(); + nodes.sort_by_key(|node_id| node_id.0); + explanations.push(PhaseExplanation { + action: draft.action, + reason: draft.reason, + nodes, + }); + Some(MultiphasePhase { + action: draft.action, + steps: draft.steps, }) }) .collect(); @@ -664,7 +908,14 @@ fn build_validated_plan( } let plan = MultiphasePlan { phases }; validate_plan_continuous_diagnostic(request, &plan) - .map(|_| plan) + .map(|_| MultiphasePlanned { + plan, + explanation: MultiphasePlanExplanation { + strategy, + phases: explanations, + validation: ValidationExplanation::passed(), + }, + }) .map_err(MultiphasePlanFailure::Validation) } @@ -894,6 +1145,13 @@ fn classify_step(step: MultiphaseStep) -> Option { } } +fn single_action_reason(action: PhaseAction) -> PhaseReason { + match action.kind { + PhaseKind::Move => PhaseReason::SingleAction, + PhaseKind::Scale => PhaseReason::SameAxisRedistribution, + } +} + fn reverse_request(request: &MultiphaseRequest) -> MultiphaseRequest { MultiphaseRequest { bounds: request.bounds, @@ -932,6 +1190,21 @@ fn reverse_plan(plan: MultiphasePlan) -> MultiphasePlan { } } +fn reverse_planned(planned: MultiphasePlanned) -> MultiphasePlanned { + let mut phases = planned.explanation.phases; + phases.reverse(); + MultiphasePlanned { + plan: reverse_plan(planned.plan), + explanation: MultiphasePlanExplanation { + strategy: PlanStrategy::ReversedForwardPlan { + original: Box::new(planned.explanation.strategy), + }, + phases, + validation: planned.explanation.validation, + }, + } +} + fn overlaps(rects: impl IntoIterator) -> bool { let rects: Vec<_> = rects.into_iter().collect(); for (idx, rect) in rects.iter().enumerate() { @@ -1240,9 +1513,10 @@ mod tests { hierarchy: Default::default(), }, ]); - let plan = plan_no_overlap(&req).unwrap(); + let planned = plan_no_overlap_explained(&req).unwrap(); + let plan = &planned.plan; assert_eq!( - actions(&plan), + actions(plan), vec![ PhaseAction { kind: PhaseKind::Scale, @@ -1260,7 +1534,29 @@ mod tests { ); assert_eq!(plan.phases[0].steps[0].to, rect(0, 0, 100, 50)); assert_eq!(plan.phases[0].steps[1].to, rect(100, 50, 200, 100)); - assert!(validate_plan_continuous(&req, &plan)); + assert_eq!( + planned.explanation.strategy, + PlanStrategy::SwapLanes { + axis: PhaseAxis::Horizontal + } + ); + assert_eq!( + planned + .explanation + .phases + .iter() + .map(|phase| phase.reason) + .collect::>(), + vec![ + PhaseReason::ShrinkIntoLanes { + lane_axis: PhaseAxis::Vertical + }, + PhaseReason::MoveThroughFreedSpace, + PhaseReason::GrowOutOfLanes, + ] + ); + assert_eq!(planned.explanation.phases[0].nodes, vec![id(1), id(2)]); + assert!(validate_plan_continuous(&req, plan)); } #[test] @@ -1410,9 +1706,10 @@ mod tests { ], ); let req = generated_request(&old, &new, rect(0, 0, 400, 100)); - let plan = plan_no_overlap(&req).unwrap(); + let planned = plan_no_overlap_explained(&req).unwrap(); + let plan = &planned.plan; assert_eq!( - actions(&plan), + actions(plan), vec![ PhaseAction { kind: PhaseKind::Scale, @@ -1424,10 +1721,32 @@ mod tests { }, ] ); - assert_eq!(step_to(&plan, 0, id(1)), rect(0, 0, 100, 100)); - assert_eq!(step_to(&plan, 0, id(2)), rect(100, 0, 400, 50)); - assert_eq!(step_to(&plan, 0, id(3)), rect(100, 50, 400, 100)); - assert!(validate_plan_continuous(&req, &plan)); + assert_eq!(step_to(plan, 0, id(1)), rect(0, 0, 100, 100)); + assert_eq!(step_to(plan, 0, id(2)), rect(100, 0, 400, 50)); + assert_eq!(step_to(plan, 0, id(3)), rect(100, 50, 400, 100)); + assert_eq!( + planned.explanation.strategy, + PlanStrategy::HierarchyOrderedScales + ); + assert_eq!( + planned.explanation.phases[0].reason, + PhaseReason::ParentAxisBeforeChildAxis { + parent_axis: PhaseAxis::Horizontal, + parent_depth: 1, + child_axis: PhaseAxis::Vertical, + child_depth: 2, + } + ); + assert_eq!( + planned.explanation.phases[0].nodes, + vec![id(1), id(2), id(3)] + ); + assert_eq!(planned.explanation.phases[1].nodes, vec![id(2), id(3)]); + assert_eq!( + planned.explanation.validation, + ValidationExplanation::passed() + ); + assert!(validate_plan_continuous(&req, plan)); } #[test] @@ -1549,9 +1868,10 @@ mod tests { hierarchy: Default::default(), }, ]); - let plan = plan_no_overlap(&req).unwrap(); + let planned = plan_no_overlap_explained(&req).unwrap(); + let plan = &planned.plan; assert_eq!( - actions(&plan), + actions(plan), vec![ PhaseAction { kind: PhaseKind::Scale, @@ -1567,7 +1887,15 @@ mod tests { }, ] ); - assert!(validate_plan_continuous(&req, &plan)); + assert_eq!( + planned.explanation.strategy, + PlanStrategy::ReversedForwardPlan { + original: Box::new(PlanStrategy::SpaceThenOrthogonalGrowth { + axis: PhaseAxis::Horizontal + }) + } + ); + assert!(validate_plan_continuous(&req, plan)); } #[test] @@ -1670,12 +1998,16 @@ mod tests { hierarchy: Default::default(), }]); assert_eq!(plan_no_overlap(&req), Err(MultiphaseError::NoPlan)); - assert_eq!( - plan_no_overlap_with_diagnostics(&req).unwrap_err(), - MultiphasePlanDiagnostic { - forward: MultiphasePlanFailure::NoPattern, - reverse: Some(MultiphasePlanFailure::NoPattern), - } + let diagnostic = plan_no_overlap_with_diagnostics(&req).unwrap_err(); + assert_eq!(diagnostic.forward, MultiphasePlanFailure::NoPattern); + assert_eq!(diagnostic.reverse, Some(MultiphasePlanFailure::NoPattern)); + assert!( + diagnostic + .attempted + .iter() + .any(|attempt| attempt.direction == PlanDirection::Forward + && attempt.strategy == PlanStrategy::SingleAction + && attempt.reason == MultiphasePlanFailure::NoPattern) ); } From 332a7468f608be9a3e17ea20ca60c5f76a36683d Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 21:13:48 +1000 Subject: [PATCH 23/47] Enumerate deterministic split-tree plans --- docs/window-animations-plan.md | 4 + src/animation/multiphase.rs | 139 ++++++++++++++++++++++++++++++++- 2 files changed, 141 insertions(+), 2 deletions(-) diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index ffda0711..890bd55b 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -248,6 +248,9 @@ Current pure planner status: - Planner tests now include a deterministic split-tree generator. It builds valid weighted tiling layouts, derives leaf hierarchy metadata, mutates them through supported transitions, and runs the real planner plus exact validator. +- The generated tests also include a bounded corpus of supported split-tree + transitions across both axes and directions. Each case is planned twice and + compared exactly to catch nondeterministic planner output. Tests: @@ -258,6 +261,7 @@ Tests: - interruption restarts only affected phase groups - reversing direction produces equivalent motion in reverse - accepted and rejected plans expose deterministic strategy explanations +- bounded generated split-tree corpus produces identical plans on repeated runs - child waits for parent/container-space phases when moving upward into a toplevel peer position - mono-mode tab switches do not animate, while entering/exiting mono can animate diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index 2addc788..4c37e676 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -1473,11 +1473,47 @@ mod tests { } fn assert_generated_case_plans(old: &TestTree, new: &TestTree, bounds: Rect) { + assert_generated_case_plans_deterministically(old, new, bounds); + } + + fn assert_generated_case_plans_deterministically( + old: &TestTree, + new: &TestTree, + bounds: Rect, + ) -> MultiphasePlanned { let req = generated_request(old, new, bounds); assert!(!overlaps(req.windows.iter().map(|window| window.from))); assert!(!overlaps(req.windows.iter().map(|window| window.to))); - let plan = plan_no_overlap(&req).unwrap(); - assert!(validate_plan_continuous(&req, &plan)); + let first = plan_no_overlap_explained(&req).unwrap(); + let second = plan_no_overlap_explained(&req).unwrap(); + assert_eq!(first, second); + assert_eq!(first.plan.phases.len(), first.explanation.phases.len()); + assert_eq!( + first.explanation.validation, + ValidationExplanation::passed() + ); + for phase in &first.explanation.phases { + assert!(phase.nodes.windows(2).all(|pair| pair[0].0 < pair[1].0)); + } + assert!(validate_plan_continuous(&req, &first.plan)); + first + } + + fn bounds_for_axis(axis: PhaseAxis) -> Rect { + match axis { + PhaseAxis::Horizontal => rect(0, 0, 400, 100), + PhaseAxis::Vertical => rect(0, 0, 100, 400), + } + } + + fn push_generated_case_bidirectional( + cases: &mut Vec<(TestTree, TestTree, Rect)>, + old: TestTree, + new: TestTree, + bounds: Rect, + ) { + cases.push((old.clone(), new.clone(), bounds)); + cases.push((new, old, bounds)); } fn request(windows: Vec) -> MultiphaseRequest { @@ -1798,6 +1834,105 @@ mod tests { assert_generated_case_plans(&vertical_new, &vertical_old, rect(0, 0, 100, 400)); } + #[test] + fn bounded_generated_supported_split_tree_corpus_is_deterministic() { + let mut cases = vec![]; + for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { + let child_axis = axis.other(); + let bounds = bounds_for_axis(axis); + + push_generated_case_bidirectional( + &mut cases, + split(10, axis, &[1, 1], vec![leaf(1), leaf(2)]), + split(10, axis, &[1, 1], vec![leaf(2), leaf(1)]), + bounds, + ); + push_generated_case_bidirectional( + &mut cases, + split(10, axis, &[1, 1, 1], vec![leaf(1), leaf(2), leaf(3)]), + split(10, axis, &[1, 2, 1], vec![leaf(1), leaf(2), leaf(3)]), + bounds, + ); + push_generated_case_bidirectional( + &mut cases, + split( + 10, + axis, + &[1, 1], + vec![ + leaf(1), + split(11, child_axis, &[1, 1], vec![leaf(2), leaf(3)]), + ], + ), + split(10, axis, &[1, 2, 1], vec![leaf(1), leaf(2), leaf(3)]), + bounds, + ); + push_generated_case_bidirectional( + &mut cases, + split( + 10, + axis, + &[1, 1], + vec![ + split(11, child_axis, &[1, 1], vec![leaf(1), leaf(2)]), + leaf(3), + ], + ), + split(10, axis, &[1, 2, 1], vec![leaf(1), leaf(2), leaf(3)]), + bounds, + ); + push_generated_case_bidirectional( + &mut cases, + split( + 10, + axis, + &[1, 1], + vec![ + leaf(1), + split(11, child_axis, &[1, 1], vec![leaf(2), leaf(3)]), + ], + ), + split( + 10, + axis, + &[1, 3], + vec![ + leaf(1), + split(11, child_axis, &[3, 1], vec![leaf(2), leaf(3)]), + ], + ), + bounds, + ); + push_generated_case_bidirectional( + &mut cases, + split( + 10, + axis, + &[1, 1], + vec![ + split(11, child_axis, &[1, 1], vec![leaf(1), leaf(2)]), + leaf(3), + ], + ), + split( + 10, + axis, + &[3, 1], + vec![ + split(11, child_axis, &[1, 3], vec![leaf(1), leaf(2)]), + leaf(3), + ], + ), + bounds, + ); + } + + assert_eq!(cases.len(), 24); + for (old, new, bounds) in cases { + assert_generated_case_plans_deterministically(&old, &new, bounds); + } + } + #[test] fn stack_extraction_creates_space_before_moving_child() { let req = request(vec![ From 01d1545c404b05ef5ae524a5a7036dff7e4581f1 Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 21:16:54 +1000 Subject: [PATCH 24/47] Assert multiphase rejection diagnostics --- docs/window-animations-plan.md | 4 ++ src/animation/multiphase.rs | 96 +++++++++++++++++++++++++++++++--- 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index 890bd55b..f8912ac0 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -245,6 +245,9 @@ Current pure planner status: - Multiphase planning also has an explained-plan entry point. Accepted plans report the deterministic strategy, phase reasons, participating nodes, and validation result; rejected plans report every attempted strategy and failure. +- Rejection diagnostics are treated as contractual test output for unsupported + patterns and analytically invalid candidate plans, including attempted strategy + order and exact validation failure. - Planner tests now include a deterministic split-tree generator. It builds valid weighted tiling layouts, derives leaf hierarchy metadata, mutates them through supported transitions, and runs the real planner plus exact validator. @@ -262,6 +265,7 @@ Tests: - reversing direction produces equivalent motion in reverse - accepted and rejected plans expose deterministic strategy explanations - bounded generated split-tree corpus produces identical plans on repeated runs +- unsupported and invalid candidate plans produce exact expected diagnostics - child waits for parent/container-space phases when moving upward into a toplevel peer position - mono-mode tab switches do not animate, while entering/exiting mono can animate diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index 4c37e676..d0ab4b70 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -1538,6 +1538,49 @@ mod tests { .to } + fn no_pattern_attempts(direction: PlanDirection) -> Vec { + vec![ + RejectedStrategy { + direction, + strategy: PlanStrategy::SingleAction, + reason: MultiphasePlanFailure::NoPattern, + }, + RejectedStrategy { + direction, + strategy: PlanStrategy::HierarchyOrderedScales, + reason: MultiphasePlanFailure::NoPattern, + }, + RejectedStrategy { + direction, + strategy: PlanStrategy::SwapLanes { + axis: PhaseAxis::Horizontal, + }, + reason: MultiphasePlanFailure::NoPattern, + }, + RejectedStrategy { + direction, + strategy: PlanStrategy::SwapLanes { + axis: PhaseAxis::Vertical, + }, + reason: MultiphasePlanFailure::NoPattern, + }, + RejectedStrategy { + direction, + strategy: PlanStrategy::SpaceThenOrthogonalGrowth { + axis: PhaseAxis::Horizontal, + }, + reason: MultiphasePlanFailure::NoPattern, + }, + RejectedStrategy { + direction, + strategy: PlanStrategy::SpaceThenOrthogonalGrowth { + axis: PhaseAxis::Vertical, + }, + reason: MultiphasePlanFailure::NoPattern, + }, + ] + } + #[test] fn horizontal_swap_shrinks_moves_then_grows_without_overlap() { let req = request(vec![ @@ -2136,14 +2179,9 @@ mod tests { let diagnostic = plan_no_overlap_with_diagnostics(&req).unwrap_err(); assert_eq!(diagnostic.forward, MultiphasePlanFailure::NoPattern); assert_eq!(diagnostic.reverse, Some(MultiphasePlanFailure::NoPattern)); - assert!( - diagnostic - .attempted - .iter() - .any(|attempt| attempt.direction == PlanDirection::Forward - && attempt.strategy == PlanStrategy::SingleAction - && attempt.reason == MultiphasePlanFailure::NoPattern) - ); + let mut expected = no_pattern_attempts(PlanDirection::Forward); + expected.extend(no_pattern_attempts(PlanDirection::Reverse)); + assert_eq!(diagnostic.attempted, expected); } #[test] @@ -2176,6 +2214,48 @@ mod tests { )); } + #[test] + fn diagnostics_report_candidate_validation_rejections() { + let req = request(vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 60, 60), + to: rect(180, 0, 240, 60), + hierarchy: Default::default(), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(90, 0, 150, 60), + to: rect(90, 0, 150, 60), + hierarchy: Default::default(), + }, + ]); + let rejection = + MultiphasePlanFailure::Validation(MultiphaseValidationError::PhaseOverlap { + phase: 0, + a: id(1), + b: id(2), + }); + let diagnostic = plan_no_overlap_with_diagnostics(&req).unwrap_err(); + + assert_eq!(diagnostic.forward, rejection); + assert_eq!(diagnostic.reverse, Some(rejection)); + assert_eq!( + diagnostic.attempted[0], + RejectedStrategy { + direction: PlanDirection::Forward, + strategy: PlanStrategy::SingleAction, + reason: rejection, + } + ); + assert!(diagnostic.attempted.iter().any(|attempt| *attempt + == RejectedStrategy { + direction: PlanDirection::Reverse, + strategy: PlanStrategy::SingleAction, + reason: rejection, + })); + } + #[test] fn hierarchy_metadata_classifies_depth_and_mono_transitions() { let source = MultiphaseHierarchyPosition { From 632873ec5aef63ad7ca02f5b9f18bacdd0092bab Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 21:23:56 +1000 Subject: [PATCH 25/47] Allow proven mixed multiphase actions --- docs/window-animations-plan.md | 4 + src/animation/multiphase.rs | 204 ++++++++++++++++++++++++++++----- 2 files changed, 179 insertions(+), 29 deletions(-) diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index f8912ac0..98d4df98 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -228,6 +228,9 @@ Current pure planner status: - Nested size redistribution can use hierarchy metadata to decompose two-axis resizing into parent-axis then child-axis scale phases, but only when the source/target ancestor split depths give a deterministic order. +- A phase can carry mixed per-window actions when each window still performs one + classified move/scale on one axis and the exact validator proves the combined + phase is non-overlapping. - Every produced plan is checked analytically for overlap over the full duration of each phase before it is accepted. This solves the linear edge inequalities for each pair of moving rectangles instead of relying on sampled frames. @@ -266,6 +269,7 @@ Tests: - accepted and rejected plans expose deterministic strategy explanations - bounded generated split-tree corpus produces identical plans on repeated runs - unsupported and invalid candidate plans produce exact expected diagnostics +- mixed-action phases are accepted only under exact continuous validation - child waits for parent/container-space phases when moving upward into a toplevel peer position - mono-mode tab switches do not animate, while entering/exiting mono can animate diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index d0ab4b70..4db0bed1 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -135,7 +135,7 @@ pub struct MultiphasePlanExplanation { #[derive(Clone, Debug, Eq, PartialEq)] pub struct PhaseExplanation { - pub action: PhaseAction, + pub action: MultiphasePhaseAction, pub reason: PhaseReason, pub nodes: Vec, } @@ -150,6 +150,7 @@ pub struct ValidationExplanation { pub enum PlanStrategy { NoOp, SingleAction, + MixedSinglePhase, HierarchyOrderedScales, SwapLanes { axis: PhaseAxis }, SpaceThenOrthogonalGrowth { axis: PhaseAxis }, @@ -173,6 +174,7 @@ pub struct RejectedStrategy { pub enum PhaseReason { SingleAction, SameAxisRedistribution, + MixedAxisActions, ShrinkIntoLanes { lane_axis: PhaseAxis, }, @@ -191,10 +193,16 @@ pub enum PhaseReason { #[derive(Clone, Debug, Eq, PartialEq)] pub struct MultiphasePhase { - pub action: PhaseAction, + pub action: MultiphasePhaseAction, pub steps: Vec, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum MultiphasePhaseAction { + Uniform(PhaseAction), + Mixed(Vec), +} + #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub struct MultiphaseStep { pub node_id: NodeId, @@ -220,6 +228,32 @@ pub enum PhaseAxis { Vertical, } +impl MultiphasePhaseAction { + fn from_step_actions(actions: Vec) -> Self { + debug_assert!(!actions.is_empty()); + let first = actions[0]; + if actions.iter().all(|action| *action == first) { + Self::Uniform(first) + } else { + Self::Mixed(actions) + } + } + + fn action_for_step(&self, idx: usize) -> Option { + match self { + Self::Uniform(action) => Some(*action), + Self::Mixed(actions) => actions.get(idx).copied(), + } + } + + fn as_uniform(&self) -> Option { + match self { + Self::Uniform(action) => Some(*action), + Self::Mixed(_) => None, + } + } +} + #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum MultiphaseError { EmptyBounds, @@ -273,11 +307,31 @@ pub enum MultiphasePlanFailure { #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum MultiphaseValidationError { - DuplicatePhaseStep { phase: usize, node_id: NodeId }, - UnknownPhaseStep { phase: usize, node_id: NodeId }, - StaleStepStart { phase: usize, node_id: NodeId }, - PhaseOverlap { phase: usize, a: NodeId, b: NodeId }, - FinalMismatch { node_id: NodeId }, + DuplicatePhaseStep { + phase: usize, + node_id: NodeId, + }, + PhaseActionCount { + phase: usize, + actions: usize, + steps: usize, + }, + UnknownPhaseStep { + phase: usize, + node_id: NodeId, + }, + StaleStepStart { + phase: usize, + node_id: NodeId, + }, + PhaseOverlap { + phase: usize, + a: NodeId, + b: NodeId, + }, + FinalMismatch { + node_id: NodeId, + }, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -501,8 +555,10 @@ fn record_rejection( fn plan_single_action_phase( request: &MultiphaseRequest, ) -> Result { - let mut action = None; + let mut uniform_action = None; + let mut is_uniform = true; let mut steps = vec![]; + let mut step_actions = vec![]; for window in &request.windows { if window.from == window.to { continue; @@ -528,21 +584,33 @@ fn plan_single_action_phase( }); } } - if action.is_some_and(|action| action != step_action) { - return Err(MultiphasePlanFailure::NoPattern); + if uniform_action.is_some_and(|action| action != step_action) { + is_uniform = false; } - action = Some(step_action); + uniform_action.get_or_insert(step_action); steps.push(step); + step_actions.push(step_action); } - let Some(action) = action else { + if steps.is_empty() { return Err(MultiphasePlanFailure::NoPattern); - }; + } + if !is_uniform { + return build_validated_plan( + request, + PlanStrategy::MixedSinglePhase, + [phase_draft_mixed( + steps, + step_actions, + PhaseReason::MixedAxisActions, + )], + ); + } + let action = uniform_action.unwrap(); build_validated_plan( request, PlanStrategy::SingleAction, - [phase_draft( - action.kind, - action.axis, + [phase_draft_uniform( + action, steps, single_action_reason(action), )], @@ -853,9 +921,26 @@ fn plan_space_then_orthogonal_growth( } struct MultiphasePhaseDraft { + action: MultiphasePhaseActionDraft, + steps: Vec, + reason: PhaseReason, +} + +enum MultiphasePhaseActionDraft { + Uniform(PhaseAction), + Mixed(Vec), +} + +fn phase_draft_uniform( action: PhaseAction, steps: Vec, reason: PhaseReason, +) -> MultiphasePhaseDraft { + MultiphasePhaseDraft { + action: MultiphasePhaseActionDraft::Uniform(action), + steps, + reason, + } } fn phase_draft( @@ -863,9 +948,17 @@ fn phase_draft( axis: PhaseAxis, steps: Vec, reason: PhaseReason, +) -> MultiphasePhaseDraft { + phase_draft_uniform(PhaseAction { kind, axis }, steps, reason) +} + +fn phase_draft_mixed( + steps: Vec, + actions: Vec, + reason: PhaseReason, ) -> MultiphasePhaseDraft { MultiphasePhaseDraft { - action: PhaseAction { kind, axis }, + action: MultiphasePhaseActionDraft::Mixed(actions), steps, reason, } @@ -885,22 +978,32 @@ fn build_validated_plan( } let mut nodes: Vec<_> = draft.steps.iter().map(|step| step.node_id).collect(); nodes.sort_by_key(|node_id| node_id.0); + let action = match draft.action { + MultiphasePhaseActionDraft::Uniform(action) => { + MultiphasePhaseAction::Uniform(action) + } + MultiphasePhaseActionDraft::Mixed(actions) => { + debug_assert_eq!(actions.len(), draft.steps.len()); + MultiphasePhaseAction::from_step_actions(actions) + } + }; explanations.push(PhaseExplanation { - action: draft.action, + action: action.clone(), reason: draft.reason, nodes, }); Some(MultiphasePhase { - action: draft.action, + action, steps: draft.steps, }) }) .collect(); for phase in &phases { - for step in &phase.steps { - if classify_step(*step) != Some(phase.action) { + for (idx, step) in phase.steps.iter().enumerate() { + let action = phase.action.action_for_step(idx).unwrap(); + if classify_step(*step) != Some(action) { return Err(MultiphasePlanFailure::InvalidPhaseStep { - action: phase.action, + action, node_id: step.node_id, }); } @@ -933,6 +1036,15 @@ fn validate_plan_continuous_diagnostic( .map(|window| (window.node_id, window.from)) .collect(); for (phase_idx, phase) in plan.phases.iter().enumerate() { + if let MultiphasePhaseAction::Mixed(actions) = &phase.action + && actions.len() != phase.steps.len() + { + return Err(MultiphaseValidationError::PhaseActionCount { + phase: phase_idx, + actions: actions.len(), + steps: phase.steps.len(), + }); + } for (idx, step) in phase.steps.iter().enumerate() { if phase.steps[..idx] .iter() @@ -1526,7 +1638,10 @@ mod tests { } fn actions(plan: &MultiphasePlan) -> Vec { - plan.phases.iter().map(|phase| phase.action).collect() + plan.phases + .iter() + .map(|phase| phase.action.as_uniform().unwrap()) + .collect() } fn step_to(plan: &MultiphasePlan, phase: usize, node_id: NodeId) -> Rect { @@ -1764,6 +1879,37 @@ mod tests { assert!(validate_plan_continuous(&vertical_req, &vertical_plan)); } + #[test] + fn mixed_single_phase_accepts_distinct_axis_actions_when_proven() { + let req = request(vec![ + window(1, rect(0, 0, 100, 100), rect(0, 0, 150, 100)), + window(2, rect(200, 0, 300, 100), rect(200, 0, 300, 150)), + ]); + let planned = plan_no_overlap_explained(&req).unwrap(); + + assert_eq!(planned.explanation.strategy, PlanStrategy::MixedSinglePhase); + assert_eq!(planned.plan.phases.len(), 1); + assert_eq!( + planned.plan.phases[0].action, + MultiphasePhaseAction::Mixed(vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + ]) + ); + assert_eq!( + planned.explanation.phases[0].reason, + PhaseReason::MixedAxisActions + ); + assert_eq!(planned.explanation.phases[0].nodes, vec![id(1), id(2)]); + assert!(validate_plan_continuous(&req, &planned.plan)); + } + #[test] fn generated_nested_size_redistribution_scales_parent_axis_first() { let old = split( @@ -2317,10 +2463,10 @@ mod tests { ]); let plan = MultiphasePlan { phases: vec![MultiphasePhase { - action: PhaseAction { + action: MultiphasePhaseAction::Uniform(PhaseAction { kind: PhaseKind::Move, axis: PhaseAxis::Horizontal, - }, + }), steps: vec![MultiphaseStep { node_id: id(1), from: rect(0, 0, 10, 10), @@ -2357,10 +2503,10 @@ mod tests { ]); let plan = MultiphasePlan { phases: vec![MultiphasePhase { - action: PhaseAction { + action: MultiphasePhaseAction::Uniform(PhaseAction { kind: PhaseKind::Move, axis: PhaseAxis::Horizontal, - }, + }), steps: vec![MultiphaseStep { node_id: id(1), from: rect(0, 0, 10, 10), @@ -2382,10 +2528,10 @@ mod tests { }]); let plan = MultiphasePlan { phases: vec![MultiphasePhase { - action: PhaseAction { + action: MultiphasePhaseAction::Uniform(PhaseAction { kind: PhaseKind::Move, axis: PhaseAxis::Horizontal, - }, + }), steps: vec![MultiphaseStep { node_id: id(1), from: rect(5, 0, 15, 10), From a5845fb293c09eac036d42e49c6dacddc1329c0b Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 21:34:53 +1000 Subject: [PATCH 26/47] Assert mixed multiphase action boundaries --- docs/window-animations-plan.md | 5 +- src/animation/multiphase.rs | 87 ++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index 98d4df98..16c5a7b6 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -170,7 +170,9 @@ Core rules: - Each phase is a discrete animation using the full curve. - A phase performs only one action kind per window: move or scale. - Movement and scaling are split by axis. -- No diagonal motion. +- No diagonal motion. Mixed-action phases may combine different per-window + actions, but no single window may move or resize on more than one axis in one + step. - A window or synchronized group owns its own timeline. - New layout changes interrupt only windows/groups with changed destinations. - Current hierarchy and target hierarchy both matter. The planner must know @@ -270,6 +272,7 @@ Tests: - bounded generated split-tree corpus produces identical plans on repeated runs - unsupported and invalid candidate plans produce exact expected diagnostics - mixed-action phases are accepted only under exact continuous validation +- diagonal per-window motion remains a hard rejection even inside mixed phases - child waits for parent/container-space phases when moving upward into a toplevel peer position - mono-mode tab switches do not animate, while entering/exiting mono can animate diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index 4db0bed1..8fa58e81 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -1910,6 +1910,56 @@ mod tests { assert!(validate_plan_continuous(&req, &planned.plan)); } + #[test] + fn mixed_single_phase_accepts_move_and_scale_when_proven() { + let req = request(vec![ + window(1, rect(0, 0, 80, 80), rect(40, 0, 120, 80)), + window(2, rect(200, 0, 280, 80), rect(200, 0, 280, 120)), + ]); + let planned = plan_no_overlap_explained(&req).unwrap(); + + assert_eq!(planned.explanation.strategy, PlanStrategy::MixedSinglePhase); + assert_eq!( + planned.plan.phases[0].action, + MultiphasePhaseAction::Mixed(vec![ + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + ]) + ); + assert_eq!( + planned.explanation.phases[0].reason, + PhaseReason::MixedAxisActions + ); + assert_eq!(planned.explanation.phases[0].nodes, vec![id(1), id(2)]); + assert!(validate_plan_continuous(&req, &planned.plan)); + } + + #[test] + fn mixed_single_phase_still_rejects_diagonal_per_window_motion() { + let req = request(vec![ + window(1, rect(0, 0, 80, 80), rect(40, 40, 120, 120)), + window(2, rect(200, 0, 280, 80), rect(200, 0, 280, 120)), + ]); + + assert_eq!(plan_no_overlap(&req), Err(MultiphaseError::NoPlan)); + let diagnostic = plan_no_overlap_with_diagnostics(&req).unwrap_err(); + let rejection = MultiphasePlanFailure::InvalidPhaseStep { + action: PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal, + }, + node_id: id(1), + }; + assert_eq!(diagnostic.forward, rejection); + assert_eq!(diagnostic.reverse, Some(rejection)); + } + #[test] fn generated_nested_size_redistribution_scales_parent_axis_first() { let old = split( @@ -2518,6 +2568,43 @@ mod tests { assert!(validate_plan_continuous(&req, &plan)); } + #[test] + fn continuous_validation_rejects_mixed_phase_action_count_mismatch() { + let req = request(vec![ + window(1, rect(0, 0, 40, 40), rect(40, 0, 80, 40)), + window(2, rect(100, 0, 140, 40), rect(100, 0, 140, 80)), + ]); + let plan = MultiphasePlan { + phases: vec![MultiphasePhase { + action: MultiphasePhaseAction::Mixed(vec![PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }]), + steps: vec![ + MultiphaseStep { + node_id: id(1), + from: rect(0, 0, 40, 40), + to: rect(40, 0, 80, 40), + }, + MultiphaseStep { + node_id: id(2), + from: rect(100, 0, 140, 40), + to: rect(100, 0, 140, 80), + }, + ], + }], + }; + + assert_eq!( + validate_plan_continuous_diagnostic(&req, &plan), + Err(MultiphaseValidationError::PhaseActionCount { + phase: 0, + actions: 1, + steps: 2, + }) + ); + } + #[test] fn continuous_validation_rejects_stale_step_start_rect() { let req = request(vec![MultiphaseWindow { From b1717e2dd822fc7723d4862bd6b0a61255fd91b0 Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 21:45:07 +1000 Subject: [PATCH 27/47] Add animation manual test plan --- docs/window-animations-plan.md | 2 + docs/window-animations-testing.md | 404 ++++++++++++++++++++++++++++++ 2 files changed, 406 insertions(+) create mode 100644 docs/window-animations-testing.md diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index 16c5a7b6..2a051bec 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -165,6 +165,8 @@ Deferred/future polish: Goal: implement Wry's staged no-overlap planner while preserving the rule that windows never overlap. +Manual verification checklist: `docs/window-animations-testing.md`. + Core rules: - Each phase is a discrete animation using the full curve. diff --git a/docs/window-animations-testing.md b/docs/window-animations-testing.md new file mode 100644 index 00000000..2484a71f --- /dev/null +++ b/docs/window-animations-testing.md @@ -0,0 +1,404 @@ +# Window Animations Manual Test Plan + +This is the manual verification checklist for Wry's animation work. Use it after +building a test compositor and booting into a normal graphical session. + +The goal is to catch visual, synchronization, damage, and retained-content +problems that unit tests cannot prove from geometry alone. + +## Setup + +- Build and install the `codex-anims-next` branch. +- Start with animations enabled and a deliberately slow duration, around + `400-700ms`, so phase ordering and damage artifacts are visible. +- Test at least one normal Wayland/XDG app, one Xwayland app if available, and + one fast-updating app such as a terminal running output, a browser animation, + a video, or a GL/Vulkan demo. +- Use visible gaps, borders, titlebars, and rounding. These make clipping and + damage mistakes much easier to see. +- If available, test on both a single-output setup and a multi-output setup. +- If logging is convenient, run with debug logging and keep any multiphase + fallback messages. A fallback is useful evidence, not automatically a bug. + +Relevant internal config hooks: + +- `SetAnimationsEnabled` +- `SetAnimationDurationMs` +- `SetAnimationCurve` +- `SetAnimationCubicBezier` + +Current curve IDs in code: + +- `0`: linear +- `1`: CSS `ease` +- `2`: CSS `ease-in` +- `3` or any other unrecognized value: CSS `ease-out` +- `4`: CSS `ease-in-out` + +## Pass Criteria + +A test passes when: + +- layout, focus, hit testing, and configure behavior use the final logical + geometry immediately +- visible presentation motion is smooth and bounded by the animated frame +- no old pixels, trails, black strips, transparent holes, or stale titlebar + fragments remain after motion +- tiled multiphase movement never overlaps and never moves a single window + diagonally during a phase +- interruption starts changed windows from their current visual rect without + restarting unaffected windows +- drag-driven pointer movement remains direct and does not lag behind the cursor +- cross-output or cross-scale movements snap instead of animating + +Record a failure with: + +- the layout before and after +- whether the window was tiled, floating, mono, XDG, Xwayland, or layer-shell +- whether the app was GPU/dmabuf-backed or likely SHM, if known +- animation duration and curve +- whether the failure was visual overlap, diagonal motion, debris, clipping, + stale content, a missing retained frame, or an incorrect animation trigger + +## Known Current Limits + +These are acceptable unless they produce worse behavior than described: + +- Spawn-out is retained-content-only. If the surface cannot be retained safely, + it should snap out rather than animate an empty frame. +- Async SHM surfaces are not retained yet. GPU/dmabuf-backed app windows are the + primary retained-content path for this phase. +- A dedicated retained-tree scaling/offscreen fallback is deferred. Retained + records currently render through the normal texture and stretch/clamp paths. +- Floats may overlap. The no-overlap invariant is for tiled multiphase motion. +- Linear fallback may overlap. This should be rare for valid tiled layouts, and + the fallback should be scoped to the affected motion group. +- Cross-output and cross-scale movements should not animate yet. + +## 1. Basic Enable/Disable + +1. Disable animations. +2. Move, resize, spawn, close, and toggle floating on a few windows. +3. Confirm all affected windows snap with no delayed presentation state. +4. Enable animations at a slow duration. +5. Repeat the same operations and confirm only eligible paths animate. +6. Disable animations while an animation is in flight. + +Expected: + +- disabling animations clears any in-flight visual state +- no stale damage remains after disabling +- newly enabled animations use the configured duration and curve + +## 2. Spawn-In + +Test newly mapped windows: + +- tiled XDG window +- floating XDG window +- Xwayland window, if available +- fullscreen window +- layer-shell or overlay surface, such as a bar, launcher, menu, notification, + or lock/overlay component, if available + +Expected: + +- newly mapped tiled and floating app windows animate in +- layer-shell, overlay, override-redirect, and fullscreen surfaces do not use + the app-window spawn-in path +- contents stay clipped to the animated frame +- if contents are smaller than the frame during the animation, no empty strips + are visible + +## 3. Spawn-Out + +Close windows from these states: + +- tiled app window +- floating app window +- Xwayland app window +- fast-updating app window +- a likely SHM/simple app, if available + +Expected: + +- retained app content shrinks out after the live node is gone +- there is no black, transparent, or unfilled moving rectangle +- if content cannot be retained, the window snaps out cleanly +- neighboring tiled windows reflow without debris left in the old area + +Hard failure: + +- a destroyed window leaves a moving empty frame +- the last frame shows unrelated newer content +- screen debris remains after the animation completes + +## 4. Linear Tiled Reflow + +Use a slow duration and a non-linear curve, then repeat with linear. + +Cases: + +- open two tiled windows and change split ratio by command +- open three tiled windows and resize the active split repeatedly +- move focus and issue command-driven swaps +- interrupt a resize by issuing another resize before the first completes +- create a layout that forces a linear fallback if possible + +Expected: + +- final layout is usable immediately +- changed windows animate from their current visual rect on interruption +- unaffected windows keep their existing timeline +- linear fallback is visually smooth, even if overlap occurs +- no pointer drag path becomes animated + +## 5. Float Movement And Tile/Float Transitions + +Cases: + +- command-toggle a tiled window to floating +- command-toggle the same window back to tiled +- command-move and command-resize a floating window +- mouse-drag a floating window +- mouse-resize a floating window +- double-click/header pointer path if that is part of the local workflow + +Expected: + +- command-driven tile-to-float and float-to-tile transitions animate linearly +- command-driven floating move/resize animates +- mouse or tablet drag/resize remains direct and tracks the pointer +- pointer/header paths that are intentionally outside the command-animation gate + do not unexpectedly use delayed animation +- retained child content remains clipped during tile/float transitions + +## 6. Multiphase Horizontal And Vertical Swaps + +Horizontal: + +1. Create two horizontally adjacent tiled windows. +2. Swap their positions. +3. Reverse the swap. + +Vertical: + +1. Create two vertically adjacent tiled windows. +2. Swap their positions. +3. Reverse the swap. + +Expected: + +- first phase shrinks into lanes on the orthogonal axis +- second phase moves only horizontally or only vertically +- third phase grows out of lanes +- no phase overlaps windows +- no window moves diagonally +- reverse direction uses the same visual logic in reverse +- titlebars, borders, gaps, and rounded corners remain respected + +## 7. Stack Extraction And Return + +Build this shape: + +```text +[ A | [ B + C ] ] +``` + +Then move `B` out so the target is: + +```text +[ A | B | C ] +``` + +Reverse the operation by putting `B` back into the stack. + +Expected: + +- peer/container space opens first +- `B` waits until there is a legal horizontal or vertical lane +- `B` moves on one axis only +- `B` and the affected peer grow together in the final phase when appropriate +- reversing the operation is visually equivalent in reverse + +## 8. Nested Parent/Child Synchronization + +Create nested split layouts where a parent changes one axis and children change +the other. Use both horizontal-parent/vertical-child and +vertical-parent/horizontal-child variants. + +Expected: + +- parent/container-space axis changes happen before child-axis changes when the + hierarchy metadata gives a deterministic order +- child windows do not visually compose parent and child transforms into + diagonal motion +- any unsupported group falls back as a group rather than partially violating the + one-axis rule + +Hard failure: + +- a child visibly changes width and height in the same phase +- a child moves diagonally because parent and child animation compound +- a child clips outside its animated frame + +## 9. Mixed-Action Phases + +Look for layouts where one window can move on one axis while another window +scales on a different axis in the same proven phase. + +Expected: + +- mixed phases are allowed only when each individual window performs one legal + move or scale on one axis +- no individual window moves diagonally +- no overlap occurs at any point during the phase + +This is easier to confirm with debug fallback logs plus visual inspection. A +fallback here is acceptable if the planner cannot prove the sequence. + +## 10. Mono Mode + +Cases: + +- enter mono mode with several siblings +- exit mono mode +- switch active tabs/windows inside mono +- move a window into mono +- move a window out of mono + +Expected: + +- entering/exiting mono may animate where it clarifies hierarchy change +- active child animates to the mono geometry +- inactive siblings snap invisible +- ordinary tab switches inside mono do not animate +- no hidden inactive sibling leaves debris or stale retained content + +## 11. Interruption And Retargeting + +Use a long duration, then issue commands mid-animation: + +- swap, then reverse before completion +- resize, then resize in the other direction before completion +- move a window out of a stack, then back before completion +- start a multiphase group, then change only one window's destination if a + command sequence allows it + +Expected: + +- affected windows restart from their current visual rect +- unaffected windows do not restart if their destination is unchanged +- a new valid multiphase plan replaces the old plan cleanly +- retained content remains the same frozen content during the retarget +- damage covers old, current, and new visual regions + +## 12. Damage And Clipping Stress + +Use a high-contrast wallpaper/background and high contrast window contents. + +Cases: + +- fast repeated swaps +- repeated spawn-in/spawn-out +- rounded corners with large gaps +- titlebar-heavy layouts +- resize while a terminal is rapidly updating +- move/resize over another window's old location +- run on different output scales if available + +Expected: + +- no trails remain in gaps, borders, or titlebar strips +- rounded corners do not reveal old pixels outside the frame +- contents never draw outside the animated bounds +- final frame exactly matches the steady layout + +## 13. Texture Freezing + +Use fast-updating contents so freezing is obvious. + +Cases: + +- tiled GPU/dmabuf-backed app during reflow +- floating GPU/dmabuf-backed app during command move/resize +- tile-to-float and float-to-tile with dynamic content +- spawn-out with dynamic content +- likely SHM/simple app, if available + +Expected: + +- retained GPU/dmabuf-backed windows freeze visually during animation +- spawn-out uses the last retained content, not a blank or unrelated frame +- undersized contents stretch/clamp to avoid unfilled frame regions +- SHM/unretained surfaces either render live safely or snap where retention is + required + +Record separately: + +- content continues updating during movement +- content freezes but samples the wrong source region +- edges show empty/black strips while scaling +- spawn-out skips because capture was unavailable + +## 14. Cross-Output And Scale Boundaries + +Cases: + +- move a tiled window to another output +- move a floating window to another output +- move between outputs with different scale factors, if available +- move a workspace between outputs, if supported locally + +Expected: + +- movement snaps instead of animating +- no retained content is rendered at the wrong scale +- no stale damage remains on the source output +- destination output renders the final layout immediately + +## 15. Regression Sweep + +After visual tests, return to normal animation duration and curve. + +Repeat: + +- ordinary tiling navigation +- workspace switching +- fullscreen enter/exit +- focus changes +- app launch/close loops +- suspend/resume or VT switch if convenient + +Expected: + +- animation state does not survive across unrelated compositor state changes +- no stuck retained frames +- no persistent high CPU/GPU use after animations stop +- no obvious client throttling after many retained-content animations + +## Summary Result Template + +```text +Commit: +Build: +Outputs/scales: +GPU/session: +Animation config: + +Passed: +- + +Known-limit observations: +- + +Failures: +- case: + app: + layout: + expected: + actual: + reproducible: + logs: +``` From 31c289f628af0de7190e626b098ab83d0c254c94 Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 23:05:31 +1000 Subject: [PATCH 28/47] Document multiphase animation test activation --- docs/window-animations-testing.md | 43 +++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/docs/window-animations-testing.md b/docs/window-animations-testing.md index 2484a71f..d464b0fe 100644 --- a/docs/window-animations-testing.md +++ b/docs/window-animations-testing.md @@ -35,6 +35,49 @@ Current curve IDs in code: - `3` or any other unrecognized value: CSS `ease-out` - `4`: CSS `ease-in-out` +## Enabling Multiphase Tests + +There is currently no separate user-facing multiphase toggle. To exercise the +multiphase planner: + +1. Enable animations with `SetAnimationsEnabled`. +2. Set a slow duration with `SetAnimationDurationMs`, around `400-700ms`. +3. Use tiled layout commands that are wired through `State::with_layout_animations`. +4. Use layouts where at least two tiled windows change geometry in the same + container layout batch. + +The compositor then attempts multiphase planning automatically when the batched +layout pass completes. If the planner proves a legal no-overlap sequence, that +group uses phased animation. If it cannot prove one, only that motion group falls +back to ordinary linear animation. + +Good command families for multiphase testing: + +- seat/window move in a tiled direction +- split changes +- tab/group operations +- group-opposite changes +- equalize +- move-tab +- mono enter/exit +- command-driven window resize + +These paths should not be used as evidence of multiphase behavior: + +- tile-to-float and float-to-tile, which deliberately use linear animation +- command-driven floating move/resize, which may animate but can overlap +- pointer or tablet drag/resize, which should not animate +- spawn-in and spawn-out, which are always linear +- cross-output or cross-scale movement, which should snap +- layer-shell, overlay, override-redirect, and fullscreen map/unmap paths + +Useful debug signal: + +- `falling back to linear layout animation for group ...` means the group entered + the multiphase gate but the planner rejected it. That is acceptable for + unsupported patterns, but unexpected for the supported swap/extraction cases + below. + ## Pass Criteria A test passes when: From 0fefe814c37eed79760436ed021c6a6f7a5ed258 Mon Sep 17 00:00:00 2001 From: atagen Date: Fri, 22 May 2026 09:16:51 +1000 Subject: [PATCH 29/47] Repair animation integration paths --- src/animation.rs | 11 +- src/animation/multiphase.rs | 159 ++++++++++++++++++++++++++ src/ifs/wl_buffer.rs | 13 --- src/ifs/wl_surface/commit_timeline.rs | 5 + src/state.rs | 9 +- src/tree/container.rs | 35 ++++-- src/tree/toplevel.rs | 29 +++-- src/tree/workspace.rs | 2 +- src/xwayland/xwm.rs | 1 + 9 files changed, 229 insertions(+), 35 deletions(-) diff --git a/src/animation.rs b/src/animation.rs index fa7a58a7..2e93df1c 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -195,13 +195,14 @@ impl RetainedToplevel { impl RetainedSurface { fn capture(surface: &WlSurface, offset: (i32, i32)) -> Option { let buffer = surface.buffer.get()?; + buffer.buffer.buf.update_texture_or_log(surface, true); let size = surface.buffer_abs_pos.get().size(); let source = *surface.buffer_points_norm.borrow(); let color_description = surface.color_description(); let render_intent = surface.render_intent(); let alpha_mode = surface.alpha_mode(); let alpha = surface.alpha(); - let content = match buffer.buffer.buf.get_stable_texture() { + let content = match buffer.buffer.buf.get_texture(surface) { Some(texture) => RetainedContent::Texture { opaque: surface.opaque(), texture, @@ -237,14 +238,18 @@ impl RetainedSurface { continue; } let pos = child.sub_surface.position.get(); - below.push(Self::capture(&child.sub_surface.surface, pos)?); + if let Some(surface) = Self::capture(&child.sub_surface.surface, pos) { + below.push(surface); + } } for child in children.above.iter() { if child.pending.get() { continue; } let pos = child.sub_surface.position.get(); - above.push(Self::capture(&child.sub_surface.surface, pos)?); + if let Some(surface) = Self::capture(&child.sub_surface.surface, pos) { + above.push(surface); + } } } Some(Self { diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index 8fa58e81..f397c70e 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -152,6 +152,7 @@ pub enum PlanStrategy { SingleAction, MixedSinglePhase, HierarchyOrderedScales, + OrientationChange { from_axis: PhaseAxis }, SwapLanes { axis: PhaseAxis }, SpaceThenOrthogonalGrowth { axis: PhaseAxis }, ReversedForwardPlan { original: Box }, @@ -501,6 +502,22 @@ fn plan_forward( } } } + for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { + match plan_orientation_change(request, axis) { + Ok(plan) => return Ok(plan), + Err(error) => { + record_rejection( + &mut attempted, + direction, + PlanStrategy::OrientationChange { from_axis: axis }, + error, + ); + if error != MultiphasePlanFailure::NoPattern { + rejection.get_or_insert(error); + } + } + } + } for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { match plan_axis_crossing_lanes(request, axis) { Ok(plan) => return Ok(plan), @@ -920,6 +937,94 @@ fn plan_space_then_orthogonal_growth( ) } +fn plan_orientation_change( + request: &MultiphaseRequest, + from_axis: PhaseAxis, +) -> Result { + if request.windows.len() < 2 { + return Err(MultiphasePlanFailure::NoPattern); + } + let to_axis = from_axis.other(); + let min_lane_size = sane_min_size(main_size(request.bounds, to_axis)); + let target_start = request + .windows + .first() + .map(|window| main_start(window.to, from_axis)) + .ok_or(MultiphasePlanFailure::NoPattern)?; + let target_end = request + .windows + .first() + .map(|window| main_end(window.to, from_axis)) + .ok_or(MultiphasePlanFailure::NoPattern)?; + let source_start = request + .windows + .first() + .map(|window| main_start(window.from, to_axis)) + .ok_or(MultiphasePlanFailure::NoPattern)?; + let source_end = request + .windows + .first() + .map(|window| main_end(window.from, to_axis)) + .ok_or(MultiphasePlanFailure::NoPattern)?; + if request.windows.iter().any(|window| { + main_start(window.from, to_axis) != source_start + || main_end(window.from, to_axis) != source_end + || main_start(window.to, from_axis) != target_start + || main_end(window.to, from_axis) != target_end + || main_size(window.to, to_axis) < min_lane_size + }) { + return Err(MultiphasePlanFailure::NoPattern); + } + + let mut phase1 = vec![]; + let mut phase2 = vec![]; + let mut phase3 = vec![]; + for window in &request.windows { + let lane = with_main_interval( + window.from, + to_axis, + main_start(window.to, to_axis), + main_end(window.to, to_axis), + ); + let moved = with_main_interval( + lane, + from_axis, + main_start(window.to, from_axis), + main_start(window.to, from_axis) + main_size(lane, from_axis), + ); + push_step(&mut phase1, window.node_id, window.from, lane); + push_step(&mut phase2, window.node_id, lane, moved); + push_step(&mut phase3, window.node_id, moved, window.to); + } + if phase1.is_empty() || phase3.is_empty() { + return Err(MultiphasePlanFailure::NoPattern); + } + build_validated_plan( + request, + PlanStrategy::OrientationChange { from_axis }, + [ + phase_draft( + PhaseKind::Scale, + to_axis, + phase1, + PhaseReason::ShrinkIntoLanes { lane_axis: to_axis }, + ), + phase_draft( + PhaseKind::Move, + from_axis, + phase2, + PhaseReason::MoveThroughFreedSpace, + ), + phase_draft( + PhaseKind::Scale, + from_axis, + phase3, + PhaseReason::GrowOutOfLanes, + ), + ], + ) +} + struct MultiphasePhaseDraft { action: MultiphasePhaseActionDraft, steps: Vec, @@ -1665,6 +1770,20 @@ mod tests { strategy: PlanStrategy::HierarchyOrderedScales, reason: MultiphasePlanFailure::NoPattern, }, + RejectedStrategy { + direction, + strategy: PlanStrategy::OrientationChange { + from_axis: PhaseAxis::Horizontal, + }, + reason: MultiphasePlanFailure::NoPattern, + }, + RejectedStrategy { + direction, + strategy: PlanStrategy::OrientationChange { + from_axis: PhaseAxis::Vertical, + }, + reason: MultiphasePlanFailure::NoPattern, + }, RejectedStrategy { direction, strategy: PlanStrategy::SwapLanes { @@ -2024,6 +2143,46 @@ mod tests { assert!(validate_plan_continuous(&req, plan)); } + #[test] + fn orientation_change_shrinks_moves_then_grows() { + let req = request(vec![ + window(1, rect(0, 0, 200, 400), rect(0, 0, 400, 200)), + window(2, rect(200, 0, 400, 400), rect(0, 200, 400, 400)), + ]); + let planned = plan_no_overlap_explained(&req).unwrap(); + let plan = &planned.plan; + + assert_eq!( + planned.explanation.strategy, + PlanStrategy::OrientationChange { + from_axis: PhaseAxis::Horizontal, + } + ); + assert_eq!( + actions(plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal, + }, + ] + ); + assert_eq!(step_to(plan, 0, id(1)), rect(0, 0, 200, 200)); + assert_eq!(step_to(plan, 0, id(2)), rect(200, 200, 400, 400)); + assert_eq!(step_to(plan, 1, id(2)), rect(0, 200, 200, 400)); + assert_eq!(step_to(plan, 2, id(1)), rect(0, 0, 400, 200)); + assert_eq!(step_to(plan, 2, id(2)), rect(0, 200, 400, 400)); + assert!(validate_plan_continuous(&req, plan)); + } + #[test] fn two_axis_redistribution_without_hierarchy_still_falls_back() { let req = request(vec![ diff --git a/src/ifs/wl_buffer.rs b/src/ifs/wl_buffer.rs index 678ee0c4..1fff5db3 100644 --- a/src/ifs/wl_buffer.rs +++ b/src/ifs/wl_buffer.rs @@ -310,19 +310,6 @@ impl WlBuffer { } } - pub fn get_stable_texture(&self) -> Option> { - match &*self.storage.borrow() { - None => None, - Some(s) => match s { - WlBufferStorage::Shm { - dmabuf_buffer_params, - .. - } => dmabuf_buffer_params.tex.clone(), - WlBufferStorage::Dmabuf { tex, .. } => tex.clone(), - }, - } - } - pub fn update_texture_or_log(&self, surface: &WlSurface, sync_shm: bool) { if let Err(e) = self.update_texture(surface, sync_shm) { log::warn!("Could not update texture: {}", ErrorFmt(e)); diff --git a/src/ifs/wl_surface/commit_timeline.rs b/src/ifs/wl_surface/commit_timeline.rs index 80ac2b4f..93372993 100644 --- a/src/ifs/wl_surface/commit_timeline.rs +++ b/src/ifs/wl_surface/commit_timeline.rs @@ -628,6 +628,11 @@ fn schedule_async_upload( { back_tex_opt = None; } + if let Some(back_tex) = &back_tex_opt + && Rc::strong_count(back_tex) > 1 + { + back_tex_opt = None; + } let damage_full = || { back.damage.clear(); back.damage.damage(slice::from_ref(&buf.rect)); diff --git a/src/state.rs b/src/state.rs index 4fc15231..98ad8cfe 100644 --- a/src/state.rs +++ b/src/state.rs @@ -836,7 +836,14 @@ impl State { pub fn map_tiled(self: &Rc, node: Rc) { let seat = self.seat_queue.last(); - self.do_map_tiled(seat.as_deref(), node.clone()); + let animate_new_app_map = node.tl_data().parent.is_none() + && node.tl_data().kind.is_app_window() + && !node.tl_data().visible.get(); + if animate_new_app_map { + self.with_layout_animations(|| self.do_map_tiled(seat.as_deref(), node.clone())); + } else { + self.do_map_tiled(seat.as_deref(), node.clone()); + } self.focus_after_map(node, seat.as_deref()); } diff --git a/src/tree/container.rs b/src/tree/container.rs index 8670125c..033c4a6f 100644 --- a/src/tree/container.rs +++ b/src/tree/container.rs @@ -1766,17 +1766,38 @@ enum SeatOpKind { pub async fn container_layout(state: Rc) { loop { - let container = state.pending_container_layout.pop().await; - if container.layout_scheduled.get() { + let first = state.pending_container_layout.pop().await; + let mut containers = vec![first]; + while let Some(container) = state.pending_container_layout.try_pop() { + containers.push(container); + } + let mut animated = vec![]; + let mut immediate = vec![]; + for container in containers { + if !container.layout_scheduled.get() { + continue; + } let animate = container.animate_next_layout.replace(false) && !state.suppress_animations_for_next_layout.get(); - let prev_active = state.layout_animations_active.replace(animate); if animate { - state.begin_layout_animation_batch(); + animated.push(container); + } else { + immediate.push(container); } - container.perform_layout(); - if animate { - state.finish_layout_animation_batch(); + } + if !animated.is_empty() { + let prev_active = state.layout_animations_active.replace(true); + state.begin_layout_animation_batch(); + for container in animated { + container.perform_layout(); + } + state.finish_layout_animation_batch(); + state.layout_animations_active.set(prev_active); + } + if !immediate.is_empty() { + let prev_active = state.layout_animations_active.replace(false); + for container in immediate { + container.perform_layout(); } state.layout_animations_active.set(prev_active); } diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index 551f48ec..bc2accc4 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -2,7 +2,10 @@ use { crate::{ animation::{ RetainedExitLayer, RetainedToplevel, - multiphase::{MultiphaseHierarchyPosition, MultiphaseWindowHierarchy, PhaseAxis}, + multiphase::{ + MultiphaseHierarchyPosition, MultiphaseHierarchyTransition, + MultiphaseWindowHierarchy, PhaseAxis, + }, }, client::{Client, ClientId}, criteria::{ @@ -195,18 +198,30 @@ impl ToplevelNode for T { target_hierarchy, ); let spawn_in_pending = data.spawn_in_pending.get(); + let spawn_in_eligible = spawn_in_pending + && !rect.is_empty() + && data.visible.get() + && !data.is_fullscreen.get() + && data.kind.is_app_window() + && !self.node_is_container(); let parent_is_mono = data .parent .get() .and_then(|parent| parent.node_into_container()) .is_some_and(|container| container.mono_child.is_some()); + let active_mono_boundary = matches!( + hierarchy.transition, + MultiphaseHierarchyTransition::EnteringMono + | MultiphaseHierarchyTransition::ExitingMono + ) && (hierarchy.source.mono_active + || hierarchy.target.mono_active); if prev != *rect && !prev.is_empty() && !rect.is_empty() && data.visible.get() && !data.parent_is_float.get() && !self.node_is_container() - && !parent_is_mono + && (!parent_is_mono || active_mono_boundary) { data.state.clone().queue_tiled_animation_with_hierarchy( data.node_id, @@ -216,20 +231,14 @@ impl ToplevelNode for T { hierarchy, ); } - if spawn_in_pending - && !rect.is_empty() - && data.visible.get() - && !data.is_fullscreen.get() - && data.kind.is_app_window() - && !self.node_is_container() - { + if spawn_in_eligible { data.state.clone().queue_spawn_in_animation( data.node_id, *rect, self.tl_animation_snapshot(), ); } - if spawn_in_pending && !rect.is_empty() { + if spawn_in_eligible { data.spawn_in_pending.set(false); } if prev.size() != rect.size() { diff --git a/src/tree/workspace.rs b/src/tree/workspace.rs index f60354a4..5e31efe6 100644 --- a/src/tree/workspace.rs +++ b/src/tree/workspace.rs @@ -197,10 +197,10 @@ impl WorkspaceNode { } self.pull_child_properties(&**container); let pos = self.position.get(); - container.clone().tl_change_extents(&pos); container.tl_set_parent(self.clone()); container.tl_set_visible(self.container_visible()); self.container.set(Some(container.clone())); + container.clone().tl_change_extents(&pos); self.state.damage(self.position.get()); } diff --git a/src/xwayland/xwm.rs b/src/xwayland/xwm.rs index b55312fe..f94645fe 100644 --- a/src/xwayland/xwm.rs +++ b/src/xwayland/xwm.rs @@ -2034,6 +2034,7 @@ impl Wm { self.windows_by_surface_serial.remove(&serial); } if let Some(window) = data.window.take() { + window.queue_spawn_out(); window.destroy(); } if let Some(parent) = data.parent.take() { From d2138b45f608bf675a42f4f9f2219659879762eb Mon Sep 17 00:00:00 2001 From: atagen Date: Fri, 22 May 2026 09:22:07 +1000 Subject: [PATCH 30/47] Constrain mono boundary animations --- src/tree/container.rs | 5 +++++ src/tree/toplevel.rs | 13 +++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/tree/container.rs b/src/tree/container.rs index 033c4a6f..37f1fa40 100644 --- a/src/tree/container.rs +++ b/src/tree/container.rs @@ -132,6 +132,7 @@ pub struct ContainerNode { pub sum_factors: Cell, pub layout_scheduled: Cell, animate_next_layout: Cell, + pub mono_transition_animation_pending: Cell, compute_render_positions_scheduled: Cell, num_children: NumCell, pub children: LinkedList, @@ -240,6 +241,7 @@ impl ContainerNode { sum_factors: Cell::new(1.0), layout_scheduled: Cell::new(false), animate_next_layout: Cell::new(false), + mono_transition_animation_pending: Cell::new(false), compute_render_positions_scheduled: Cell::new(false), num_children: NumCell::new(1), children, @@ -473,6 +475,7 @@ impl ContainerNode { fn perform_layout(self: &Rc) { self.layout_scheduled.set(false); if self.num_children.get() == 0 { + self.mono_transition_animation_pending.set(false); return; } if let Some(child) = self.mono_child.get() { @@ -490,6 +493,7 @@ impl ContainerNode { self.damage(); } } + self.mono_transition_animation_pending.set(false); } fn perform_mono_layout(self: &Rc, child: &ContainerChild) { @@ -823,6 +827,7 @@ impl ContainerNode { } } self.mono_child.set(child.clone()); + self.mono_transition_animation_pending.set(true); if child.is_some() { self.rebuild_tab_bar(); } else { diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index bc2accc4..5c8c1351 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -204,17 +204,22 @@ impl ToplevelNode for T { && !data.is_fullscreen.get() && data.kind.is_app_window() && !self.node_is_container(); - let parent_is_mono = data + let parent_container = data .parent .get() - .and_then(|parent| parent.node_into_container()) + .and_then(|parent| parent.node_into_container()); + let parent_is_mono = parent_container + .as_ref() .is_some_and(|container| container.mono_child.is_some()); + let parent_mono_transition = parent_container + .as_ref() + .is_some_and(|container| container.mono_transition_animation_pending.get()); let active_mono_boundary = matches!( hierarchy.transition, MultiphaseHierarchyTransition::EnteringMono | MultiphaseHierarchyTransition::ExitingMono - ) && (hierarchy.source.mono_active - || hierarchy.target.mono_active); + ) && parent_mono_transition + && (hierarchy.source.mono_active || hierarchy.target.mono_active); if prev != *rect && !prev.is_empty() && !rect.is_empty() From 1a75f47709fbf7c6222dc970fbc2cc2c5749f566 Mon Sep 17 00:00:00 2001 From: atagen Date: Fri, 22 May 2026 16:26:03 +1000 Subject: [PATCH 31/47] Refine animation planner test fixes --- src/animation.rs | 31 +---- src/animation/multiphase.rs | 253 ++++++++++++++++++++++++++++-------- src/state.rs | 7 + 3 files changed, 214 insertions(+), 77 deletions(-) diff --git a/src/animation.rs b/src/animation.rs index 2e93df1c..19647b80 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -22,8 +22,6 @@ const DEFAULT_DURATION_MS: u32 = 160; const CURVE_MAX_POINTS: usize = 33; const CURVE_FLATNESS_EPSILON: f32 = 0.001; const CURVE_MAX_DEPTH: u8 = 8; -const SPAWN_IN_INITIAL_SCALE_NUMERATOR: i32 = 4; -const SPAWN_IN_INITIAL_SCALE_DENOMINATOR: i32 = 5; #[derive(Copy, Clone, Debug, PartialEq)] pub enum AnimationCurve { @@ -296,7 +294,7 @@ impl AnimationState { duration_ms: u32, curve: AnimationCurve, ) -> bool { - if old == new || old.is_empty() || new.is_empty() || duration_ms == 0 { + if old == new || new.is_empty() || duration_ms == 0 { self.windows.borrow_mut().remove(&node_id); self.phased.borrow_mut().remove(&node_id); return false; @@ -420,7 +418,7 @@ impl AnimationState { return false; } let to = spawn_in_start_rect(from); - if to == from || to.is_empty() { + if to == from { return false; } let source_body_size = body_size_for_frame(from, frame_inset); @@ -690,20 +688,8 @@ impl LatchListener for AnimationTick { } pub(crate) fn spawn_in_start_rect(target: Rect) -> Rect { - fn scaled_dimension(value: i32) -> i32 { - let scaled = (value as i64 * SPAWN_IN_INITIAL_SCALE_NUMERATOR as i64 - / SPAWN_IN_INITIAL_SCALE_DENOMINATOR as i64) as i32; - scaled.clamp(1, value.max(1)) - } - - let width = scaled_dimension(target.width()); - let height = scaled_dimension(target.height()); - Rect::new_sized_saturating( - target.x1() + (target.width() - width) / 2, - target.y1() + (target.height() - height) / 2, - width, - height, - ) + let (cx, cy) = target.center(); + Rect::new_empty(cx, cy) } fn body_size_for_frame(rect: Rect, frame_inset: i32) -> (i32, i32) { @@ -936,12 +922,9 @@ mod tests { } #[test] - fn spawn_in_start_rect_is_centered_and_non_empty() { + fn spawn_in_start_rect_is_centered_and_empty() { let target = Rect::new_sized_saturating(10, 20, 100, 50); - assert_eq!( - spawn_in_start_rect(target), - Rect::new_sized_saturating(20, 25, 80, 40) - ); + assert_eq!(spawn_in_start_rect(target), Rect::new_empty(60, 45)); } #[test] @@ -952,7 +935,7 @@ mod tests { assert!(state.set_spawn_in(id, target, None, 0, 160)); assert_eq!( state.visual_rect(id, target, 80_000_000), - Rect::new_sized_saturating(15, 23, 90, 45) + Rect::new_sized_saturating(35, 33, 50, 25) ); } } diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index f397c70e..ff7c111c 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -6,6 +6,7 @@ const MIN_SHRINK_DENOMINATOR: i32 = 4; pub struct MultiphaseRequest { pub bounds: Rect, pub windows: Vec, + pub clearance: i32, } #[derive(Copy, Clone, Debug, Eq, PartialEq)] @@ -488,6 +489,22 @@ fn plan_forward( } } } + for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { + match plan_space_then_orthogonal_growth(request, axis) { + Ok(plan) => return Ok(plan), + Err(error) => { + record_rejection( + &mut attempted, + direction, + PlanStrategy::SpaceThenOrthogonalGrowth { axis }, + error, + ); + if error != MultiphasePlanFailure::NoPattern { + rejection.get_or_insert(error); + } + } + } + } match plan_hierarchy_ordered_axis_scales(request) { Ok(plan) => return Ok(plan), Err(error) => { @@ -534,22 +551,6 @@ fn plan_forward( } } } - for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { - match plan_space_then_orthogonal_growth(request, axis) { - Ok(plan) => return Ok(plan), - Err(error) => { - record_rejection( - &mut attempted, - direction, - PlanStrategy::SpaceThenOrthogonalGrowth { axis }, - error, - ); - if error != MultiphasePlanFailure::NoPattern { - rejection.get_or_insert(error); - } - } - } - } Err(PlanForwardFailure { reason: rejection.unwrap_or(MultiphasePlanFailure::NoPattern), attempted, @@ -750,7 +751,13 @@ fn plan_axis_crossing_lanes( request: &MultiphaseRequest, axis: PhaseAxis, ) -> Result { - if request.windows.len() != 2 { + let moving_windows: Vec<_> = request + .windows + .iter() + .copied() + .filter(|window| window.from != window.to) + .collect(); + if moving_windows.len() != 2 { return Err(MultiphasePlanFailure::NoPattern); } let orth_min = request @@ -765,7 +772,7 @@ fn plan_axis_crossing_lanes( .map(|window| orth_end(window.from, axis)) .max() .ok_or(MultiphasePlanFailure::NoPattern)?; - if request.windows.iter().any(|window| { + if moving_windows.iter().any(|window| { main_size(window.from, axis) != main_size(window.to, axis) || orth_start(window.from, axis) != orth_min || orth_end(window.from, axis) != orth_max @@ -775,7 +782,18 @@ fn plan_axis_crossing_lanes( }) { return Err(MultiphasePlanFailure::NoPattern); } - let lane_size = (orth_max - orth_min) / request.windows.len() as i32; + let clearance = request.clearance.max(0); + let lane_count = moving_windows.len() as i32; + let available = (orth_max - orth_min) - clearance.saturating_mul(lane_count - 1); + if available <= 0 { + return Err(MultiphasePlanFailure::ShrinkBound { + axis: axis.other(), + available: 0, + required: sane_min_size(orth_max - orth_min), + }); + } + let lane_size = available / lane_count; + let mut lane_remainder = available % lane_count; let required = sane_min_size(orth_max - orth_min); if lane_size < required { return Err(MultiphasePlanFailure::ShrinkBound { @@ -785,7 +803,7 @@ fn plan_axis_crossing_lanes( }); } - let mut windows = request.windows.clone(); + let mut windows = moving_windows; windows.sort_by_key(|window| lane_index_for_direction(*window, axis)); if windows.windows(2).any(|pair| { lane_index_for_direction(pair[0], axis) == lane_index_for_direction(pair[1], axis) @@ -795,13 +813,15 @@ fn plan_axis_crossing_lanes( let mut phase1 = vec![]; let mut phase2 = vec![]; let mut phase3 = vec![]; + let mut lane_start = orth_min; for (idx, window) in windows.iter().enumerate() { - let lane_start = orth_min + lane_size * idx as i32; - let lane_end = if idx + 1 == windows.len() { - orth_max + let extra = if lane_remainder > 0 { + lane_remainder -= 1; + 1 } else { - lane_start + lane_size + 0 }; + let lane_end = lane_start + lane_size + extra; let lane_from = with_orth_interval(window.from, axis, lane_start, lane_end); let lane_to = with_main_interval( lane_from, @@ -812,6 +832,9 @@ fn plan_axis_crossing_lanes( push_step(&mut phase1, window.node_id, window.from, lane_from); push_step(&mut phase2, window.node_id, lane_from, lane_to); push_step(&mut phase3, window.node_id, lane_to, window.to); + if idx + 1 < windows.len() { + lane_start = lane_end + clearance; + } } build_validated_plan( request, @@ -882,6 +905,7 @@ fn plan_space_then_orthogonal_growth( || main_end(window.from, axis) != main_end(window.to, axis); let orth_changes = orth_start(window.from, axis) != orth_start(window.to, axis) || orth_end(window.from, axis) != orth_end(window.to, axis); + let mut orth_from = window.from; if main_changes && main_size(window.from, axis) == main_size(window.to, axis) { let after_move = with_main_interval( window.from, @@ -890,22 +914,50 @@ fn plan_space_then_orthogonal_growth( main_end(window.to, axis), ); push_step(&mut phase2, window.node_id, window.from, after_move); - if orth_changes { - push_step(&mut phase3, window.node_id, after_move, window.to); - } + orth_from = after_move; } else if main_changes { - let after_main_scale = with_main_interval( - window.from, - axis, - main_start(window.to, axis), - main_end(window.to, axis), - ); + let target_size = main_size(window.to, axis); + let after_main_scale = if main_start(window.from, axis) == main_start(window.to, axis) + || main_end(window.from, axis) == main_end(window.to, axis) + { + with_main_interval( + window.from, + axis, + main_start(window.to, axis), + main_end(window.to, axis), + ) + } else if main_start(window.to, axis) < main_start(window.from, axis) { + with_main_interval( + window.from, + axis, + main_end(window.from, axis) - target_size, + main_end(window.from, axis), + ) + } else { + with_main_interval( + window.from, + axis, + main_start(window.from, axis), + main_start(window.from, axis) + target_size, + ) + }; push_step(&mut phase1, window.node_id, window.from, after_main_scale); - if orth_changes { - push_step(&mut phase3, window.node_id, after_main_scale, window.to); + orth_from = after_main_scale; + if main_start(after_main_scale, axis) != main_start(window.to, axis) + || main_end(after_main_scale, axis) != main_end(window.to, axis) + { + let after_move = with_main_interval( + after_main_scale, + axis, + main_start(window.to, axis), + main_end(window.to, axis), + ); + push_step(&mut phase2, window.node_id, after_main_scale, after_move); + orth_from = after_move; } - } else if orth_changes { - push_step(&mut phase3, window.node_id, window.from, window.to); + } + if orth_changes { + push_step(&mut phase3, window.node_id, orth_from, window.to); } } if phase1.is_empty() || phase2.is_empty() || phase3.is_empty() { @@ -1372,6 +1424,7 @@ fn single_action_reason(action: PhaseAction) -> PhaseReason { fn reverse_request(request: &MultiphaseRequest) -> MultiphaseRequest { MultiphaseRequest { bounds: request.bounds, + clearance: request.clearance, windows: request .windows .iter() @@ -1686,7 +1739,11 @@ mod tests { MultiphaseWindowHierarchy::new(old_leaf.hierarchy, new_leaf.hierarchy), )); } - MultiphaseRequest { bounds, windows } + MultiphaseRequest { + bounds, + windows, + clearance: 0, + } } fn assert_generated_case_plans(old: &TestTree, new: &TestTree, bounds: Rect) { @@ -1739,7 +1796,11 @@ mod tests { .map(|window| window.from.union(window.to)) .reduce(|bounds, rect| bounds.union(rect)) .unwrap_or_else(|| rect(0, 0, 1, 1)); - MultiphaseRequest { bounds, windows } + MultiphaseRequest { + bounds, + windows, + clearance: 0, + } } fn actions(plan: &MultiphasePlan) -> Vec { @@ -1765,6 +1826,20 @@ mod tests { strategy: PlanStrategy::SingleAction, reason: MultiphasePlanFailure::NoPattern, }, + RejectedStrategy { + direction, + strategy: PlanStrategy::SpaceThenOrthogonalGrowth { + axis: PhaseAxis::Horizontal, + }, + reason: MultiphasePlanFailure::NoPattern, + }, + RejectedStrategy { + direction, + strategy: PlanStrategy::SpaceThenOrthogonalGrowth { + axis: PhaseAxis::Vertical, + }, + reason: MultiphasePlanFailure::NoPattern, + }, RejectedStrategy { direction, strategy: PlanStrategy::HierarchyOrderedScales, @@ -1798,20 +1873,6 @@ mod tests { }, reason: MultiphasePlanFailure::NoPattern, }, - RejectedStrategy { - direction, - strategy: PlanStrategy::SpaceThenOrthogonalGrowth { - axis: PhaseAxis::Horizontal, - }, - reason: MultiphasePlanFailure::NoPattern, - }, - RejectedStrategy { - direction, - strategy: PlanStrategy::SpaceThenOrthogonalGrowth { - axis: PhaseAxis::Vertical, - }, - reason: MultiphasePlanFailure::NoPattern, - }, ] } @@ -1916,6 +1977,38 @@ mod tests { assert!(validate_plan_continuous(&req, &plan)); } + #[test] + fn swap_lanes_respect_requested_clearance() { + let mut req = request(vec![ + window(1, rect(0, 0, 100, 100), rect(100, 0, 200, 100)), + window(2, rect(100, 0, 200, 100), rect(0, 0, 100, 100)), + ]); + req.clearance = 10; + + let plan = plan_no_overlap(&req).unwrap(); + assert_eq!(step_to(&plan, 0, id(1)), rect(0, 0, 100, 45)); + assert_eq!(step_to(&plan, 0, id(2)), rect(100, 55, 200, 100)); + assert!(validate_plan_continuous(&req, &plan)); + } + + #[test] + fn swap_lanes_tolerate_stationary_siblings_in_request() { + let req = request(vec![ + window(1, rect(0, 0, 100, 100), rect(100, 0, 200, 100)), + window(2, rect(100, 0, 200, 100), rect(0, 0, 100, 100)), + window(3, rect(200, 0, 300, 100), rect(200, 0, 300, 100)), + ]); + + let planned = plan_no_overlap_explained(&req).unwrap(); + assert_eq!( + planned.explanation.strategy, + PlanStrategy::SwapLanes { + axis: PhaseAxis::Horizontal, + } + ); + assert!(validate_plan_continuous(&req, &planned.plan)); + } + #[test] fn vertical_swap_lanes_follow_motion_direction_not_node_id() { let req = request(vec![ @@ -2232,6 +2325,59 @@ mod tests { assert_generated_case_plans(&vertical_new, &vertical_old, rect(0, 0, 100, 400)); } + #[test] + fn stack_extraction_with_resized_moving_child_still_moves_before_growth() { + let old = split( + 10, + PhaseAxis::Horizontal, + &[1, 1], + vec![ + leaf(1), + split(11, PhaseAxis::Vertical, &[1, 1], vec![leaf(2), leaf(3)]), + ], + ); + let new = split( + 10, + PhaseAxis::Horizontal, + &[1, 1, 1], + vec![leaf(1), leaf(2), leaf(3)], + ); + let req = generated_request(&old, &new, rect(0, 0, 300, 120)); + let planned = plan_no_overlap_explained(&req).unwrap(); + let plan = &planned.plan; + + assert_eq!( + planned.explanation.strategy, + PlanStrategy::SpaceThenOrthogonalGrowth { + axis: PhaseAxis::Horizontal, + } + ); + assert_eq!( + actions(plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + ] + ); + assert_eq!(step_to(plan, 0, id(1)), rect(0, 0, 100, 120)); + assert_eq!(step_to(plan, 0, id(2)), rect(200, 0, 300, 60)); + assert_eq!(step_to(plan, 0, id(3)), rect(200, 60, 300, 120)); + assert_eq!(step_to(plan, 1, id(2)), rect(100, 0, 200, 60)); + assert_eq!(step_to(plan, 2, id(2)), rect(100, 0, 200, 120)); + assert_eq!(step_to(plan, 2, id(3)), rect(200, 0, 300, 120)); + assert!(validate_plan_continuous(&req, plan)); + } + #[test] fn bounded_generated_supported_split_tree_corpus_is_deterministic() { let mut cases = vec![]; @@ -2543,6 +2689,7 @@ mod tests { fn diagnostics_report_shrink_bound_rejections() { let req = MultiphaseRequest { bounds: rect(0, 0, 400, 100), + clearance: 0, windows: vec![ MultiphaseWindow { node_id: id(1), diff --git a/src/state.rs b/src/state.rs index 98ad8cfe..c21b575d 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1650,6 +1650,12 @@ impl State { } } + fn layout_animation_clearance(&self) -> i32 { + let border = self.theme.sizes.border_width.get().max(0); + let gap = self.theme.sizes.gap.get().max(0); + if gap == 0 { border } else { gap + 2 * border } + } + fn start_multiphase_layout_animation( self: &Rc, candidates: &[LayoutAnimationCandidate], @@ -1671,6 +1677,7 @@ impl State { let request = MultiphaseRequest { bounds, windows: request_windows, + clearance: self.layout_animation_clearance(), }; let plan = match plan_no_overlap_with_diagnostics(&request) { Ok(plan) => plan, From b211b5352824ecdc7974aeabf737c6c79e1171c3 Mon Sep 17 00:00:00 2001 From: atagen Date: Fri, 22 May 2026 16:35:44 +1000 Subject: [PATCH 32/47] Handle phased animation retargeting --- docs/window-animations-testing.md | 27 ++++++-- src/animation.rs | 106 ++++++++++++++++++++++++++++++ src/animation/multiphase.rs | 82 +++++++++++++++++++++++ src/state.rs | 55 +++++++++++++++- 4 files changed, 263 insertions(+), 7 deletions(-) diff --git a/docs/window-animations-testing.md b/docs/window-animations-testing.md index d464b0fe..e5d18900 100644 --- a/docs/window-animations-testing.md +++ b/docs/window-animations-testing.md @@ -288,8 +288,27 @@ Hard failure: ## 9. Mixed-Action Phases -Look for layouts where one window can move on one axis while another window -scales on a different axis in the same proven phase. +This case is easiest to prove with the planner tests because Wry currently has +few user commands that create a same-batch move for one tiled window and an +independent resize for another. The canonical geometry is: + +```text +start: A = (0,0)-(80,80) B = (200,0)-(280,80) +target: A = (40,0)-(120,80) B = (200,0)-(280,120) +``` + +`A` moves horizontally while `B` scales vertically. The windows are far enough +apart that the mixed phase is provably non-overlapping. + +To exercise the current proof directly, run: + +```sh +cargo test animation::multiphase::tests::mixed_single_phase_accepts_move_and_scale_when_proven +``` + +For visual/manual testing, use any command sequence that can produce the same +shape in one layout batch: one window changes only x-position, and a separate, +non-overlapping window changes only height. Expected: @@ -298,8 +317,8 @@ Expected: - no individual window moves diagonally - no overlap occurs at any point during the phase -This is easier to confirm with debug fallback logs plus visual inspection. A -fallback here is acceptable if the planner cannot prove the sequence. +A fallback here is acceptable if no normal user command can create this geometry; +the planner test above is the authority for the mixed-action rule. ## 10. Mono Mode diff --git a/src/animation.rs b/src/animation.rs index 19647b80..d0fafb1f 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -474,6 +474,20 @@ impl AnimationState { } } + pub fn phased_route_to( + &self, + node_id: NodeId, + target: Rect, + now_nsec: u64, + ) -> Option> { + let phased = self.phased.borrow(); + let anim = phased.get(&node_id)?; + if anim.done(now_nsec) { + return None; + } + anim.route_to(target, now_nsec) + } + pub fn exit_frames(&self, now_nsec: u64) -> Vec { self.exits .borrow() @@ -614,6 +628,52 @@ impl PhasedWindowAnimation { let t = self.curve.sample(t); lerp_rect(segment.from, segment.to, t) } + + fn phase_at(&self, now_nsec: u64) -> Option { + if self.duration_nsec == 0 || self.segments.is_empty() { + return None; + } + let elapsed = now_nsec.saturating_sub(self.start_nsec); + let phase = (elapsed / self.duration_nsec) as usize; + (phase < self.segments.len()).then_some(phase) + } + + fn path_points(&self) -> Option> { + let first = self.segments.first()?; + let mut points = vec![first.from]; + points.extend(self.segments.iter().map(|segment| segment.to)); + Some(points) + } + + fn route_to(&self, target: Rect, now_nsec: u64) -> Option> { + let phase = self.phase_at(now_nsec)?; + let points = self.path_points()?; + let target_idx = points.iter().position(|point| *point == target)?; + let current = self.rect_at(now_nsec); + if current == target { + return Some(vec![]); + } + + let mut route = vec![]; + if target_idx <= phase { + push_non_empty_segment(&mut route, current, points[phase]); + for idx in (target_idx..phase).rev() { + push_non_empty_segment(&mut route, points[idx + 1], points[idx]); + } + } else { + push_non_empty_segment(&mut route, current, points[phase + 1]); + for idx in (phase + 1)..target_idx { + push_non_empty_segment(&mut route, points[idx], points[idx + 1]); + } + } + Some(route) + } +} + +fn push_non_empty_segment(route: &mut Vec<(Rect, Rect)>, from: Rect, to: Rect) { + if from != to { + route.push((from, to)); + } } struct ExitAnimation { @@ -863,6 +923,52 @@ mod tests { assert_eq!(state.visual_rect(id, c, 200_000_000), c); } + #[test] + fn phased_route_reverses_to_existing_endpoint() { + let state = AnimationState::default(); + let id = NodeId(1); + let a = Rect::new_sized_saturating(0, 0, 100, 100); + let b = Rect::new_sized_saturating(0, 0, 100, 50); + let c = Rect::new_sized_saturating(100, 0, 100, 50); + assert!(state.set_phased_target( + id, + vec![(a, b), (b, c)], + None, + 0, + 100, + AnimationCurve::Linear + )); + + let current = lerp_rect(b, c, 0.5); + assert_eq!( + state.phased_route_to(id, a, 150_000_000).unwrap(), + vec![(current, b), (b, a)] + ); + } + + #[test] + fn phased_route_continues_to_existing_endpoint() { + let state = AnimationState::default(); + let id = NodeId(1); + let a = Rect::new_sized_saturating(0, 0, 100, 100); + let b = Rect::new_sized_saturating(0, 0, 100, 50); + let c = Rect::new_sized_saturating(100, 0, 100, 50); + assert!(state.set_phased_target( + id, + vec![(a, b), (b, c)], + None, + 0, + 100, + AnimationCurve::Linear + )); + + let current = lerp_rect(a, b, 0.5); + assert_eq!( + state.phased_route_to(id, c, 50_000_000).unwrap(), + vec![(current, b), (b, c)] + ); + } + #[test] fn linear_retarget_interrupts_phased_animation_from_current_rect() { let state = AnimationState::default(); diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index ff7c111c..b4397c2a 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -402,6 +402,52 @@ pub fn plan_no_overlap_explained( } } +pub(crate) fn validate_phase_paths( + request: &MultiphaseRequest, + paths: &[Vec<(Rect, Rect)>], +) -> Result { + if paths.len() != request.windows.len() { + return Err(MultiphasePlanFailure::NoPattern); + } + let phase_count = paths.iter().map(Vec::len).max().unwrap_or(0); + if phase_count == 0 { + return Err(MultiphasePlanFailure::NoPattern); + } + let mut phases = vec![]; + for phase_idx in 0..phase_count { + let mut steps = vec![]; + let mut actions = vec![]; + for (window_idx, path) in paths.iter().enumerate() { + let Some((from, to)) = path.get(phase_idx).copied() else { + continue; + }; + if from == to { + continue; + } + let step = MultiphaseStep { + node_id: request.windows[window_idx].node_id, + from, + to, + }; + let Some(action) = classify_step(step) else { + return Err(MultiphasePlanFailure::NoPattern); + }; + steps.push(step); + actions.push(action); + } + if !steps.is_empty() { + phases.push(MultiphasePhase { + action: MultiphasePhaseAction::from_step_actions(actions), + steps, + }); + } + } + let plan = MultiphasePlan { phases }; + validate_plan_continuous_diagnostic(request, &plan) + .map(|_| plan) + .map_err(MultiphasePlanFailure::Validation) +} + pub(crate) fn partition_motion_groups(windows: &[MultiphaseWindow]) -> Vec> { let mut groups = vec![]; let mut seen = vec![false; windows.len()]; @@ -2378,6 +2424,42 @@ mod tests { assert!(validate_plan_continuous(&req, plan)); } + #[test] + fn validated_phase_paths_accept_interrupted_reverse_route() { + let a_current = rect(50, 0, 150, 50); + let b_current = rect(50, 50, 150, 100); + let req = request(vec![ + window(1, a_current, rect(0, 0, 100, 100)), + window(2, b_current, rect(100, 0, 200, 100)), + ]); + let paths = vec![ + vec![ + (a_current, rect(0, 0, 100, 50)), + (rect(0, 0, 100, 50), rect(0, 0, 100, 100)), + ], + vec![ + (b_current, rect(100, 50, 200, 100)), + (rect(100, 50, 200, 100), rect(100, 0, 200, 100)), + ], + ]; + + let plan = validate_phase_paths(&req, &paths).unwrap(); + assert_eq!( + actions(&plan), + vec![ + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + ] + ); + assert!(validate_plan_continuous(&req, &plan)); + } + #[test] fn bounded_generated_supported_split_tree_corpus_is_deterministic() { let mut cases = vec![]; diff --git a/src/state.rs b/src/state.rs index c21b575d..e0cc4469 100644 --- a/src/state.rs +++ b/src/state.rs @@ -7,7 +7,7 @@ use { expand_damage_rect, multiphase::{ MultiphaseRequest, MultiphaseWindow, MultiphaseWindowHierarchy, - partition_motion_groups, plan_no_overlap_with_diagnostics, + partition_motion_groups, plan_no_overlap_with_diagnostics, validate_phase_paths, }, spawn_in_start_rect, }, @@ -1679,6 +1679,9 @@ impl State { windows: request_windows, clearance: self.layout_animation_clearance(), }; + if self.start_existing_phased_retarget(candidates, windows, group, &request, now_nsec) { + return true; + } let plan = match plan_no_overlap_with_diagnostics(&request) { Ok(plan) => plan, Err(diagnostic) => { @@ -1690,7 +1693,53 @@ impl State { return false; } }; - if plan.phases.is_empty() { + self.start_multiphase_plan(candidates, windows, group, &plan.phases, now_nsec) + } + + fn start_existing_phased_retarget( + self: &Rc, + candidates: &[LayoutAnimationCandidate], + windows: &[MultiphaseWindow], + group: &[usize], + request: &MultiphaseRequest, + now_nsec: u64, + ) -> bool { + let mut paths = vec![]; + for &idx in group { + let candidate = &candidates[idx]; + let window = windows[idx]; + let Some(path) = + self.animations + .phased_route_to(candidate.node_id, window.to, now_nsec) + else { + return false; + }; + paths.push(path); + } + let plan = match validate_phase_paths(request, &paths) { + Ok(plan) => plan, + Err(error) => { + log::debug!( + "existing phased retarget rejected for group {:?}: {:?}", + group, + error + ); + return false; + } + }; + log::debug!("retargeting active phased animation for group {:?}", group); + self.start_multiphase_plan(candidates, windows, group, &plan.phases, now_nsec) + } + + fn start_multiphase_plan( + self: &Rc, + candidates: &[LayoutAnimationCandidate], + windows: &[MultiphaseWindow], + group: &[usize], + plan_phases: &[crate::animation::multiphase::MultiphasePhase], + now_nsec: u64, + ) -> bool { + if plan_phases.is_empty() { return false; } let mut entries = vec![]; @@ -1700,7 +1749,7 @@ impl State { let mut current = window.from; let mut damage = current.union(window.to); let mut phases = vec![]; - for phase in &plan.phases { + for phase in plan_phases { match phase .steps .iter() From dfcb2d0fd69cf9a53543c78c99d2f04c75944c60 Mon Sep 17 00:00:00 2001 From: atagen Date: Sun, 24 May 2026 14:40:22 +1000 Subject: [PATCH 33/47] Use configured curve for spawn animations --- src/animation.rs | 25 +++++++++++++++++++------ src/state.rs | 2 ++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/animation.rs b/src/animation.rs index d0fafb1f..8d804588 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -391,6 +391,7 @@ impl AnimationState { retained: Option>, now_nsec: u64, duration_ms: u32, + curve: AnimationCurve, ) -> bool { let start = spawn_in_start_rect(target); self.set_target( @@ -400,7 +401,7 @@ impl AnimationState { retained, now_nsec, duration_ms, - AnimationCurve::Linear, + curve, ) } @@ -413,6 +414,7 @@ impl AnimationState { layer: RetainedExitLayer, now_nsec: u64, duration_ms: u32, + curve: AnimationCurve, ) -> bool { if from.is_empty() || duration_ms == 0 { return false; @@ -430,6 +432,7 @@ impl AnimationState { to, start_nsec: now_nsec, duration_nsec: duration_ms as u64 * 1_000_000, + curve, last_damage: from, retained, frame_inset, @@ -681,6 +684,7 @@ struct ExitAnimation { to: Rect, start_nsec: u64, duration_nsec: u64, + curve: AnimationCurve, last_damage: Rect, retained: Rc, frame_inset: i32, @@ -700,6 +704,7 @@ impl ExitAnimation { } let elapsed = now_nsec.saturating_sub(self.start_nsec); let t = (elapsed as f64 / self.duration_nsec as f64).clamp(0.0, 1.0); + let t = self.curve.sample(t); lerp_rect(self.from, self.to, t) } } @@ -872,11 +877,12 @@ mod tests { } #[test] - fn spawn_out_frames_shrink_linearly_and_expire() { + fn spawn_out_frames_use_configured_curve_and_expire() { let state = AnimationState::default(); let retained = retained_for_tests(); let from = Rect::new_sized_saturating(10, 20, 100, 80); let to = spawn_in_start_rect(from); + let curve = AnimationCurve::from_config(3); assert!(state.set_spawn_out( from, 2, @@ -884,7 +890,8 @@ mod tests { true, RetainedExitLayer::Floating, 0, - 160 + 160, + curve )); let start = state.exit_frames(0); @@ -897,7 +904,8 @@ mod tests { let middle = state.exit_frames(80_000_000); assert_eq!(middle.len(), 1); - assert_eq!(middle[0].rect, lerp_rect(from, to, 0.5)); + assert_eq!(middle[0].rect, lerp_rect(from, to, curve.sample(0.5))); + assert_ne!(middle[0].rect, lerp_rect(from, to, 0.5)); assert!(state.exit_frames(160_000_000).is_empty()); } @@ -1034,12 +1042,17 @@ mod tests { } #[test] - fn spawn_in_uses_linear_curve() { + fn spawn_in_uses_configured_curve() { let state = AnimationState::default(); let id = NodeId(1); let target = Rect::new_sized_saturating(10, 20, 100, 50); - assert!(state.set_spawn_in(id, target, None, 0, 160)); + let curve = AnimationCurve::from_config(3); + assert!(state.set_spawn_in(id, target, None, 0, 160, curve)); assert_eq!( + state.visual_rect(id, target, 80_000_000), + lerp_rect(spawn_in_start_rect(target), target, curve.sample(0.5)) + ); + assert_ne!( state.visual_rect(id, target, 80_000_000), Rect::new_sized_saturating(35, 33, 50, 25) ); diff --git a/src/state.rs b/src/state.rs index e0cc4469..685af984 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1812,6 +1812,7 @@ impl State { retained, now, self.animations.duration_ms.get(), + self.animations.curve.get(), ); if started { self.damage(expand_damage_rect( @@ -1842,6 +1843,7 @@ impl State { layer, now, self.animations.duration_ms.get(), + self.animations.curve.get(), ); if started { self.damage(expand_damage_rect( From 0f6f9f2602eba0cc08188b55dbfe1c675be747ac Mon Sep 17 00:00:00 2001 From: atagen Date: Sun, 24 May 2026 14:55:24 +1000 Subject: [PATCH 34/47] Fix animation retarget and reflow regressions --- docs/window-animations-testing.md | 27 +++-- src/animation.rs | 160 ++++++++++++++++++++++++++---- src/animation/multiphase.rs | 76 +++++++++++++- src/renderer.rs | 23 +++++ src/state.rs | 3 - src/tree/container.rs | 5 + 6 files changed, 261 insertions(+), 33 deletions(-) diff --git a/docs/window-animations-testing.md b/docs/window-animations-testing.md index e5d18900..6fe30f63 100644 --- a/docs/window-animations-testing.md +++ b/docs/window-animations-testing.md @@ -67,7 +67,7 @@ These paths should not be used as evidence of multiphase behavior: - tile-to-float and float-to-tile, which deliberately use linear animation - command-driven floating move/resize, which may animate but can overlap - pointer or tablet drag/resize, which should not animate -- spawn-in and spawn-out, which are always linear +- spawn-in and spawn-out, which are single-phase and use the configured curve - cross-output or cross-scale movement, which should snap - layer-shell, overlay, override-redirect, and fullscreen map/unmap paths @@ -288,9 +288,9 @@ Hard failure: ## 9. Mixed-Action Phases -This case is easiest to prove with the planner tests because Wry currently has -few user commands that create a same-batch move for one tiled window and an -independent resize for another. The canonical geometry is: +This case is easiest to prove with the planner tests because Wry does not yet +have a confirmed stock command that reliably creates a same-batch move for one +tiled window and an independent resize for another. The canonical geometry is: ```text start: A = (0,0)-(80,80) B = (200,0)-(280,80) @@ -306,9 +306,19 @@ To exercise the current proof directly, run: cargo test animation::multiphase::tests::mixed_single_phase_accepts_move_and_scale_when_proven ``` -For visual/manual testing, use any command sequence that can produce the same -shape in one layout batch: one window changes only x-position, and a separate, -non-overlapping window changes only height. +For visual/manual testing, the target shape is: + +```text +before: [ A ] [ B ] +after: [ A ] [ B ] + [ ] +``` + +`A` must move horizontally without resizing. `B` must resize vertically without +moving. The two motion bounds must remain separate for the whole animation. If a +normal command sequence cannot produce that in one layout batch, treat the unit +test as the authority and record the visual test as not applicable rather than a +failure. Expected: @@ -344,7 +354,8 @@ Use a long duration, then issue commands mid-animation: - swap, then reverse before completion - resize, then resize in the other direction before completion -- move a window out of a stack, then back before completion +- build `[A | [B | C | D]]`, move `C` left to form `[A | C | [B | D]]`, + then move `C` back into the stack before completion - start a multiphase group, then change only one window's destination if a command sequence allows it diff --git a/src/animation.rs b/src/animation.rs index 8d804588..f0721562 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -12,6 +12,7 @@ use { ahash::AHashMap, std::{ cell::{Cell, RefCell}, + collections::VecDeque, rc::{Rc, Weak}, }, }; @@ -368,6 +369,14 @@ impl AnimationState { .into_iter() .map(|(from, to)| PhasedSegment { from, to }) .collect(); + let mut route_edges = route_edges_from_segments(&segments); + if let Some(anim) = self.phased.borrow().get(&node_id) + && !anim.done(now_nsec) + { + for &(from, to) in &anim.route_edges { + push_unique_route_edge(&mut route_edges, from, to); + } + } self.windows.borrow_mut().remove(&node_id); self.phased.borrow_mut().insert( node_id, @@ -378,6 +387,7 @@ impl AnimationState { curve, last_damage: from, final_rect, + route_edges, retained, }, ); @@ -601,6 +611,7 @@ struct PhasedWindowAnimation { curve: AnimationCurve, last_damage: Rect, final_rect: Rect, + route_edges: Vec<(Rect, Rect)>, retained: Option>, } @@ -641,36 +652,110 @@ impl PhasedWindowAnimation { (phase < self.segments.len()).then_some(phase) } - fn path_points(&self) -> Option> { - let first = self.segments.first()?; - let mut points = vec![first.from]; - points.extend(self.segments.iter().map(|segment| segment.to)); - Some(points) - } - fn route_to(&self, target: Rect, now_nsec: u64) -> Option> { let phase = self.phase_at(now_nsec)?; - let points = self.path_points()?; - let target_idx = points.iter().position(|point| *point == target)?; let current = self.rect_at(now_nsec); if current == target { return Some(vec![]); } + let segment = self.segments.get(phase)?; + route_through_edges(current, target, segment.from, segment.to, &self.route_edges) + } +} - let mut route = vec![]; - if target_idx <= phase { - push_non_empty_segment(&mut route, current, points[phase]); - for idx in (target_idx..phase).rev() { - push_non_empty_segment(&mut route, points[idx + 1], points[idx]); - } - } else { - push_non_empty_segment(&mut route, current, points[phase + 1]); - for idx in (phase + 1)..target_idx { - push_non_empty_segment(&mut route, points[idx], points[idx + 1]); +fn route_edges_from_segments(segments: &[PhasedSegment]) -> Vec<(Rect, Rect)> { + let mut edges = vec![]; + for segment in segments { + push_unique_route_edge(&mut edges, segment.from, segment.to); + } + edges +} + +fn push_unique_route_edge(edges: &mut Vec<(Rect, Rect)>, from: Rect, to: Rect) { + if from == to { + return; + } + if edges + .iter() + .any(|&(a, b)| (a == from && b == to) || (a == to && b == from)) + { + return; + } + edges.push((from, to)); +} + +fn route_through_edges( + current: Rect, + target: Rect, + current_from: Rect, + current_to: Rect, + known_edges: &[(Rect, Rect)], +) -> Option> { + let mut edges = known_edges.to_vec(); + push_unique_route_edge(&mut edges, current, current_from); + push_unique_route_edge(&mut edges, current, current_to); + rect_graph_route(current, target, &edges) +} + +fn rect_graph_route( + start: Rect, + target: Rect, + edges: &[(Rect, Rect)], +) -> Option> { + let mut nodes = vec![]; + let mut adjacency: Vec> = vec![]; + let start_idx = rect_graph_node(&mut nodes, &mut adjacency, start); + let target_idx = rect_graph_node(&mut nodes, &mut adjacency, target); + for &(from, to) in edges { + let from_idx = rect_graph_node(&mut nodes, &mut adjacency, from); + let to_idx = rect_graph_node(&mut nodes, &mut adjacency, to); + if !adjacency[from_idx].contains(&to_idx) { + adjacency[from_idx].push(to_idx); + } + if !adjacency[to_idx].contains(&from_idx) { + adjacency[to_idx].push(from_idx); + } + } + + let mut previous = vec![None; nodes.len()]; + let mut queue = VecDeque::from([start_idx]); + previous[start_idx] = Some(start_idx); + while let Some(idx) = queue.pop_front() { + if idx == target_idx { + break; + } + for &next in &adjacency[idx] { + if previous[next].is_none() { + previous[next] = Some(idx); + queue.push_back(next); } } - Some(route) } + previous[target_idx]?; + + let mut reversed_nodes = vec![target_idx]; + let mut idx = target_idx; + while idx != start_idx { + idx = previous[idx]?; + reversed_nodes.push(idx); + } + reversed_nodes.reverse(); + + let mut route = vec![]; + for pair in reversed_nodes.windows(2) { + push_non_empty_segment(&mut route, nodes[pair[0]], nodes[pair[1]]); + } + Some(route) +} + +fn rect_graph_node(nodes: &mut Vec, adjacency: &mut Vec>, rect: Rect) -> usize { + if let Some(idx) = nodes.iter().position(|&node| node == rect) { + return idx; + } + let idx = nodes.len(); + nodes.push(rect); + adjacency.push(vec![]); + idx } fn push_non_empty_segment(route: &mut Vec<(Rect, Rect)>, from: Rect, to: Rect) { @@ -977,6 +1062,41 @@ mod tests { ); } + #[test] + fn phased_route_remembers_original_path_after_retarget() { + let state = AnimationState::default(); + let id = NodeId(1); + let a = Rect::new_sized_saturating(0, 0, 100, 100); + let b = Rect::new_sized_saturating(0, 0, 100, 50); + let c = Rect::new_sized_saturating(100, 0, 100, 50); + assert!(state.set_phased_target( + id, + vec![(a, b), (b, c)], + None, + 0, + 100, + AnimationCurve::Linear + )); + + let current = lerp_rect(b, c, 0.5); + let reverse = state.phased_route_to(id, a, 150_000_000).unwrap(); + assert_eq!(reverse, vec![(current, b), (b, a)]); + assert!(state.set_phased_target( + id, + reverse, + None, + 150_000_000, + 100, + AnimationCurve::Linear + )); + + let current = lerp_rect(current, b, 0.5); + assert_eq!( + state.phased_route_to(id, c, 200_000_000).unwrap(), + vec![(current, b), (b, c)] + ); + } + #[test] fn linear_retarget_interrupts_phased_animation_from_current_rect() { let state = AnimationState::default(); diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index b4397c2a..74fde909 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -1,6 +1,6 @@ use {crate::rect::Rect, crate::tree::NodeId}; -const MIN_SHRINK_DENOMINATOR: i32 = 4; +const MIN_SHRINK_DENOMINATOR: i32 = 8; #[derive(Clone, Debug)] pub struct MultiphaseRequest { @@ -2198,6 +2198,23 @@ mod tests { assert!(validate_plan_continuous(&req, &planned.plan)); } + #[test] + fn single_window_one_axis_group_is_still_multiphase_plannable() { + let req = request(vec![window(1, rect(0, 0, 100, 100), rect(40, 0, 140, 100))]); + let planned = plan_no_overlap_explained(&req).unwrap(); + + assert_eq!(planned.explanation.strategy, PlanStrategy::SingleAction); + assert_eq!( + planned.plan.phases[0].action, + MultiphasePhaseAction::Uniform(PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }) + ); + assert_eq!(planned.explanation.phases[0].nodes, vec![id(1)]); + assert!(validate_plan_continuous(&req, &planned.plan)); + } + #[test] fn mixed_single_phase_still_rejects_diagonal_per_window_motion() { let req = request(vec![ @@ -2424,6 +2441,61 @@ mod tests { assert!(validate_plan_continuous(&req, plan)); } + #[test] + fn three_child_stack_extraction_plans_without_linear_fallback() { + let old = split( + 10, + PhaseAxis::Horizontal, + &[1, 1], + vec![ + leaf(1), + split( + 11, + PhaseAxis::Vertical, + &[1, 1, 1], + vec![leaf(2), leaf(3), leaf(4)], + ), + ], + ); + let new = split( + 10, + PhaseAxis::Horizontal, + &[1, 1, 1], + vec![ + leaf(1), + leaf(3), + split(11, PhaseAxis::Vertical, &[1, 1], vec![leaf(2), leaf(4)]), + ], + ); + let req = generated_request(&old, &new, rect(0, 0, 600, 300)); + let planned = plan_no_overlap_explained(&req).unwrap(); + + assert_eq!( + planned.explanation.strategy, + PlanStrategy::SpaceThenOrthogonalGrowth { + axis: PhaseAxis::Horizontal, + } + ); + assert_eq!( + actions(&planned.plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + ] + ); + assert!(validate_plan_continuous(&req, &planned.plan)); + } + #[test] fn validated_phase_paths_accept_interrupted_reverse_route() { let a_current = rect(50, 0, 150, 50); @@ -2793,7 +2865,7 @@ mod tests { MultiphasePlanFailure::ShrinkBound { axis: PhaseAxis::Horizontal, available: 10, - required: 100, + required: 50, } )); } diff --git a/src/renderer.rs b/src/renderer.rs index bb44e71d..38a38464 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -553,6 +553,7 @@ impl Renderer<'_> { return; } let bounds = self.base.scale_rect(body); + self.render_window_body_background(&bounds); self.stretch = if frame.source_body_size != body.size() { Some(self.base.scale_point(body.width(), body.height())) } else { @@ -573,6 +574,21 @@ impl Renderer<'_> { self.corner_radius = None; } + fn render_window_body_background(&mut self, bounds: &Rect) { + if bounds.is_empty() { + return; + } + let color = self.state.theme.colors.background.get(); + self.base.sync(); + self.base.fill_scaled_boxes( + slice::from_ref(bounds), + &color, + None, + &self.state.color_manager.srgb_gamma22().linear, + RenderIntent::Perceptual, + ); + } + fn render_retained_surface_scaled( &mut self, retained: &RetainedSurface, @@ -744,6 +760,9 @@ impl Renderer<'_> { } let body = visual_mb.move_(x, y); let body = self.base.scale_rect(body); + if !child.node.node_is_container() { + self.render_window_body_background(&body); + } let content = container .mono_content .get() @@ -818,6 +837,9 @@ impl Renderer<'_> { } let body = body.move_(x, y); let body = self.base.scale_rect(body); + if !child.node.node_is_container() { + self.render_window_body_background(&body); + } self.render_child_or_snapshot( &child.node, x + content.x1(), @@ -1087,6 +1109,7 @@ impl Renderer<'_> { let inner_cr = self.scale_corner_radius(cr.expanded_by(-(bw as f32))); self.corner_radius = Some(inner_cr); } + self.render_window_body_background(&scissor_body); self.render_child_or_snapshot(&child, body.x1(), body.y1(), Some(&scissor_body)); self.stretch = None; self.corner_radius = None; diff --git a/src/state.rs b/src/state.rs index 685af984..c0dbb837 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1663,9 +1663,6 @@ impl State { group: &[usize], now_nsec: u64, ) -> bool { - if group.len() < 2 { - return false; - } let request_windows: Vec<_> = group.iter().map(|&idx| windows[idx]).collect(); let Some(first) = request_windows.first() else { return false; diff --git a/src/tree/container.rs b/src/tree/container.rs index 37f1fa40..b8de7b25 100644 --- a/src/tree/container.rs +++ b/src/tree/container.rs @@ -2303,6 +2303,11 @@ impl ContainingNode for ContainerNode { } // log::info!("cnode_remove_child2"); self.rebuild_tab_bar(); + if self.state.animations.enabled.get() + && !self.state.suppress_animations_for_next_layout.get() + { + self.animate_next_layout.set(true); + } self.schedule_layout(); self.cancel_seat_ops(); self.child_removed.trigger(); From 313323888b1dd2c1d748f18b0b3df6ab08018039 Mon Sep 17 00:00:00 2001 From: atagen Date: Wed, 27 May 2026 13:26:15 +1000 Subject: [PATCH 35/47] Preserve rounded clipping for window body backgrounds --- src/renderer.rs | 52 +++++++++++++++++++++++++++---------------------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/src/renderer.rs b/src/renderer.rs index 38a38464..b80e3f18 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -552,13 +552,6 @@ impl Renderer<'_> { if body.is_empty() { return; } - let bounds = self.base.scale_rect(body); - self.render_window_body_background(&bounds); - self.stretch = if frame.source_body_size != body.size() { - Some(self.base.scale_point(body.width(), body.height())) - } else { - None - }; if inset > 0 && !self.state.theme.corner_radius.get().is_zero() { let inner_cr = self.scale_corner_radius( self.state @@ -569,24 +562,37 @@ impl Renderer<'_> { ); self.corner_radius = Some(inner_cr); } + self.render_window_body_background(body); + let bounds = self.base.scale_rect(body); + self.stretch = if frame.source_body_size != body.size() { + Some(self.base.scale_point(body.width(), body.height())) + } else { + None + }; self.render_retained_toplevel(&frame.retained, body.x1(), body.y1(), Some(&bounds)); self.stretch = None; self.corner_radius = None; } - fn render_window_body_background(&mut self, bounds: &Rect) { - if bounds.is_empty() { + fn render_window_body_background(&mut self, body: Rect) { + if body.is_empty() { return; } let color = self.state.theme.colors.background.get(); + let srgb_srgb = self.state.color_manager.srgb_gamma22(); + let srgb = &srgb_srgb.linear; + let perceptual = RenderIntent::Perceptual; self.base.sync(); - self.base.fill_scaled_boxes( - slice::from_ref(bounds), - &color, - None, - &self.state.color_manager.srgb_gamma22().linear, - RenderIntent::Perceptual, - ); + if let Some(cr) = self.corner_radius + && !cr.is_zero() + { + self.base + .fill_rounded_rect(body, &color, None, srgb, perceptual, cr, 0.0); + } else { + let bounds = self.base.scale_rect(body); + self.base + .fill_scaled_boxes(slice::from_ref(&bounds), &color, None, srgb, perceptual); + } } fn render_retained_surface_scaled( @@ -759,10 +765,6 @@ impl Renderer<'_> { } } let body = visual_mb.move_(x, y); - let body = self.base.scale_rect(body); - if !child.node.node_is_container() { - self.render_window_body_background(&body); - } let content = container .mono_content .get() @@ -781,6 +783,10 @@ impl Renderer<'_> { self.corner_radius = Some(inner_cr); } } + if !child.node.node_is_container() { + self.render_window_body_background(body); + } + let body = self.base.scale_rect(body); self.render_child_or_snapshot( &child.node, x + content.x1(), @@ -836,10 +842,10 @@ impl Renderer<'_> { self.corner_radius = Some(inner_cr); } let body = body.move_(x, y); - let body = self.base.scale_rect(body); if !child.node.node_is_container() { - self.render_window_body_background(&body); + self.render_window_body_background(body); } + let body = self.base.scale_rect(body); self.render_child_or_snapshot( &child.node, x + content.x1(), @@ -1109,7 +1115,7 @@ impl Renderer<'_> { let inner_cr = self.scale_corner_radius(cr.expanded_by(-(bw as f32))); self.corner_radius = Some(inner_cr); } - self.render_window_body_background(&scissor_body); + self.render_window_body_background(body); self.render_child_or_snapshot(&child, body.x1(), body.y1(), Some(&scissor_body)); self.stretch = None; self.corner_radius = None; From 6c133018aafa9d8af2526a5efefaffa58b4b93f3 Mon Sep 17 00:00:00 2001 From: atagen Date: Wed, 27 May 2026 13:36:46 +1000 Subject: [PATCH 36/47] Handle uneven swap lanes and clearance grouping --- src/animation/multiphase.rs | 136 +++++++++++++++++++++++++++++++++--- src/state.rs | 2 +- 2 files changed, 127 insertions(+), 11 deletions(-) diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index 74fde909..31c406b5 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -448,7 +448,11 @@ pub(crate) fn validate_phase_paths( .map_err(MultiphasePlanFailure::Validation) } -pub(crate) fn partition_motion_groups(windows: &[MultiphaseWindow]) -> Vec> { +pub(crate) fn partition_motion_groups( + windows: &[MultiphaseWindow], + clearance: i32, +) -> Vec> { + let clearance = clearance.max(0); let mut groups = vec![]; let mut seen = vec![false; windows.len()]; for start in 0..windows.len() { @@ -460,9 +464,11 @@ pub(crate) fn partition_motion_groups(windows: &[MultiphaseWindow]) -> Vec 0 { @@ -875,9 +881,11 @@ fn plan_axis_crossing_lanes( main_start(window.to, axis), main_end(window.to, axis), ); + let lane_move = crossing_lane_move_rect(lane_from, window.to, axis); push_step(&mut phase1, window.node_id, window.from, lane_from); - push_step(&mut phase2, window.node_id, lane_from, lane_to); - push_step(&mut phase3, window.node_id, lane_to, window.to); + push_step(&mut phase2, window.node_id, lane_from, lane_move); + push_step(&mut phase3, window.node_id, lane_move, lane_to); + push_step(&mut phase4, window.node_id, lane_to, window.to); if idx + 1 < windows.len() { lane_start = lane_end + clearance; } @@ -902,14 +910,31 @@ fn plan_axis_crossing_lanes( ), phase_draft( PhaseKind::Scale, - axis.other(), + axis, phase3, + PhaseReason::SameAxisRedistribution, + ), + phase_draft( + PhaseKind::Scale, + axis.other(), + phase4, PhaseReason::GrowOutOfLanes, ), ], ) } +fn crossing_lane_move_rect(from: Rect, target: Rect, axis: PhaseAxis) -> Rect { + let size = main_size(from, axis); + if main_start(target, axis) > main_start(from, axis) { + let end = main_end(target, axis); + with_main_interval(from, axis, end - size, end) + } else { + let start = main_start(target, axis); + with_main_interval(from, axis, start, start + size) + } +} + fn lane_index_for_direction(window: MultiphaseWindow, axis: PhaseAxis) -> Option { let delta = main_start(window.to, axis) - main_start(window.from, axis); match delta.cmp(&0) { @@ -1535,6 +1560,16 @@ fn motion_bounds(window: MultiphaseWindow) -> Rect { window.from.union(window.to) } +fn motion_bounds_with_clearance(window: MultiphaseWindow, clearance: i32) -> Rect { + let bounds = motion_bounds(window); + Rect::new_saturating( + bounds.x1().saturating_sub(clearance), + bounds.y1().saturating_sub(clearance), + bounds.x2().saturating_add(clearance), + bounds.y2().saturating_add(clearance), + ) +} + fn interval_changed(from: Rect, to: Rect, axis: PhaseAxis) -> bool { main_start(from, axis) != main_start(to, axis) || main_end(from, axis) != main_end(to, axis) } @@ -2055,6 +2090,67 @@ mod tests { assert!(validate_plan_continuous(&req, &planned.plan)); } + #[test] + fn uneven_swap_lanes_split_move_and_same_axis_scale() { + let req = request(vec![ + window(1, rect(0, 0, 101, 100), rect(101, 0, 201, 100)), + window(2, rect(101, 0, 201, 100), rect(0, 0, 101, 100)), + ]); + let planned = plan_no_overlap_explained(&req).unwrap(); + let plan = &planned.plan; + + assert_eq!( + planned.explanation.strategy, + PlanStrategy::SwapLanes { + axis: PhaseAxis::Horizontal, + } + ); + assert_eq!( + actions(plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + ] + ); + assert_eq!(step_to(plan, 1, id(1)), rect(100, 0, 201, 50)); + assert_eq!(step_to(plan, 1, id(2)), rect(0, 50, 100, 100)); + assert_eq!(step_to(plan, 2, id(1)), rect(101, 0, 201, 50)); + assert_eq!(step_to(plan, 2, id(2)), rect(0, 50, 101, 100)); + assert!(validate_plan_continuous(&req, plan)); + } + + #[test] + fn uneven_swap_lanes_tolerate_stationary_sibling_in_odd_layout() { + let req = request(vec![ + window(1, rect(0, 0, 101, 100), rect(101, 0, 201, 100)), + window(2, rect(101, 0, 201, 100), rect(0, 0, 101, 100)), + window(3, rect(201, 0, 300, 100), rect(201, 0, 300, 100)), + ]); + let planned = plan_no_overlap_explained(&req).unwrap(); + + assert_eq!( + planned.explanation.strategy, + PlanStrategy::SwapLanes { + axis: PhaseAxis::Horizontal, + } + ); + assert!(validate_plan_continuous(&req, &planned.plan)); + } + #[test] fn vertical_swap_lanes_follow_motion_direction_not_node_id() { let req = request(vec![ @@ -3118,7 +3214,7 @@ mod tests { hierarchy: Default::default(), }, ]; - assert_eq!(partition_motion_groups(&windows), vec![vec![0, 1], vec![2]]); + assert_eq!(partition_motion_groups(&windows, 0), vec![vec![0, 1], vec![2]]); } #[test] @@ -3143,6 +3239,26 @@ mod tests { hierarchy: Default::default(), }, ]; - assert_eq!(partition_motion_groups(&windows), vec![vec![0, 1, 2]]); + assert_eq!(partition_motion_groups(&windows, 0), vec![vec![0, 1, 2]]); + } + + #[test] + fn motion_groups_join_across_animation_clearance() { + let windows = vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 100, 100), + to: rect(0, 0, 80, 100), + hierarchy: Default::default(), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(120, 0, 220, 100), + to: rect(110, 0, 210, 100), + hierarchy: Default::default(), + }, + ]; + assert_eq!(partition_motion_groups(&windows, 0), vec![vec![0], vec![1]]); + assert_eq!(partition_motion_groups(&windows, 10), vec![vec![0, 1]]); } } diff --git a/src/state.rs b/src/state.rs index c0dbb837..b32cfd30 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1640,7 +1640,7 @@ impl State { ) }) .collect(); - for group in partition_motion_groups(&windows) { + for group in partition_motion_groups(&windows, self.layout_animation_clearance()) { if self.start_multiphase_layout_animation(&candidates, &windows, &group, now) { continue; } From 502a93a00a8f879f7b87cf0d69c3f8875a2b1d23 Mon Sep 17 00:00:00 2001 From: atagen Date: Wed, 27 May 2026 22:08:09 +1000 Subject: [PATCH 37/47] Use live content for normal animations --- src/animation.rs | 49 +++++++++++++++++--- src/animation/multiphase.rs | 92 +++++++++++++++++++++++++++++++------ src/state.rs | 25 +++------- src/tree/float.rs | 6 +-- src/tree/toplevel.rs | 14 ++---- 5 files changed, 134 insertions(+), 52 deletions(-) diff --git a/src/animation.rs b/src/animation.rs index f0721562..d31ec6a5 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -290,7 +290,7 @@ impl AnimationState { node_id: NodeId, old: Rect, new: Rect, - retained: Option>, + _retained: Option>, now_nsec: u64, duration_ms: u32, curve: AnimationCurve, @@ -302,7 +302,6 @@ impl AnimationState { } let duration_nsec = duration_ms as u64 * 1_000_000; let mut from = old; - let mut retained = retained; { let phased = self.phased.borrow(); if let Some(anim) = phased.get(&node_id) { @@ -310,7 +309,6 @@ impl AnimationState { return false; } from = anim.rect_at(now_nsec); - retained = anim.retained.clone().or(retained); } } { @@ -320,7 +318,6 @@ impl AnimationState { return false; } from = anim.rect_at(now_nsec); - retained = anim.retained.clone().or(retained); } } if from == new { @@ -338,7 +335,7 @@ impl AnimationState { duration_nsec, curve, last_damage: from, - retained, + retained: None, }, ); true @@ -348,7 +345,7 @@ impl AnimationState { &self, node_id: NodeId, phases: Vec<(Rect, Rect)>, - retained: Option>, + _retained: Option>, now_nsec: u64, duration_ms: u32, curve: AnimationCurve, @@ -388,7 +385,7 @@ impl AnimationState { last_damage: from, final_rect, route_edges, - retained, + retained: None, }, ); true @@ -994,6 +991,44 @@ mod tests { assert!(state.exit_frames(160_000_000).is_empty()); } + #[test] + fn normal_window_animations_do_not_retain_content() { + let state = AnimationState::default(); + let id = NodeId(1); + let from = Rect::new_sized_saturating(0, 0, 100, 100); + let to = Rect::new_sized_saturating(100, 0, 100, 100); + assert!(state.set_target( + id, + from, + to, + Some(retained_for_tests()), + 0, + 160, + AnimationCurve::Linear + )); + + assert!(state.retained_snapshot(id, 80_000_000).is_none()); + } + + #[test] + fn phased_window_animations_do_not_retain_content() { + let state = AnimationState::default(); + let id = NodeId(1); + let a = Rect::new_sized_saturating(0, 0, 100, 100); + let b = Rect::new_sized_saturating(0, 0, 100, 50); + let c = Rect::new_sized_saturating(100, 0, 100, 50); + assert!(state.set_phased_target( + id, + vec![(a, b), (b, c)], + Some(retained_for_tests()), + 0, + 100, + AnimationCurve::Linear + )); + + assert!(state.retained_snapshot(id, 50_000_000).is_none()); + } + #[test] fn phased_animation_uses_full_duration_per_phase() { let state = AnimationState::default(); diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index 31c406b5..1b4e83b8 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -809,7 +809,7 @@ fn plan_axis_crossing_lanes( .copied() .filter(|window| window.from != window.to) .collect(); - if moving_windows.len() != 2 { + if moving_windows.len() < 2 { return Err(MultiphasePlanFailure::NoPattern); } let orth_min = request @@ -855,12 +855,7 @@ fn plan_axis_crossing_lanes( } let mut windows = moving_windows; - windows.sort_by_key(|window| lane_index_for_direction(*window, axis)); - if windows.windows(2).any(|pair| { - lane_index_for_direction(pair[0], axis) == lane_index_for_direction(pair[1], axis) - }) { - return Err(MultiphasePlanFailure::NoPattern); - } + windows.sort_by_key(|window| lane_sort_key(*window, axis)); let mut phase1 = vec![]; let mut phase2 = vec![]; let mut phase3 = vec![]; @@ -935,13 +930,19 @@ fn crossing_lane_move_rect(from: Rect, target: Rect, axis: PhaseAxis) -> Rect { } } -fn lane_index_for_direction(window: MultiphaseWindow, axis: PhaseAxis) -> Option { +fn lane_sort_key(window: MultiphaseWindow, axis: PhaseAxis) -> (usize, i32, i32, u32) { let delta = main_start(window.to, axis) - main_start(window.from, axis); - match delta.cmp(&0) { - std::cmp::Ordering::Greater => Some(0), - std::cmp::Ordering::Less => Some(1), - std::cmp::Ordering::Equal => None, - } + let direction = match delta.cmp(&0) { + std::cmp::Ordering::Greater => 0, + std::cmp::Ordering::Less => 1, + std::cmp::Ordering::Equal => 2, + }; + ( + direction, + main_start(window.from, axis), + main_start(window.to, axis), + window.node_id.0, + ) } fn plan_space_then_orthogonal_growth( @@ -2151,6 +2152,41 @@ mod tests { assert!(validate_plan_continuous(&req, &planned.plan)); } + #[test] + fn horizontal_rotation_uses_crossing_lanes() { + let req = request(vec![ + window(1, rect(0, 0, 100, 100), rect(100, 0, 200, 100)), + window(2, rect(100, 0, 200, 100), rect(200, 0, 300, 100)), + window(3, rect(200, 0, 300, 100), rect(0, 0, 100, 100)), + ]); + let planned = plan_no_overlap_explained(&req).unwrap(); + + assert_eq!( + planned.explanation.strategy, + PlanStrategy::SwapLanes { + axis: PhaseAxis::Horizontal, + } + ); + assert_eq!( + actions(&planned.plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + ] + ); + assert!(validate_plan_continuous(&req, &planned.plan)); + } + #[test] fn vertical_swap_lanes_follow_motion_direction_not_node_id() { let req = request(vec![ @@ -2875,6 +2911,36 @@ mod tests { assert!(validate_plan_continuous(&req, &plan)); } + #[test] + fn vertical_stack_extraction_with_clearance_still_plans() { + let old = split( + 20, + PhaseAxis::Vertical, + &[1, 1], + vec![ + leaf(1), + split(21, PhaseAxis::Horizontal, &[1, 1], vec![leaf(2), leaf(3)]), + ], + ); + let new = split( + 20, + PhaseAxis::Vertical, + &[1, 2, 1], + vec![leaf(1), leaf(2), leaf(3)], + ); + let mut req = generated_request(&old, &new, rect(0, 0, 100, 400)); + req.clearance = 10; + let planned = plan_no_overlap_explained(&req).unwrap(); + + assert_eq!( + planned.explanation.strategy, + PlanStrategy::SpaceThenOrthogonalGrowth { + axis: PhaseAxis::Vertical, + } + ); + assert!(validate_plan_continuous(&req, &planned.plan)); + } + #[test] fn vertical_stack_extraction_reverse_replays_phases_in_reverse() { let req = request(vec![ diff --git a/src/state.rs b/src/state.rs index b32cfd30..bf806640 100644 --- a/src/state.rs +++ b/src/state.rs @@ -167,7 +167,6 @@ pub(crate) struct LayoutAnimationCandidate { node_id: NodeId, old: Rect, new: Rect, - retained: Option>, curve: AnimationCurve, hierarchy: MultiphaseWindowHierarchy, } @@ -1503,7 +1502,6 @@ impl State { node_id: NodeId, old: Rect, new: Rect, - retained: Option>, ) { let curve = self .layout_animation_curve_override @@ -1513,7 +1511,6 @@ impl State { node_id, old, new, - retained, curve, MultiphaseWindowHierarchy::default(), ); @@ -1524,14 +1521,13 @@ impl State { node_id: NodeId, old: Rect, new: Rect, - retained: Option>, hierarchy: MultiphaseWindowHierarchy, ) { let curve = self .layout_animation_curve_override .get() .unwrap_or_else(|| self.animations.curve.get()); - self.queue_layout_animation(node_id, old, new, retained, curve, hierarchy); + self.queue_layout_animation(node_id, old, new, curve, hierarchy); } pub fn queue_linear_layout_animation( @@ -1539,13 +1535,11 @@ impl State { node_id: NodeId, old: Rect, new: Rect, - retained: Option>, ) { self.queue_layout_animation( node_id, old, new, - retained, AnimationCurve::Linear, MultiphaseWindowHierarchy::default(), ); @@ -1556,7 +1550,6 @@ impl State { node_id: NodeId, old: Rect, new: Rect, - retained: Option>, curve: AnimationCurve, hierarchy: MultiphaseWindowHierarchy, ) { @@ -1583,7 +1576,6 @@ impl State { node_id, old, new, - retained, curve, hierarchy, }; @@ -1603,7 +1595,7 @@ impl State { candidate.node_id, candidate.old, candidate.new, - candidate.retained, + None, now_nsec, self.animations.duration_ms.get(), candidate.curve, @@ -1763,18 +1755,14 @@ impl State { if current != window.to { return false; } - let retained = self - .animations - .retained_snapshot(candidate.node_id, now_nsec) - .or_else(|| candidate.retained.clone()); - entries.push((candidate.clone(), phases, damage, retained)); + entries.push((candidate.clone(), phases, damage)); } let mut started_any = false; - for (candidate, phases, damage, retained) in entries { + for (candidate, phases, damage) in entries { if self.animations.set_phased_target( candidate.node_id, phases, - retained, + None, now_nsec, self.animations.duration_ms.get(), candidate.curve, @@ -1796,7 +1784,6 @@ impl State { self: &Rc, node_id: NodeId, target: Rect, - retained: Option>, ) { if !self.animations.enabled.get() || target.is_empty() { return; @@ -1806,7 +1793,7 @@ impl State { let started = self.animations.set_spawn_in( node_id, target, - retained, + None, now, self.animations.duration_ms.get(), self.animations.curve.get(), diff --git a/src/tree/float.rs b/src/tree/float.rs index f2f96681..a57c2b91 100644 --- a/src/tree/float.rs +++ b/src/tree/float.rs @@ -161,8 +161,7 @@ impl FloatNode { data.spawn_in_pending.get() && data.kind.is_app_window() && !data.is_fullscreen.get() }; if spawn_in_pending && self.visible.get() { - self.state - .queue_spawn_in_animation(self.id.into(), pos, None); + self.state.queue_spawn_in_animation(self.id.into(), pos); } let theme = &self.state.theme; let bw = theme.sizes.border_width.get(); @@ -401,7 +400,7 @@ impl FloatNode { fn queue_position_animation(&self, old_pos: Rect, new_pos: Rect) { self.state .clone() - .queue_tiled_animation(self.id.into(), old_pos, new_pos, None); + .queue_tiled_animation(self.id.into(), old_pos, new_pos); let Some(child) = self.child.get() else { return; }; @@ -409,7 +408,6 @@ impl FloatNode { child.node_id(), self.body_for_outer(old_pos), self.body_for_outer(new_pos), - child.tl_animation_snapshot(), ); } diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index 5c8c1351..312b4ac6 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -232,16 +232,13 @@ impl ToplevelNode for T { data.node_id, prev, *rect, - self.tl_animation_snapshot(), hierarchy, ); } if spawn_in_eligible { - data.state.clone().queue_spawn_in_animation( - data.node_id, - *rect, - self.tl_animation_snapshot(), - ); + data.state + .clone() + .queue_spawn_in_animation(data.node_id, *rect); } if spawn_in_eligible { data.spawn_in_pending.set(false); @@ -1273,14 +1270,13 @@ pub fn toplevel_set_floating(state: &Rc, tl: Rc, floati .animations .visual_rect(node_id, tl.node_absolute_position(), state.now_nsec()); let old_outer = float_outer_for_body(state, old_body); - let retained = tl.tl_animation_snapshot(); parent.cnode_remove_child2(&*tl, true); let (width, height) = data.float_size(&ws); let floater = state.map_floating(tl, width, height, &ws, None); let new_outer = floater.position.get(); let new_body = float_body_for_outer(state, new_outer); - state.queue_linear_layout_animation(floater.node_id(), old_outer, new_outer, None); - state.queue_linear_layout_animation(node_id, old_body, new_body, retained); + state.queue_linear_layout_animation(floater.node_id(), old_outer, new_outer); + state.queue_linear_layout_animation(node_id, old_body, new_body); } } From 02222d5189abf563206e8abbb7accb4e2b1264cf Mon Sep 17 00:00:00 2001 From: atagen Date: Wed, 27 May 2026 22:24:01 +1000 Subject: [PATCH 38/47] Coalesce layout animation candidates --- src/state.rs | 152 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/src/state.rs b/src/state.rs index bf806640..071a054d 100644 --- a/src/state.rs +++ b/src/state.rs @@ -171,6 +171,28 @@ pub(crate) struct LayoutAnimationCandidate { hierarchy: MultiphaseWindowHierarchy, } +fn coalesce_layout_animation_candidates( + candidates: Vec, +) -> Vec { + let mut merged: Vec = vec![]; + for candidate in candidates { + if let Some(existing) = merged + .iter_mut() + .find(|existing| existing.node_id == candidate.node_id) + { + existing.new = candidate.new; + existing.curve = candidate.curve; + existing.hierarchy = MultiphaseWindowHierarchy::new( + existing.hierarchy.source, + candidate.hierarchy.target, + ); + } else { + merged.push(candidate); + } + } + merged +} + pub struct State { pub pid: c::pid_t, pub kb_ctx: KbvmContext, @@ -1619,6 +1641,10 @@ impl State { let Some(candidates) = self.layout_animation_batch.borrow_mut().take() else { return; }; + let candidates = coalesce_layout_animation_candidates(candidates); + if candidates.is_empty() { + return; + } let now = self.now_nsec(); let windows: Vec<_> = candidates .iter() @@ -2431,6 +2457,132 @@ impl State { } } +#[cfg(test)] +mod tests { + use { + super::*, + crate::animation::multiphase::MultiphaseHierarchyPosition, + }; + + fn rect(x1: i32, y1: i32, x2: i32, y2: i32) -> Rect { + Rect::new_saturating(x1, y1, x2, y2) + } + + fn hierarchy( + source: MultiphaseHierarchyPosition, + target: MultiphaseHierarchyPosition, + ) -> MultiphaseWindowHierarchy { + MultiphaseWindowHierarchy::new(source, target) + } + + #[test] + fn layout_animation_candidates_coalesce_duplicate_nodes() { + let source = MultiphaseHierarchyPosition { + parent: Some(NodeId(10)), + depth: 2, + sibling_index: Some(1), + ..Default::default() + }; + let intermediate = MultiphaseHierarchyPosition { + parent: Some(NodeId(11)), + depth: 1, + sibling_index: Some(0), + ..Default::default() + }; + let target = MultiphaseHierarchyPosition { + parent: Some(NodeId(12)), + depth: 0, + sibling_index: Some(2), + ..Default::default() + }; + let second_source = MultiphaseHierarchyPosition { + parent: Some(NodeId(20)), + depth: 1, + sibling_index: Some(0), + ..Default::default() + }; + let second_target = MultiphaseHierarchyPosition { + parent: Some(NodeId(20)), + depth: 1, + sibling_index: Some(1), + ..Default::default() + }; + + let candidates = vec![ + LayoutAnimationCandidate { + node_id: NodeId(1), + old: rect(0, 0, 100, 100), + new: rect(0, 0, 80, 100), + curve: AnimationCurve::Linear, + hierarchy: hierarchy(source, intermediate), + }, + LayoutAnimationCandidate { + node_id: NodeId(2), + old: rect(100, 0, 200, 100), + new: rect(120, 0, 220, 100), + curve: AnimationCurve::Linear, + hierarchy: hierarchy(second_source, second_target), + }, + LayoutAnimationCandidate { + node_id: NodeId(1), + old: rect(0, 0, 80, 100), + new: rect(0, 0, 60, 100), + curve: AnimationCurve::from_config(4), + hierarchy: hierarchy(intermediate, target), + }, + ]; + + let merged = coalesce_layout_animation_candidates(candidates); + + assert_eq!(merged.len(), 2); + assert_eq!(merged[0].node_id, NodeId(1)); + assert_eq!(merged[0].old, rect(0, 0, 100, 100)); + assert_eq!(merged[0].new, rect(0, 0, 60, 100)); + assert_eq!(merged[0].curve, AnimationCurve::from_config(4)); + assert_eq!(merged[0].hierarchy, hierarchy(source, target)); + assert_eq!(merged[1].node_id, NodeId(2)); + assert_eq!(merged[1].old, rect(100, 0, 200, 100)); + assert_eq!(merged[1].new, rect(120, 0, 220, 100)); + assert_eq!(merged[1].hierarchy, hierarchy(second_source, second_target)); + } + + #[test] + fn layout_animation_candidates_keep_coalesced_layout_noops() { + let hierarchy = MultiphaseWindowHierarchy::default(); + let candidates = vec![ + LayoutAnimationCandidate { + node_id: NodeId(1), + old: rect(0, 0, 100, 100), + new: rect(0, 0, 80, 100), + curve: AnimationCurve::Linear, + hierarchy, + }, + LayoutAnimationCandidate { + node_id: NodeId(1), + old: rect(0, 0, 80, 100), + new: rect(0, 0, 100, 100), + curve: AnimationCurve::Linear, + hierarchy, + }, + LayoutAnimationCandidate { + node_id: NodeId(2), + old: rect(100, 0, 200, 100), + new: rect(120, 0, 220, 100), + curve: AnimationCurve::Linear, + hierarchy, + }, + ]; + + let merged = coalesce_layout_animation_candidates(candidates); + + assert_eq!(merged.len(), 2); + assert_eq!(merged[0].node_id, NodeId(1)); + assert_eq!(merged[0].old, rect(0, 0, 100, 100)); + assert_eq!(merged[0].new, rect(0, 0, 100, 100)); + assert_eq!(merged[1].node_id, NodeId(2)); + } +} + #[derive(Debug, Error)] pub enum ShmScreencopyError { #[error("There is no render context")] From e7f9a5cb09f47baf034f21826123a3fbf84d9759 Mon Sep 17 00:00:00 2001 From: atagen Date: Wed, 27 May 2026 22:52:06 +1000 Subject: [PATCH 39/47] Add animation style toggle --- docs/window-animations-plan.md | 10 +-- docs/window-animations-testing.md | 30 +++++++-- jay-config/src/_private/client.rs | 4 ++ jay-config/src/_private/ipc.rs | 3 + jay-config/src/lib.rs | 16 +++++ src/animation.rs | 18 +++++ src/compositor.rs | 1 + src/config/handler.rs | 7 ++ src/state.rs | 70 +++++++++++++++++++- toml-config/src/config.rs | 11 +++ toml-config/src/config/parsers/animations.rs | 6 +- toml-config/src/lib.rs | 12 +++- toml-spec/spec/spec.generated.json | 16 ++++- toml-spec/spec/spec.generated.md | 30 +++++++++ toml-spec/spec/spec.yaml | 24 +++++++ 15 files changed, 238 insertions(+), 20 deletions(-) diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index 2a051bec..7cb60d03 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -6,11 +6,11 @@ be handled deliberately. ## Accepted Decisions -- The first landed slice is linear interpolation only, disabled by default. +- The first landed slice is plain interpolation only, disabled by default. - Animation is presentation-only. Logical layout, input hit testing, focus, and Wayland configure state use final geometry immediately. - Pointer drag and resize initiated by the mouse or tablet do not animate. -- Linear animations restart only for windows whose destination changes. Other +- Plain animations restart only for windows whose destination changes. Other in-flight windows keep their existing timelines. - Spawn-in uses scale and position for newly mapped tiled and floating app windows. Layer-shell, overlay, override-redirect, and fullscreen surfaces do @@ -19,7 +19,7 @@ be handled deliberately. destroy. - Command-driven tile-to-float and float-to-tile transitions may animate. Protocol drag/drop paths do not. -- The no-overlap multiphase system is a separate phase after the linear path is +- The no-overlap multiphase system is a separate phase after the plain path is working and testable. - Content freezing will use retained per-surface texture references, not a full offscreen snapshot as the default design. @@ -34,7 +34,7 @@ be handled deliberately. below roughly one quarter of the relevant full size. The implementation may enforce a conservative sanity minimum, and pathological cases may fall back. - If the no-overlap planner cannot produce a legal sequence, only the affected - group should fall back to linear animation. This is expected to be rare for + group should fall back to plain animation. This is expected to be rare for valid tiling layouts. - When entering mono mode, the active child should animate to the mono geometry. Inactive siblings may snap invisible. Floats may overlap normally and do not @@ -285,6 +285,7 @@ Phase 1 should expose a disabled-by-default setting for: - enabled/disabled - duration +- style: `plain` or `multiphase` - curve preset or cubic bezier Initial TOML shape: @@ -293,6 +294,7 @@ Initial TOML shape: [animations] enabled = false duration-ms = 160 +style = "multiphase" curve = "ease-out" # or: curve = [0.25, 0.1, 0.25, 1.0] diff --git a/docs/window-animations-testing.md b/docs/window-animations-testing.md index 6fe30f63..f5bdd416 100644 --- a/docs/window-animations-testing.md +++ b/docs/window-animations-testing.md @@ -24,9 +24,24 @@ Relevant internal config hooks: - `SetAnimationsEnabled` - `SetAnimationDurationMs` +- `SetAnimationStyle` - `SetAnimationCurve` - `SetAnimationCubicBezier` +TOML example: + +```toml +[animations] +enabled = true +duration-ms = 600 +style = "multiphase" +curve = "ease-out" +``` + +Set `style = "plain"` to force ordinary one-step movement interpolation while +keeping the configured curve. `curve = "linear"` only changes easing; it does +not select the plain animation style. + Current curve IDs in code: - `0`: linear @@ -37,19 +52,20 @@ Current curve IDs in code: ## Enabling Multiphase Tests -There is currently no separate user-facing multiphase toggle. To exercise the -multiphase planner: +To exercise the multiphase planner: 1. Enable animations with `SetAnimationsEnabled`. 2. Set a slow duration with `SetAnimationDurationMs`, around `400-700ms`. -3. Use tiled layout commands that are wired through `State::with_layout_animations`. -4. Use layouts where at least two tiled windows change geometry in the same +3. Select `style = "multiphase"` in TOML, or call `SetAnimationStyle` with + `AnimationStyle::MULTIPHASE`. +4. Use tiled layout commands that are wired through `State::with_layout_animations`. +5. Use layouts where at least two tiled windows change geometry in the same container layout batch. The compositor then attempts multiphase planning automatically when the batched layout pass completes. If the planner proves a legal no-overlap sequence, that group uses phased animation. If it cannot prove one, only that motion group falls -back to ordinary linear animation. +back to ordinary plain animation. Good command families for multiphase testing: @@ -64,7 +80,7 @@ Good command families for multiphase testing: These paths should not be used as evidence of multiphase behavior: -- tile-to-float and float-to-tile, which deliberately use linear animation +- tile-to-float and float-to-tile, which deliberately use plain animation - command-driven floating move/resize, which may animate but can overlap - pointer or tablet drag/resize, which should not animate - spawn-in and spawn-out, which are single-phase and use the configured curve @@ -73,7 +89,7 @@ These paths should not be used as evidence of multiphase behavior: Useful debug signal: -- `falling back to linear layout animation for group ...` means the group entered +- `falling back to plain layout animation for group ...` means the group entered the multiphase gate but the planner rejected it. That is acceptable for unsupported patterns, but unexpected for the supported swap/extraction cases below. diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index 09a96527..71927bbc 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -1035,6 +1035,10 @@ impl ConfigClient { self.send(&ClientMessage::SetAnimationCurve { curve }); } + pub fn set_animation_style(&self, style: u32) { + self.send(&ClientMessage::SetAnimationStyle { style }); + } + pub fn set_animation_cubic_bezier(&self, x1: f32, y1: f32, x2: f32, y2: f32) { self.send(&ClientMessage::SetAnimationCubicBezier { x1, y1, x2, y2 }); } diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index f0c8aa67..e86e79ca 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -554,6 +554,9 @@ pub enum ClientMessage<'a> { SetAnimationCurve { curve: u32, }, + SetAnimationStyle { + style: u32, + }, SetAnimationCubicBezier { x1: f32, y1: f32, diff --git a/jay-config/src/lib.rs b/jay-config/src/lib.rs index fc8915ee..c95c6620 100644 --- a/jay-config/src/lib.rs +++ b/jay-config/src/lib.rs @@ -115,6 +115,15 @@ impl AnimationCurve { pub const EASE_IN_OUT: Self = Self(4); } +/// The presentation style used for tiled window movement animations. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct AnimationStyle(pub u32); + +impl AnimationStyle { + pub const PLAIN: Self = Self(0); + pub const MULTIPHASE: Self = Self(1); +} + /// Exits the compositor. pub fn quit() { get!().quit() @@ -320,6 +329,13 @@ pub fn set_animation_curve(curve: AnimationCurve) { get!().set_animation_curve(curve.0); } +/// Sets the presentation style used for tiled window movement animations. +/// +/// The default is [`AnimationStyle::MULTIPHASE`]. +pub fn set_animation_style(style: AnimationStyle) { + get!().set_animation_style(style.0); +} + /// Sets a custom cubic-bezier curve used by tiled window animations. /// /// `x1` and `x2` must be between `0.0` and `1.0`. The curve starts at `(0, 0)` diff --git a/src/animation.rs b/src/animation.rs index d31ec6a5..e76e030b 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -30,6 +30,22 @@ pub enum AnimationCurve { Piecewise(PiecewiseCurve), } +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum AnimationStyle { + Plain, + Multiphase, +} + +impl AnimationStyle { + pub fn from_config(value: u32) -> Option { + match value { + 0 => Some(Self::Plain), + 1 => Some(Self::Multiphase), + _ => None, + } + } +} + impl AnimationCurve { pub fn from_config(value: u32) -> Self { match value { @@ -129,6 +145,7 @@ pub struct AnimationState { pub enabled: Cell, pub duration_ms: Cell, pub curve: Cell, + pub style: Cell, windows: RefCell>, phased: RefCell>, exits: RefCell>, @@ -267,6 +284,7 @@ impl Default for AnimationState { enabled: Cell::new(false), duration_ms: Cell::new(DEFAULT_DURATION_MS), curve: Cell::new(AnimationCurve::from_config(3)), + style: Cell::new(AnimationStyle::Multiphase), windows: Default::default(), phased: Default::default(), exits: Default::default(), diff --git a/src/compositor.rs b/src/compositor.rs index fdb0f282..11f23808 100644 --- a/src/compositor.rs +++ b/src/compositor.rs @@ -364,6 +364,7 @@ fn start_compositor2( layout_animations_requested: Default::default(), layout_animations_active: Default::default(), layout_animation_curve_override: Default::default(), + layout_animation_style_override: Default::default(), layout_animation_batch: Default::default(), suppress_animations_for_next_layout: Default::default(), toplevels: Default::default(), diff --git a/src/config/handler.rs b/src/config/handler.rs index 88a64d1d..336da9ff 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -1005,6 +1005,12 @@ impl ConfigProxyHandler { self.state.set_animation_curve(curve); } + fn handle_set_animation_style(&self, style: u32) { + if !self.state.set_animation_style(style) { + log::warn!("Ignoring invalid animation style"); + } + } + fn handle_set_animation_cubic_bezier(&self, x1: f32, y1: f32, x2: f32, y2: f32) { if !self.state.set_animation_cubic_bezier(x1, y1, x2, y2) { log::warn!("Ignoring invalid animation cubic-bezier curve"); @@ -3249,6 +3255,7 @@ impl ConfigProxyHandler { self.handle_set_animation_duration_ms(duration_ms) } ClientMessage::SetAnimationCurve { curve } => self.handle_set_animation_curve(curve), + ClientMessage::SetAnimationStyle { style } => self.handle_set_animation_style(style), ClientMessage::SetAnimationCubicBezier { x1, y1, x2, y2 } => { self.handle_set_animation_cubic_bezier(x1, y1, x2, y2) } diff --git a/src/state.rs b/src/state.rs index 071a054d..607ffa6b 100644 --- a/src/state.rs +++ b/src/state.rs @@ -3,7 +3,8 @@ use { acceptor::Acceptor, allocator::BufferObject, animation::{ - AnimationCurve, AnimationState, AnimationTick, RetainedExitLayer, RetainedToplevel, + AnimationCurve, AnimationState, AnimationStyle, AnimationTick, RetainedExitLayer, + RetainedToplevel, expand_damage_rect, multiphase::{ MultiphaseRequest, MultiphaseWindow, MultiphaseWindowHierarchy, @@ -168,6 +169,7 @@ pub(crate) struct LayoutAnimationCandidate { old: Rect, new: Rect, curve: AnimationCurve, + style: AnimationStyle, hierarchy: MultiphaseWindowHierarchy, } @@ -182,6 +184,7 @@ fn coalesce_layout_animation_candidates( { existing.new = candidate.new; existing.curve = candidate.curve; + existing.style = candidate.style; existing.hierarchy = MultiphaseWindowHierarchy::new( existing.hierarchy.source, candidate.hierarchy.target, @@ -193,6 +196,15 @@ fn coalesce_layout_animation_candidates( merged } +fn layout_animation_group_uses_plain( + candidates: &[LayoutAnimationCandidate], + group: &[usize], +) -> bool { + group + .iter() + .any(|&idx| candidates[idx].style == AnimationStyle::Plain) +} + pub struct State { pub pid: c::pid_t, pub kb_ctx: KbvmContext, @@ -307,6 +319,7 @@ pub struct State { pub layout_animations_requested: Cell, pub layout_animations_active: Cell, pub layout_animation_curve_override: Cell>, + pub layout_animation_style_override: Cell>, pub(crate) layout_animation_batch: RefCell>>, pub suppress_animations_for_next_layout: Cell, pub toplevels: CopyHashMap>, @@ -1172,6 +1185,7 @@ impl State { self.layout_animations_requested.set(false); self.layout_animations_active.set(false); self.layout_animation_curve_override.set(None); + self.layout_animation_style_override.set(None); self.suppress_animations_for_next_layout.set(false); self.render_ctx_watchers.clear(); self.workspace_watchers.clear(); @@ -1599,6 +1613,10 @@ impl State { old, new, curve, + style: self + .layout_animation_style_override + .get() + .unwrap_or_else(|| self.animations.style.get()), hierarchy, }; if let Some(batch) = self.layout_animation_batch.borrow_mut().as_mut() { @@ -1659,6 +1677,12 @@ impl State { }) .collect(); for group in partition_motion_groups(&windows, self.layout_animation_clearance()) { + if layout_animation_group_uses_plain(&candidates, &group) { + for idx in group { + self.start_layout_animation_candidate(candidates[idx].clone(), now); + } + continue; + } if self.start_multiphase_layout_animation(&candidates, &windows, &group, now) { continue; } @@ -1701,7 +1725,7 @@ impl State { Ok(plan) => plan, Err(diagnostic) => { log::debug!( - "falling back to linear layout animation for group {:?}: {:?}", + "falling back to plain layout animation for group {:?}: {:?}", group, diagnostic ); @@ -1881,6 +1905,14 @@ impl State { .set(AnimationCurve::from_config(curve)); } + pub fn set_animation_style(&self, style: u32) -> bool { + let Some(style) = AnimationStyle::from_config(style) else { + return false; + }; + self.animations.style.set(style); + true + } + pub fn set_animation_cubic_bezier(&self, x1: f32, y1: f32, x2: f32, y2: f32) -> bool { let Some(curve) = AnimationCurve::from_cubic_bezier(x1, y1, x2, y2) else { return false; @@ -1904,10 +1936,14 @@ impl State { let prev_curve = self .layout_animation_curve_override .replace(Some(AnimationCurve::Linear)); + let prev_style = self + .layout_animation_style_override + .replace(Some(AnimationStyle::Plain)); let res = f(); self.layout_animations_requested.set(prev_requested); self.layout_animations_active.set(prev_active); self.layout_animation_curve_override.set(prev_curve); + self.layout_animation_style_override.set(prev_style); res } @@ -2475,6 +2511,28 @@ mod tests { MultiphaseWindowHierarchy::new(source, target) } + fn candidate(node_id: u32, style: AnimationStyle) -> LayoutAnimationCandidate { + LayoutAnimationCandidate { + node_id: NodeId(node_id), + old: rect(0, 0, 100, 100), + new: rect(100, 0, 200, 100), + curve: AnimationCurve::Linear, + style, + hierarchy: MultiphaseWindowHierarchy::default(), + } + } + + #[test] + fn plain_style_candidate_forces_group_plain() { + let candidates = vec![ + candidate(1, AnimationStyle::Multiphase), + candidate(2, AnimationStyle::Plain), + ]; + + assert!(!layout_animation_group_uses_plain(&candidates, &[0])); + assert!(layout_animation_group_uses_plain(&candidates, &[0, 1])); + } + #[test] fn layout_animation_candidates_coalesce_duplicate_nodes() { let source = MultiphaseHierarchyPosition { @@ -2514,6 +2572,7 @@ mod tests { old: rect(0, 0, 100, 100), new: rect(0, 0, 80, 100), curve: AnimationCurve::Linear, + style: AnimationStyle::Multiphase, hierarchy: hierarchy(source, intermediate), }, LayoutAnimationCandidate { @@ -2521,6 +2580,7 @@ mod tests { old: rect(100, 0, 200, 100), new: rect(120, 0, 220, 100), curve: AnimationCurve::Linear, + style: AnimationStyle::Multiphase, hierarchy: hierarchy(second_source, second_target), }, LayoutAnimationCandidate { @@ -2528,6 +2588,7 @@ mod tests { old: rect(0, 0, 80, 100), new: rect(0, 0, 60, 100), curve: AnimationCurve::from_config(4), + style: AnimationStyle::Plain, hierarchy: hierarchy(intermediate, target), }, ]; @@ -2539,6 +2600,7 @@ mod tests { assert_eq!(merged[0].old, rect(0, 0, 100, 100)); assert_eq!(merged[0].new, rect(0, 0, 60, 100)); assert_eq!(merged[0].curve, AnimationCurve::from_config(4)); + assert_eq!(merged[0].style, AnimationStyle::Plain); assert_eq!(merged[0].hierarchy, hierarchy(source, target)); assert_eq!(merged[1].node_id, NodeId(2)); assert_eq!(merged[1].old, rect(100, 0, 200, 100)); @@ -2555,6 +2617,7 @@ mod tests { old: rect(0, 0, 100, 100), new: rect(0, 0, 80, 100), curve: AnimationCurve::Linear, + style: AnimationStyle::Multiphase, hierarchy, }, LayoutAnimationCandidate { @@ -2562,6 +2625,7 @@ mod tests { old: rect(0, 0, 80, 100), new: rect(0, 0, 100, 100), curve: AnimationCurve::Linear, + style: AnimationStyle::Plain, hierarchy, }, LayoutAnimationCandidate { @@ -2569,6 +2633,7 @@ mod tests { old: rect(100, 0, 200, 100), new: rect(120, 0, 220, 100), curve: AnimationCurve::Linear, + style: AnimationStyle::Multiphase, hierarchy, }, ]; @@ -2579,6 +2644,7 @@ mod tests { assert_eq!(merged[0].node_id, NodeId(1)); assert_eq!(merged[0].old, rect(0, 0, 100, 100)); assert_eq!(merged[0].new, rect(0, 0, 100, 100)); + assert_eq!(merged[0].style, AnimationStyle::Plain); assert_eq!(merged[1].node_id, NodeId(2)); } } diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index 8b01c1f4..35aca02c 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -270,6 +270,7 @@ pub struct UiDrag { pub struct Animations { pub enabled: Option, pub duration_ms: Option, + pub style: Option, pub curve: Option, } @@ -678,3 +679,13 @@ fn custom_animation_curve_parses() { Some(AnimationCurveConfig::CubicBezier([0.25, 0.1, 0.25, 1.0])) ); } + +#[test] +fn animation_style_parses() { + let input = b" + [animations] + style = \"plain\" + "; + let config = parse_config(input, &Default::default(), |_| ()).unwrap(); + assert_eq!(config.animations.style.as_deref(), Some("plain")); +} diff --git a/toml-config/src/config/parsers/animations.rs b/toml-config/src/config/parsers/animations.rs index 938ba7b9..cc5cb439 100644 --- a/toml-config/src/config/parsers/animations.rs +++ b/toml-config/src/config/parsers/animations.rs @@ -3,7 +3,7 @@ use { config::{ AnimationCurveConfig, Animations, context::Context, - extractor::{Extractor, ExtractorError, bol, n32, opt, recover, val}, + extractor::{Extractor, ExtractorError, bol, n32, opt, recover, str, val}, parser::{DataType, ParseResult, Parser, UnexpectedDataType}, }, toml::{ @@ -44,9 +44,10 @@ impl Parser for AnimationsParser<'_> { table: &IndexMap, Spanned>, ) -> ParseResult { let mut ext = Extractor::new(self.0, span, table); - let (enabled, duration_ms, curve) = ext.extract(( + let (enabled, duration_ms, style, curve) = ext.extract(( recover(opt(bol("enabled"))), recover(opt(n32("duration-ms"))), + recover(opt(str("style"))), opt(val("curve")), ))?; let curve = match curve { @@ -56,6 +57,7 @@ impl Parser for AnimationsParser<'_> { Ok(Animations { enabled: enabled.despan(), duration_ms: duration_ms.despan(), + style: style.despan().map(|style| style.to_string()), curve, }) } diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index 605e1fd1..4dbf8e74 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -23,7 +23,7 @@ use { ahash::{AHashMap, AHashSet}, error_reporter::Report, jay_config::{ - AnimationCurve, Axis, + AnimationCurve, AnimationStyle, Axis, client::Client, config, config_dir, exec::{Command, set_env, unset_env}, @@ -38,8 +38,9 @@ use { keyboard::Keymap, logging::{clean_logs_older_than, set_log_level}, on_devices_enumerated, on_idle, on_unload, quit, reload, set_animation_cubic_bezier, - set_animation_curve, set_animation_duration_ms, set_animations_enabled, set_autotile, - set_color_management_enabled, set_corner_radius, set_default_workspace_capture, + set_animation_curve, set_animation_duration_ms, set_animation_style, + set_animations_enabled, set_autotile, set_color_management_enabled, set_corner_radius, + set_default_workspace_capture, set_explicit_sync_enabled, set_float_above_fullscreen, set_floating_titles, set_idle, set_idle_grace_period, set_middle_click_paste_enabled, set_show_bar, set_show_float_pin_icon, set_show_titles, set_tab_title_align, set_ui_drag_enabled, @@ -1652,6 +1653,11 @@ fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc set_animation_style(AnimationStyle::PLAIN), + "multiphase" => set_animation_style(AnimationStyle::MULTIPHASE), + style_name => log::warn!("Unknown animation style: {style_name}"), + } match config .animations .curve diff --git a/toml-spec/spec/spec.generated.json b/toml-spec/spec/spec.generated.json index b7b5ce2d..50cc8887 100644 --- a/toml-spec/spec/spec.generated.json +++ b/toml-spec/spec/spec.generated.json @@ -665,8 +665,16 @@ } ] }, + "AnimationStyle": { + "type": "string", + "description": "Describes a tiled window movement animation style.\n", + "enum": [ + "plain", + "multiphase" + ] + }, "Animations": { - "description": "Describes window animation settings.\n\n- Example:\n\n ```toml\n [animations]\n enabled = true\n duration-ms = 160\n curve = [0.25, 0.1, 0.25, 1.0]\n ```\n", + "description": "Describes window animation settings.\n\n- Example:\n\n ```toml\n [animations]\n enabled = true\n duration-ms = 160\n style = \"multiphase\"\n curve = [0.25, 0.1, 0.25, 1.0]\n ```\n", "type": "object", "properties": { "enabled": { @@ -677,6 +685,10 @@ "type": "integer", "description": "Sets the animation duration in milliseconds.\n\nThe default is `160`.\n" }, + "style": { + "description": "Sets the animation style used for tiled window movement animations.\n\nThe default is `multiphase`.\n", + "$ref": "#/$defs/AnimationStyle" + }, "curve": { "description": "Sets the animation curve.\n\nThe default is `ease-out`.\n", "$ref": "#/$defs/AnimationCurve" @@ -1129,7 +1141,7 @@ "$ref": "#/$defs/UiDrag" }, "animations": { - "description": "Configures window animations.\n\nAnimations are disabled by default.\n\n- Example:\n\n ```toml\n [animations]\n enabled = true\n duration-ms = 160\n curve = \"ease-out\"\n ```\n", + "description": "Configures window animations.\n\nAnimations are disabled by default.\n\n- Example:\n\n ```toml\n [animations]\n enabled = true\n duration-ms = 160\n style = \"multiphase\"\n curve = \"ease-out\"\n ```\n", "$ref": "#/$defs/Animations" }, "xwayland": { diff --git a/toml-spec/spec/spec.generated.md b/toml-spec/spec/spec.generated.md index a1c4ff29..a31a3767 100644 --- a/toml-spec/spec/spec.generated.md +++ b/toml-spec/spec/spec.generated.md @@ -987,6 +987,26 @@ be between `0` and `1`. Each element of this array should be a number. + +### `AnimationStyle` + +Describes a tiled window movement animation style. + +Values of this type should be strings. + +The string should have one of the following values: + +- `plain`: + + Uses a single interpolated movement from each window's current visual + rectangle to its destination rectangle. + +- `multiphase`: + + Uses the no-overlap multiphase planner for tiled window movement when a + supported plan exists. + + ### `Animations` @@ -998,6 +1018,7 @@ Describes window animation settings. [animations] enabled = true duration-ms = 160 + style = "multiphase" curve = [0.25, 0.1, 0.25, 1.0] ``` @@ -1023,6 +1044,14 @@ The table has the following fields: The numbers should be integers. +- `style` (optional): + + Sets the animation style used for tiled window movement animations. + + The default is `multiphase`. + + The value of this field should be a [AnimationStyle](#types-AnimationStyle). + - `curve` (optional): Sets the animation curve. @@ -2271,6 +2300,7 @@ The table has the following fields: [animations] enabled = true duration-ms = 160 + style = "multiphase" curve = "ease-out" ``` diff --git a/toml-spec/spec/spec.yaml b/toml-spec/spec/spec.yaml index 7d2abfb7..706c016a 100644 --- a/toml-spec/spec/spec.yaml +++ b/toml-spec/spec/spec.yaml @@ -2956,6 +2956,7 @@ Config: [animations] enabled = true duration-ms = 160 + style = "multiphase" curve = "ease-out" ``` xwayland: @@ -3682,6 +3683,7 @@ Animations: [animations] enabled = true duration-ms = 160 + style = "multiphase" curve = [0.25, 0.1, 0.25, 1.0] ``` fields: @@ -3700,6 +3702,13 @@ Animations: Sets the animation duration in milliseconds. The default is `160`. + style: + ref: AnimationStyle + required: false + description: | + Sets the animation style used for tiled window movement animations. + + The default is `multiphase`. curve: ref: AnimationCurve required: false @@ -3709,6 +3718,21 @@ Animations: The default is `ease-out`. +AnimationStyle: + kind: string + description: | + Describes a tiled window movement animation style. + values: + - value: plain + description: | + Uses a single interpolated movement from each window's current visual + rectangle to its destination rectangle. + - value: multiphase + description: | + Uses the no-overlap multiphase planner for tiled window movement when a + supported plan exists. + + AnimationCurve: kind: variable description: | From 09305ab026adfe24598d654a0e2034ff99a92dc9 Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 28 May 2026 10:57:36 +1000 Subject: [PATCH 40/47] Bridge interrupted phased retargets --- src/state.rs | 216 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 213 insertions(+), 3 deletions(-) diff --git a/src/state.rs b/src/state.rs index 607ffa6b..42dd909d 100644 --- a/src/state.rs +++ b/src/state.rs @@ -7,7 +7,8 @@ use { RetainedToplevel, expand_damage_rect, multiphase::{ - MultiphaseRequest, MultiphaseWindow, MultiphaseWindowHierarchy, + MultiphasePhase, MultiphasePlan, MultiphasePlanFailure, MultiphaseRequest, + MultiphaseWindow, MultiphaseWindowHierarchy, partition_motion_groups, plan_no_overlap_with_diagnostics, validate_phase_paths, }, spawn_in_start_rect, @@ -205,6 +206,56 @@ fn layout_animation_group_uses_plain( .any(|&idx| candidates[idx].style == AnimationStyle::Plain) } +fn bridged_retarget_plan( + request: &MultiphaseRequest, + candidates: &[LayoutAnimationCandidate], + group: &[usize], + bridge_paths: &[Vec<(Rect, Rect)>], + bridge_phase_count: usize, + follow_phases: &[MultiphasePhase], +) -> Result { + let mut paths = vec![]; + for (group_pos, &idx) in group.iter().enumerate() { + let candidate = &candidates[idx]; + let window = request.windows[group_pos]; + let Some(bridge_path) = bridge_paths.get(group_pos) else { + return Err(MultiphasePlanFailure::NoPattern); + }; + let mut path = bridge_path.clone(); + let mut current = path + .last() + .map(|(_, to)| *to) + .unwrap_or(window.from); + while path.len() < bridge_phase_count { + path.push((current, current)); + } + if current != candidate.old { + return Err(MultiphasePlanFailure::NoPattern); + } + for phase in follow_phases { + match phase + .steps + .iter() + .find(|step| step.node_id == candidate.node_id) + { + Some(step) => { + if step.from != current { + return Err(MultiphasePlanFailure::NoPattern); + } + path.push((step.from, step.to)); + current = step.to; + } + None => path.push((current, current)), + } + } + if current != window.to { + return Err(MultiphasePlanFailure::NoPattern); + } + paths.push(path); + } + validate_phase_paths(request, &paths) +} + pub struct State { pub pid: c::pid_t, pub kb_ctx: KbvmContext, @@ -1721,6 +1772,9 @@ impl State { if self.start_existing_phased_retarget(candidates, windows, group, &request, now_nsec) { return true; } + if self.start_bridged_phased_retarget(candidates, windows, group, &request, now_nsec) { + return true; + } let plan = match plan_no_overlap_with_diagnostics(&request) { Ok(plan) => plan, Err(diagnostic) => { @@ -1770,6 +1824,97 @@ impl State { self.start_multiphase_plan(candidates, windows, group, &plan.phases, now_nsec) } + fn start_bridged_phased_retarget( + self: &Rc, + candidates: &[LayoutAnimationCandidate], + windows: &[MultiphaseWindow], + group: &[usize], + request: &MultiphaseRequest, + now_nsec: u64, + ) -> bool { + let mut bridge_paths = vec![]; + let mut bridge_phase_count = 0; + let mut has_bridge = false; + for &idx in group { + let candidate = &candidates[idx]; + let window = windows[idx]; + if window.from == candidate.old { + bridge_paths.push(vec![]); + continue; + } + let Some(path) = + self.animations + .phased_route_to(candidate.node_id, candidate.old, now_nsec) + else { + return false; + }; + if !path.is_empty() { + has_bridge = true; + bridge_phase_count = bridge_phase_count.max(path.len()); + } + bridge_paths.push(path); + } + if !has_bridge { + return false; + } + + let settled_windows: Vec<_> = group + .iter() + .map(|&idx| { + let candidate = &candidates[idx]; + MultiphaseWindow::with_hierarchy( + candidate.node_id, + candidate.old, + candidate.new, + candidate.hierarchy, + ) + }) + .collect(); + let Some(first) = settled_windows.first() else { + return false; + }; + let mut bounds = first.from.union(first.to); + for window in &settled_windows[1..] { + bounds = bounds.union(window.from).union(window.to); + } + let settled_request = MultiphaseRequest { + bounds, + windows: settled_windows, + clearance: self.layout_animation_clearance(), + }; + let follow_plan = match plan_no_overlap_with_diagnostics(&settled_request) { + Ok(plan) => plan, + Err(diagnostic) => { + log::debug!( + "bridged phased retarget follow-up rejected for group {:?}: {:?}", + group, + diagnostic + ); + return false; + } + }; + let plan = match bridged_retarget_plan( + request, + candidates, + group, + &bridge_paths, + bridge_phase_count, + &follow_plan.phases, + ) { + Ok(plan) => plan, + Err(error) => { + log::debug!( + "bridged phased retarget rejected for group {:?}: {:?}", + group, + error + ); + return false; + } + }; + log::debug!("bridging active phased animation for group {:?}", group); + self.start_multiphase_plan(candidates, windows, group, &plan.phases, now_nsec) + } + fn start_multiphase_plan( self: &Rc, candidates: &[LayoutAnimationCandidate], @@ -2512,10 +2657,24 @@ mod tests { } fn candidate(node_id: u32, style: AnimationStyle) -> LayoutAnimationCandidate { + candidate_rects( + node_id, + rect(0, 0, 100, 100), + rect(100, 0, 200, 100), + style, + ) + } + + fn candidate_rects( + node_id: u32, + old: Rect, + new: Rect, + style: AnimationStyle, + ) -> LayoutAnimationCandidate { LayoutAnimationCandidate { node_id: NodeId(node_id), - old: rect(0, 0, 100, 100), - new: rect(100, 0, 200, 100), + old, + new, curve: AnimationCurve::Linear, style, hierarchy: MultiphaseWindowHierarchy::default(), @@ -2533,6 +2692,57 @@ mod tests { assert!(layout_animation_group_uses_plain(&candidates, &[0, 1])); } + #[test] + fn bridged_retarget_handles_second_rotation_interrupt() { + let a_left = rect(0, 0, 100, 100); + let c_mid = rect(100, 0, 200, 100); + let c_left = a_left; + let a_mid = c_mid; + let c_current = rect(150, 50, 250, 100); + let c_mid_lane = rect(100, 50, 200, 100); + let candidates = vec![ + candidate_rects(1, a_left, a_mid, AnimationStyle::Multiphase), + candidate_rects(3, c_mid, c_left, AnimationStyle::Multiphase), + ]; + let request = MultiphaseRequest { + bounds: rect(0, 0, 250, 100), + windows: vec![ + MultiphaseWindow::new(NodeId(1), a_left, a_mid), + MultiphaseWindow::new(NodeId(3), c_current, c_left), + ], + clearance: 0, + }; + let settled_request = MultiphaseRequest { + bounds: rect(0, 0, 200, 100), + windows: vec![ + MultiphaseWindow::new(NodeId(1), a_left, a_mid), + MultiphaseWindow::new(NodeId(3), c_mid, c_left), + ], + clearance: 0, + }; + let follow_plan = plan_no_overlap_with_diagnostics(&settled_request).unwrap(); + let bridge_paths = vec![vec![], vec![(c_current, c_mid_lane), (c_mid_lane, c_mid)]]; + + let plan = bridged_retarget_plan( + &request, + &candidates, + &[0, 1], + &bridge_paths, + 2, + &follow_plan.phases, + ) + .unwrap(); + + assert!(plan + .phases + .iter() + .any(|phase| phase.steps.iter().any(|step| step.node_id == NodeId(1)))); + assert!(plan + .phases + .iter() + .any(|phase| phase.steps.iter().any(|step| step.node_id == NodeId(3)))); + } + #[test] fn layout_animation_candidates_coalesce_duplicate_nodes() { let source = MultiphaseHierarchyPosition { From 158682757af48227ccff412f55d81b542b330a7a Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 28 May 2026 17:13:24 +1000 Subject: [PATCH 41/47] Skip tiny swap redistribution phases --- src/animation/multiphase.rs | 95 +++++++++++++++++++++++++++++++++---- 1 file changed, 85 insertions(+), 10 deletions(-) diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index 1b4e83b8..cb067241 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -1,6 +1,9 @@ use {crate::rect::Rect, crate::tree::NodeId}; const MIN_SHRINK_DENOMINATOR: i32 = 8; +// Integer split remainders can make swapped siblings differ by one pixel. Do +// not spend a full animation phase on that imperceptible bookkeeping step. +const SWAP_AXIS_SNAP_PX: i32 = 1; #[derive(Clone, Debug)] pub struct MultiphaseRequest { @@ -876,7 +879,10 @@ fn plan_axis_crossing_lanes( main_start(window.to, axis), main_end(window.to, axis), ); - let lane_move = crossing_lane_move_rect(lane_from, window.to, axis); + let mut lane_move = crossing_lane_move_rect(lane_from, window.to, axis); + if same_axis_delta_within(lane_move, lane_to, axis, SWAP_AXIS_SNAP_PX) { + lane_move = lane_to; + } push_step(&mut phase1, window.node_id, window.from, lane_from); push_step(&mut phase2, window.node_id, lane_from, lane_move); push_step(&mut phase3, window.node_id, lane_move, lane_to); @@ -897,12 +903,10 @@ fn plan_axis_crossing_lanes( lane_axis: axis.other(), }, ), - phase_draft( - PhaseKind::Move, - axis, + phase_draft_classified( phase2, PhaseReason::MoveThroughFreedSpace, - ), + )?, phase_draft( PhaseKind::Scale, axis, @@ -919,6 +923,17 @@ fn plan_axis_crossing_lanes( ) } +fn phase_draft_classified( + steps: Vec, + reason: PhaseReason, +) -> Result { + let actions = steps + .iter() + .map(|step| classify_step(*step).ok_or(MultiphasePlanFailure::NoPattern)) + .collect::, _>>()?; + Ok(phase_draft_mixed(steps, actions, reason)) +} + fn crossing_lane_move_rect(from: Rect, target: Rect, axis: PhaseAxis) -> Rect { let size = main_size(from, axis); if main_start(target, axis) > main_start(from, axis) { @@ -930,6 +945,12 @@ fn crossing_lane_move_rect(from: Rect, target: Rect, axis: PhaseAxis) -> Rect { } } +fn same_axis_delta_within(from: Rect, to: Rect, axis: PhaseAxis, max_delta: i32) -> bool { + let start_delta = (main_start(from, axis) - main_start(to, axis)).abs(); + let end_delta = (main_end(from, axis) - main_end(to, axis)).abs(); + start_delta.max(end_delta) <= max_delta +} + fn lane_sort_key(window: MultiphaseWindow, axis: PhaseAxis) -> (usize, i32, i32, u32) { let delta = main_start(window.to, axis) - main_start(window.from, axis); let direction = match delta.cmp(&0) { @@ -2092,7 +2113,7 @@ mod tests { } #[test] - fn uneven_swap_lanes_split_move_and_same_axis_scale() { + fn one_pixel_uneven_swap_lanes_fold_remainder_into_crossing_phase() { let req = request(vec![ window(1, rect(0, 0, 101, 100), rect(101, 0, 201, 100)), window(2, rect(101, 0, 201, 100), rect(0, 0, 101, 100)), @@ -2100,6 +2121,43 @@ mod tests { let planned = plan_no_overlap_explained(&req).unwrap(); let plan = &planned.plan; + assert_eq!( + planned.explanation.strategy, + PlanStrategy::SwapLanes { + axis: PhaseAxis::Horizontal, + } + ); + assert_eq!( + actions(plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + ] + ); + assert_eq!(step_to(plan, 1, id(1)), rect(101, 0, 201, 50)); + assert_eq!(step_to(plan, 1, id(2)), rect(0, 50, 101, 100)); + assert!(validate_plan_continuous(&req, plan)); + } + + #[test] + fn large_uneven_swap_lanes_split_move_and_same_axis_scale() { + let req = request(vec![ + window(1, rect(0, 0, 120, 100), rect(120, 0, 200, 100)), + window(2, rect(120, 0, 200, 100), rect(0, 0, 120, 100)), + ]); + let planned = plan_no_overlap_explained(&req).unwrap(); + let plan = &planned.plan; + assert_eq!( planned.explanation.strategy, PlanStrategy::SwapLanes { @@ -2127,10 +2185,10 @@ mod tests { }, ] ); - assert_eq!(step_to(plan, 1, id(1)), rect(100, 0, 201, 50)); - assert_eq!(step_to(plan, 1, id(2)), rect(0, 50, 100, 100)); - assert_eq!(step_to(plan, 2, id(1)), rect(101, 0, 201, 50)); - assert_eq!(step_to(plan, 2, id(2)), rect(0, 50, 101, 100)); + assert_eq!(step_to(plan, 1, id(1)), rect(80, 0, 200, 50)); + assert_eq!(step_to(plan, 1, id(2)), rect(0, 50, 80, 100)); + assert_eq!(step_to(plan, 2, id(1)), rect(120, 0, 200, 50)); + assert_eq!(step_to(plan, 2, id(2)), rect(0, 50, 120, 100)); assert!(validate_plan_continuous(&req, plan)); } @@ -2149,6 +2207,23 @@ mod tests { axis: PhaseAxis::Horizontal, } ); + assert_eq!( + actions(&planned.plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + ] + ); assert!(validate_plan_continuous(&req, &planned.plan)); } From ce14169d6bc482d3ea1ac7f19d81259ce365d939 Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 15:20:46 +1000 Subject: [PATCH 42/47] feat: add window animations --- jay-config/src/_private/client.rs | 20 + jay-config/src/_private/ipc.rs | 18 + jay-config/src/lib.rs | 57 + src/animation.rs | 1233 ++++++ src/animation/multiphase.rs | 3405 +++++++++++++++++ src/compositor.rs | 7 + src/config/handler.rs | 160 +- src/ifs/wl_seat.rs | 3 + src/ifs/wl_surface/commit_timeline.rs | 5 + src/ifs/wl_surface/x_surface.rs | 19 +- src/ifs/wl_surface/x_surface/xwindow.rs | 11 + src/ifs/wl_surface/xdg_surface.rs | 13 + .../wl_surface/xdg_surface/xdg_toplevel.rs | 16 + src/main.rs | 1 + src/renderer.rs | 367 +- src/state.rs | 886 ++++- src/tree/container.rs | 55 +- src/tree/float.rs | 71 +- src/tree/toplevel.rs | 217 +- src/tree/workspace.rs | 2 +- src/xwayland/xwm.rs | 1 + toml-config/src/config.rs | 38 + toml-config/src/config/parsers.rs | 1 + toml-config/src/config/parsers/animations.rs | 99 + toml-config/src/config/parsers/config.rs | 15 +- toml-config/src/lib.rs | 46 +- toml-spec/spec/spec.generated.json | 59 + toml-spec/spec/spec.generated.md | 138 +- toml-spec/spec/spec.yaml | 108 + 29 files changed, 6957 insertions(+), 114 deletions(-) create mode 100644 src/animation.rs create mode 100644 src/animation/multiphase.rs create mode 100644 toml-config/src/config/parsers/animations.rs diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index 8ef87476..71927bbc 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -1023,6 +1023,26 @@ impl ConfigClient { self.send(&ClientMessage::SetUiDragThreshold { threshold }); } + pub fn set_animations_enabled(&self, enabled: bool) { + self.send(&ClientMessage::SetAnimationsEnabled { enabled }); + } + + pub fn set_animation_duration_ms(&self, duration_ms: u32) { + self.send(&ClientMessage::SetAnimationDurationMs { duration_ms }); + } + + pub fn set_animation_curve(&self, curve: u32) { + self.send(&ClientMessage::SetAnimationCurve { curve }); + } + + pub fn set_animation_style(&self, style: u32) { + self.send(&ClientMessage::SetAnimationStyle { style }); + } + + pub fn set_animation_cubic_bezier(&self, x1: f32, y1: f32, x2: f32, y2: f32) { + self.send(&ClientMessage::SetAnimationCubicBezier { x1, y1, x2, y2 }); + } + pub fn set_color_management_enabled(&self, enabled: bool) { self.send(&ClientMessage::SetColorManagementEnabled { enabled }); } diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index acb5ad81..e86e79ca 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -545,6 +545,24 @@ pub enum ClientMessage<'a> { SetUiDragThreshold { threshold: i32, }, + SetAnimationsEnabled { + enabled: bool, + }, + SetAnimationDurationMs { + duration_ms: u32, + }, + SetAnimationCurve { + curve: u32, + }, + SetAnimationStyle { + style: u32, + }, + SetAnimationCubicBezier { + x1: f32, + y1: f32, + x2: f32, + y2: f32, + }, SetXScalingMode { mode: XScalingMode, }, diff --git a/jay-config/src/lib.rs b/jay-config/src/lib.rs index e25710f9..c95c6620 100644 --- a/jay-config/src/lib.rs +++ b/jay-config/src/lib.rs @@ -103,6 +103,27 @@ impl Axis { } } +/// The curve used for tiled window animations. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct AnimationCurve(pub u32); + +impl AnimationCurve { + pub const LINEAR: Self = Self(0); + pub const EASE: Self = Self(1); + pub const EASE_IN: Self = Self(2); + pub const EASE_OUT: Self = Self(3); + pub const EASE_IN_OUT: Self = Self(4); +} + +/// The presentation style used for tiled window movement animations. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct AnimationStyle(pub u32); + +impl AnimationStyle { + pub const PLAIN: Self = Self(0); + pub const MULTIPHASE: Self = Self(1); +} + /// Exits the compositor. pub fn quit() { get!().quit() @@ -287,6 +308,42 @@ pub fn set_ui_drag_threshold(threshold: i32) { get!().set_ui_drag_threshold(threshold); } +/// Enables or disables tiled window animations. +/// +/// The default is `false`. +pub fn set_animations_enabled(enabled: bool) { + get!().set_animations_enabled(enabled); +} + +/// Sets the duration of tiled window animations in milliseconds. +/// +/// The default is `160`. +pub fn set_animation_duration_ms(duration_ms: u32) { + get!().set_animation_duration_ms(duration_ms); +} + +/// Sets the curve used by tiled window animations. +/// +/// The default is [`AnimationCurve::EASE_OUT`]. +pub fn set_animation_curve(curve: AnimationCurve) { + get!().set_animation_curve(curve.0); +} + +/// Sets the presentation style used for tiled window movement animations. +/// +/// The default is [`AnimationStyle::MULTIPHASE`]. +pub fn set_animation_style(style: AnimationStyle) { + get!().set_animation_style(style.0); +} + +/// Sets a custom cubic-bezier curve used by tiled window animations. +/// +/// `x1` and `x2` must be between `0.0` and `1.0`. The curve starts at `(0, 0)` +/// and ends at `(1, 1)`. +pub fn set_animation_cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32) { + get!().set_animation_cubic_bezier(x1, y1, x2, y2); +} + /// Enables or disables the color-management protocol. /// /// The default is `false`. diff --git a/src/animation.rs b/src/animation.rs new file mode 100644 index 00000000..e76e030b --- /dev/null +++ b/src/animation.rs @@ -0,0 +1,1233 @@ +use { + crate::{ + cmm::{cmm_description::ColorDescription, cmm_render_intent::RenderIntent}, + gfx_api::{GfxTexture, SampleRect}, + ifs::wl_surface::{SurfaceBuffer, WlSurface}, + rect::Rect, + state::State, + theme::Color, + tree::{LatchListener, NodeId, OutputNode}, + utils::{clonecell::CloneCell, event_listener::EventListener}, + }, + ahash::AHashMap, + std::{ + cell::{Cell, RefCell}, + collections::VecDeque, + rc::{Rc, Weak}, + }, +}; + +pub mod multiphase; + +const DEFAULT_DURATION_MS: u32 = 160; +const CURVE_MAX_POINTS: usize = 33; +const CURVE_FLATNESS_EPSILON: f32 = 0.001; +const CURVE_MAX_DEPTH: u8 = 8; + +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum AnimationCurve { + Linear, + Piecewise(PiecewiseCurve), +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum AnimationStyle { + Plain, + Multiphase, +} + +impl AnimationStyle { + pub fn from_config(value: u32) -> Option { + match value { + 0 => Some(Self::Plain), + 1 => Some(Self::Multiphase), + _ => None, + } + } +} + +impl AnimationCurve { + pub fn from_config(value: u32) -> Self { + match value { + 0 => Self::Linear, + 1 => Self::from_cubic_bezier(0.25, 0.1, 0.25, 1.0).unwrap(), + 2 => Self::from_cubic_bezier(0.42, 0.0, 1.0, 1.0).unwrap(), + 4 => Self::from_cubic_bezier(0.42, 0.0, 0.58, 1.0).unwrap(), + _ => Self::from_cubic_bezier(0.0, 0.0, 0.58, 1.0).unwrap(), + } + } + + pub fn from_cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32) -> Option { + if !x1.is_finite() + || !y1.is_finite() + || !x2.is_finite() + || !y2.is_finite() + || !(0.0..=1.0).contains(&x1) + || !(0.0..=1.0).contains(&x2) + { + return None; + } + Some(Self::Piecewise(PiecewiseCurve::from_cubic_bezier( + x1, y1, x2, y2, + ))) + } + + fn sample(self, t: f64) -> f64 { + let t = t.clamp(0.0, 1.0); + match self { + Self::Linear => t, + Self::Piecewise(curve) => curve.sample(t as f32) as f64, + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct PiecewiseCurve { + len: u8, + points: [CurvePoint; CURVE_MAX_POINTS], +} + +impl PiecewiseCurve { + fn from_cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32) -> Self { + let mut points = Vec::with_capacity(CURVE_MAX_POINTS); + let p0 = cubic_bezier_point(x1, y1, x2, y2, 0.0); + let p1 = cubic_bezier_point(x1, y1, x2, y2, 1.0); + points.push(p0); + flatten_cubic_bezier(&mut points, (x1, y1, x2, y2), 0.0, p0, 1.0, p1, 0); + let mut array = [CurvePoint::default(); CURVE_MAX_POINTS]; + let len = points.len().min(CURVE_MAX_POINTS); + array[..len].copy_from_slice(&points[..len]); + Self { + len: len as u8, + points: array, + } + } + + fn sample(self, x: f32) -> f32 { + let len = self.len as usize; + if len <= 1 { + return x; + } + let points = &self.points[..len]; + if x <= points[0].x { + return points[0].y; + } + if x >= points[len - 1].x { + return points[len - 1].y; + } + let mut lo = 0; + let mut hi = len - 1; + while lo + 1 < hi { + let mid = (lo + hi) / 2; + if points[mid].x <= x { + lo = mid; + } else { + hi = mid; + } + } + let from = points[lo]; + let to = points[hi]; + if to.x <= from.x { + return to.y; + } + let t = (x - from.x) / (to.x - from.x); + from.y + (to.y - from.y) * t + } +} + +#[derive(Copy, Clone, Debug, Default, PartialEq)] +struct CurvePoint { + x: f32, + y: f32, +} + +pub struct AnimationState { + pub enabled: Cell, + pub duration_ms: Cell, + pub curve: Cell, + pub style: Cell, + windows: RefCell>, + phased: RefCell>, + exits: RefCell>, + tick: CloneCell>>, +} + +pub struct RetainedToplevel { + pub offset: (i32, i32), + pub surface: RetainedSurface, +} + +pub struct RetainedSurface { + pub offset: (i32, i32), + pub size: (i32, i32), + pub content: RetainedContent, + pub below: Vec, + pub above: Vec, +} + +pub enum RetainedContent { + Texture { + texture: Rc, + buffer: Rc, + source: SampleRect, + alpha: Option, + color_description: Rc, + render_intent: RenderIntent, + alpha_mode: crate::gfx_api::AlphaMode, + opaque: bool, + }, + Color { + color: Color, + alpha: Option, + color_description: Rc, + render_intent: RenderIntent, + }, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum RetainedExitLayer { + Tiled, + Floating, +} + +pub struct RetainedExitFrame { + pub rect: Rect, + pub retained: Rc, + pub frame_inset: i32, + pub source_body_size: (i32, i32), + pub active: bool, + pub layer: RetainedExitLayer, +} + +impl RetainedToplevel { + pub fn capture_surface(surface: &WlSurface, offset: (i32, i32)) -> Option> { + Some(Rc::new(Self { + offset, + surface: RetainedSurface::capture(surface, (0, 0))?, + })) + } +} + +impl RetainedSurface { + fn capture(surface: &WlSurface, offset: (i32, i32)) -> Option { + let buffer = surface.buffer.get()?; + buffer.buffer.buf.update_texture_or_log(surface, true); + let size = surface.buffer_abs_pos.get().size(); + let source = *surface.buffer_points_norm.borrow(); + let color_description = surface.color_description(); + let render_intent = surface.render_intent(); + let alpha_mode = surface.alpha_mode(); + let alpha = surface.alpha(); + let content = match buffer.buffer.buf.get_texture(surface) { + Some(texture) => RetainedContent::Texture { + opaque: surface.opaque(), + texture, + buffer, + source, + alpha, + color_description, + render_intent, + alpha_mode, + }, + None => { + let color = buffer.buffer.buf.color?; + RetainedContent::Color { + color: Color::from_u32( + color_description.eotf, + alpha_mode, + color[0], + color[1], + color[2], + color[3], + ), + alpha, + color_description, + render_intent, + } + } + }; + let mut below = vec![]; + let mut above = vec![]; + if let Some(children) = surface.children.borrow().as_deref() { + for child in children.below.iter() { + if child.pending.get() { + continue; + } + let pos = child.sub_surface.position.get(); + if let Some(surface) = Self::capture(&child.sub_surface.surface, pos) { + below.push(surface); + } + } + for child in children.above.iter() { + if child.pending.get() { + continue; + } + let pos = child.sub_surface.position.get(); + if let Some(surface) = Self::capture(&child.sub_surface.surface, pos) { + above.push(surface); + } + } + } + Some(Self { + offset, + size, + content, + below, + above, + }) + } +} + +impl Default for AnimationState { + fn default() -> Self { + Self { + enabled: Cell::new(false), + duration_ms: Cell::new(DEFAULT_DURATION_MS), + curve: Cell::new(AnimationCurve::from_config(3)), + style: Cell::new(AnimationStyle::Multiphase), + windows: Default::default(), + phased: Default::default(), + exits: Default::default(), + tick: Default::default(), + } + } +} + +impl AnimationState { + pub fn clear(&self) { + self.windows.borrow_mut().clear(); + self.phased.borrow_mut().clear(); + self.exits.borrow_mut().clear(); + if let Some(tick) = self.tick.take() { + tick.detach(); + } + } + + pub fn set_target( + &self, + node_id: NodeId, + old: Rect, + new: Rect, + _retained: Option>, + now_nsec: u64, + duration_ms: u32, + curve: AnimationCurve, + ) -> bool { + if old == new || new.is_empty() || duration_ms == 0 { + self.windows.borrow_mut().remove(&node_id); + self.phased.borrow_mut().remove(&node_id); + return false; + } + let duration_nsec = duration_ms as u64 * 1_000_000; + let mut from = old; + { + let phased = self.phased.borrow(); + if let Some(anim) = phased.get(&node_id) { + if anim.final_rect == new { + return false; + } + from = anim.rect_at(now_nsec); + } + } + { + let windows = self.windows.borrow(); + if let Some(anim) = windows.get(&node_id) { + if anim.to == new { + return false; + } + from = anim.rect_at(now_nsec); + } + } + if from == new { + self.windows.borrow_mut().remove(&node_id); + self.phased.borrow_mut().remove(&node_id); + return false; + } + self.phased.borrow_mut().remove(&node_id); + self.windows.borrow_mut().insert( + node_id, + WindowAnimation { + from, + to: new, + start_nsec: now_nsec, + duration_nsec, + curve, + last_damage: from, + retained: None, + }, + ); + true + } + + pub fn set_phased_target( + &self, + node_id: NodeId, + phases: Vec<(Rect, Rect)>, + _retained: Option>, + now_nsec: u64, + duration_ms: u32, + curve: AnimationCurve, + ) -> bool { + if phases.is_empty() || duration_ms == 0 { + return false; + } + let Some((from, _)) = phases.first().copied() else { + return false; + }; + let Some((_, final_rect)) = phases.last().copied() else { + return false; + }; + if from.is_empty() || final_rect.is_empty() || from == final_rect { + return false; + } + let segments: Vec<_> = phases + .into_iter() + .map(|(from, to)| PhasedSegment { from, to }) + .collect(); + let mut route_edges = route_edges_from_segments(&segments); + if let Some(anim) = self.phased.borrow().get(&node_id) + && !anim.done(now_nsec) + { + for &(from, to) in &anim.route_edges { + push_unique_route_edge(&mut route_edges, from, to); + } + } + self.windows.borrow_mut().remove(&node_id); + self.phased.borrow_mut().insert( + node_id, + PhasedWindowAnimation { + segments, + start_nsec: now_nsec, + duration_nsec: duration_ms as u64 * 1_000_000, + curve, + last_damage: from, + final_rect, + route_edges, + retained: None, + }, + ); + true + } + + pub fn set_spawn_in( + &self, + node_id: NodeId, + target: Rect, + retained: Option>, + now_nsec: u64, + duration_ms: u32, + curve: AnimationCurve, + ) -> bool { + let start = spawn_in_start_rect(target); + self.set_target( + node_id, + start, + target, + retained, + now_nsec, + duration_ms, + curve, + ) + } + + pub fn set_spawn_out( + &self, + from: Rect, + frame_inset: i32, + retained: Rc, + active: bool, + layer: RetainedExitLayer, + now_nsec: u64, + duration_ms: u32, + curve: AnimationCurve, + ) -> bool { + if from.is_empty() || duration_ms == 0 { + return false; + } + let to = spawn_in_start_rect(from); + if to == from { + return false; + } + let source_body_size = body_size_for_frame(from, frame_inset); + if source_body_size.0 <= 0 || source_body_size.1 <= 0 { + return false; + } + self.exits.borrow_mut().push(ExitAnimation { + from, + to, + start_nsec: now_nsec, + duration_nsec: duration_ms as u64 * 1_000_000, + curve, + last_damage: from, + retained, + frame_inset, + source_body_size, + active, + layer, + }); + true + } + + pub fn visual_rect(&self, node_id: NodeId, layout: Rect, now_nsec: u64) -> Rect { + let phased = self.phased.borrow(); + if let Some(anim) = phased.get(&node_id) + && !anim.done(now_nsec) + { + return anim.rect_at(now_nsec); + } + drop(phased); + let windows = self.windows.borrow(); + match windows.get(&node_id) { + Some(anim) if !anim.done(now_nsec) => anim.rect_at(now_nsec), + _ => layout, + } + } + + pub fn retained_snapshot( + &self, + node_id: NodeId, + now_nsec: u64, + ) -> Option> { + let phased = self.phased.borrow(); + if let Some(anim) = phased.get(&node_id) + && !anim.done(now_nsec) + { + return anim.retained.clone(); + } + drop(phased); + let windows = self.windows.borrow(); + match windows.get(&node_id) { + Some(anim) if !anim.done(now_nsec) => anim.retained.clone(), + _ => None, + } + } + + pub fn phased_route_to( + &self, + node_id: NodeId, + target: Rect, + now_nsec: u64, + ) -> Option> { + let phased = self.phased.borrow(); + let anim = phased.get(&node_id)?; + if anim.done(now_nsec) { + return None; + } + anim.route_to(target, now_nsec) + } + + pub fn exit_frames(&self, now_nsec: u64) -> Vec { + self.exits + .borrow() + .iter() + .filter(|exit| !exit.done(now_nsec)) + .map(|exit| RetainedExitFrame { + rect: exit.rect_at(now_nsec), + retained: exit.retained.clone(), + frame_inset: exit.frame_inset, + source_body_size: exit.source_body_size, + active: exit.active, + layer: exit.layer, + }) + .collect() + } + + fn damage_active(&self, state: &State, now_nsec: u64) -> bool { + let mut damages = vec![]; + let mut any_active = false; + { + let mut windows = self.windows.borrow_mut(); + windows.retain(|_, anim| { + let current = anim.rect_at(now_nsec); + let damage = anim.last_damage.union(current).union(anim.to); + damages.push(expand_damage_rect( + damage, + state.theme.sizes.border_width.get().max(0), + )); + anim.last_damage = current; + let active = !anim.done(now_nsec); + any_active |= active; + active + }); + self.phased.borrow_mut().retain(|_, anim| { + let current = anim.rect_at(now_nsec); + let damage = anim.last_damage.union(current).union(anim.final_rect); + damages.push(expand_damage_rect( + damage, + state.theme.sizes.border_width.get().max(0), + )); + anim.last_damage = current; + let active = !anim.done(now_nsec); + any_active |= active; + active + }); + self.exits.borrow_mut().retain_mut(|exit| { + let current = exit.rect_at(now_nsec); + let damage = exit.last_damage.union(current).union(exit.to); + damages.push(expand_damage_rect( + damage, + state.theme.sizes.border_width.get().max(0), + )); + exit.last_damage = current; + let active = !exit.done(now_nsec); + any_active |= active; + active + }); + } + for damage in damages { + state.damage(damage); + } + any_active + } + + pub(crate) fn tick_is_active(&self) -> bool { + self.tick.is_some() + } + + pub(crate) fn set_tick(&self, tick: Rc) { + self.tick.set(Some(tick)); + } + + pub(crate) fn clear_tick(&self) { + self.tick.take(); + } +} + +struct WindowAnimation { + from: Rect, + to: Rect, + start_nsec: u64, + duration_nsec: u64, + curve: AnimationCurve, + last_damage: Rect, + retained: Option>, +} + +impl WindowAnimation { + fn done(&self, now_nsec: u64) -> bool { + now_nsec.saturating_sub(self.start_nsec) >= self.duration_nsec + } + + fn rect_at(&self, now_nsec: u64) -> Rect { + if self.duration_nsec == 0 { + return self.to; + } + let elapsed = now_nsec.saturating_sub(self.start_nsec); + let t = (elapsed as f64 / self.duration_nsec as f64).clamp(0.0, 1.0); + let t = self.curve.sample(t); + lerp_rect(self.from, self.to, t) + } +} + +struct PhasedWindowAnimation { + segments: Vec, + start_nsec: u64, + duration_nsec: u64, + curve: AnimationCurve, + last_damage: Rect, + final_rect: Rect, + route_edges: Vec<(Rect, Rect)>, + retained: Option>, +} + +struct PhasedSegment { + from: Rect, + to: Rect, +} + +impl PhasedWindowAnimation { + fn done(&self, now_nsec: u64) -> bool { + let total_duration = self + .duration_nsec + .saturating_mul(self.segments.len() as u64); + now_nsec.saturating_sub(self.start_nsec) >= total_duration + } + + fn rect_at(&self, now_nsec: u64) -> Rect { + if self.duration_nsec == 0 { + return self.final_rect; + } + let elapsed = now_nsec.saturating_sub(self.start_nsec); + let phase = (elapsed / self.duration_nsec) as usize; + let Some(segment) = self.segments.get(phase) else { + return self.final_rect; + }; + let phase_elapsed = elapsed % self.duration_nsec; + let t = (phase_elapsed as f64 / self.duration_nsec as f64).clamp(0.0, 1.0); + let t = self.curve.sample(t); + lerp_rect(segment.from, segment.to, t) + } + + fn phase_at(&self, now_nsec: u64) -> Option { + if self.duration_nsec == 0 || self.segments.is_empty() { + return None; + } + let elapsed = now_nsec.saturating_sub(self.start_nsec); + let phase = (elapsed / self.duration_nsec) as usize; + (phase < self.segments.len()).then_some(phase) + } + + fn route_to(&self, target: Rect, now_nsec: u64) -> Option> { + let phase = self.phase_at(now_nsec)?; + let current = self.rect_at(now_nsec); + if current == target { + return Some(vec![]); + } + let segment = self.segments.get(phase)?; + route_through_edges(current, target, segment.from, segment.to, &self.route_edges) + } +} + +fn route_edges_from_segments(segments: &[PhasedSegment]) -> Vec<(Rect, Rect)> { + let mut edges = vec![]; + for segment in segments { + push_unique_route_edge(&mut edges, segment.from, segment.to); + } + edges +} + +fn push_unique_route_edge(edges: &mut Vec<(Rect, Rect)>, from: Rect, to: Rect) { + if from == to { + return; + } + if edges + .iter() + .any(|&(a, b)| (a == from && b == to) || (a == to && b == from)) + { + return; + } + edges.push((from, to)); +} + +fn route_through_edges( + current: Rect, + target: Rect, + current_from: Rect, + current_to: Rect, + known_edges: &[(Rect, Rect)], +) -> Option> { + let mut edges = known_edges.to_vec(); + push_unique_route_edge(&mut edges, current, current_from); + push_unique_route_edge(&mut edges, current, current_to); + rect_graph_route(current, target, &edges) +} + +fn rect_graph_route( + start: Rect, + target: Rect, + edges: &[(Rect, Rect)], +) -> Option> { + let mut nodes = vec![]; + let mut adjacency: Vec> = vec![]; + let start_idx = rect_graph_node(&mut nodes, &mut adjacency, start); + let target_idx = rect_graph_node(&mut nodes, &mut adjacency, target); + for &(from, to) in edges { + let from_idx = rect_graph_node(&mut nodes, &mut adjacency, from); + let to_idx = rect_graph_node(&mut nodes, &mut adjacency, to); + if !adjacency[from_idx].contains(&to_idx) { + adjacency[from_idx].push(to_idx); + } + if !adjacency[to_idx].contains(&from_idx) { + adjacency[to_idx].push(from_idx); + } + } + + let mut previous = vec![None; nodes.len()]; + let mut queue = VecDeque::from([start_idx]); + previous[start_idx] = Some(start_idx); + while let Some(idx) = queue.pop_front() { + if idx == target_idx { + break; + } + for &next in &adjacency[idx] { + if previous[next].is_none() { + previous[next] = Some(idx); + queue.push_back(next); + } + } + } + previous[target_idx]?; + + let mut reversed_nodes = vec![target_idx]; + let mut idx = target_idx; + while idx != start_idx { + idx = previous[idx]?; + reversed_nodes.push(idx); + } + reversed_nodes.reverse(); + + let mut route = vec![]; + for pair in reversed_nodes.windows(2) { + push_non_empty_segment(&mut route, nodes[pair[0]], nodes[pair[1]]); + } + Some(route) +} + +fn rect_graph_node(nodes: &mut Vec, adjacency: &mut Vec>, rect: Rect) -> usize { + if let Some(idx) = nodes.iter().position(|&node| node == rect) { + return idx; + } + let idx = nodes.len(); + nodes.push(rect); + adjacency.push(vec![]); + idx +} + +fn push_non_empty_segment(route: &mut Vec<(Rect, Rect)>, from: Rect, to: Rect) { + if from != to { + route.push((from, to)); + } +} + +struct ExitAnimation { + from: Rect, + to: Rect, + start_nsec: u64, + duration_nsec: u64, + curve: AnimationCurve, + last_damage: Rect, + retained: Rc, + frame_inset: i32, + source_body_size: (i32, i32), + active: bool, + layer: RetainedExitLayer, +} + +impl ExitAnimation { + fn done(&self, now_nsec: u64) -> bool { + now_nsec.saturating_sub(self.start_nsec) >= self.duration_nsec + } + + fn rect_at(&self, now_nsec: u64) -> Rect { + if self.duration_nsec == 0 { + return self.to; + } + let elapsed = now_nsec.saturating_sub(self.start_nsec); + let t = (elapsed as f64 / self.duration_nsec as f64).clamp(0.0, 1.0); + let t = self.curve.sample(t); + lerp_rect(self.from, self.to, t) + } +} + +pub struct AnimationTick { + state: Weak, + slf: Weak, + latch_listeners: RefCell>>, +} + +impl AnimationTick { + pub fn new(state: &Rc, slf: &Weak) -> Self { + let slf: Weak = slf.clone(); + Self { + state: Rc::downgrade(state), + slf, + latch_listeners: Default::default(), + } + } + + pub fn attach(&self, output: &OutputNode) { + let listener = EventListener::new(self.slf.clone()); + listener.attach(&output.latch_event); + self.latch_listeners.borrow_mut().push(listener); + } + + pub fn detach(&self) { + for listener in self.latch_listeners.borrow_mut().drain(..) { + listener.detach(); + } + } +} + +impl LatchListener for AnimationTick { + fn after_latch(self: Rc, _on: &OutputNode, _tearing: bool) { + let Some(state) = self.state.upgrade() else { + self.detach(); + return; + }; + let active = state.animations.damage_active(&state, state.now_nsec()); + if !active { + self.detach(); + state.animations.clear_tick(); + } + } +} + +pub(crate) fn spawn_in_start_rect(target: Rect) -> Rect { + let (cx, cy) = target.center(); + Rect::new_empty(cx, cy) +} + +fn body_size_for_frame(rect: Rect, frame_inset: i32) -> (i32, i32) { + ( + rect.width().saturating_sub(2 * frame_inset), + rect.height().saturating_sub(2 * frame_inset), + ) +} + +fn lerp_rect(from: Rect, to: Rect, t: f64) -> Rect { + fn lerp(from: i32, to: i32, t: f64) -> i32 { + (from as f64 + (to as f64 - from as f64) * t).round() as i32 + } + Rect::new_saturating( + lerp(from.x1(), to.x1(), t), + lerp(from.y1(), to.y1(), t), + lerp(from.x2(), to.x2(), t), + lerp(from.y2(), to.y2(), t), + ) +} + +pub(crate) fn expand_damage_rect(rect: Rect, width: i32) -> Rect { + Rect::new_saturating( + rect.x1().saturating_sub(width), + rect.y1().saturating_sub(width), + rect.x2().saturating_add(width), + rect.y2().saturating_add(width), + ) +} + +fn flatten_cubic_bezier( + points: &mut Vec, + controls: (f32, f32, f32, f32), + t0: f32, + p0: CurvePoint, + t1: f32, + p1: CurvePoint, + depth: u8, +) { + let tm = (t0 + t1) * 0.5; + let pm = cubic_bezier_point(controls.0, controls.1, controls.2, controls.3, tm); + let projected_y = if p1.x <= p0.x { + (p0.y + p1.y) * 0.5 + } else { + let tx = (pm.x - p0.x) / (p1.x - p0.x); + p0.y + (p1.y - p0.y) * tx + }; + if (pm.y - projected_y).abs() > CURVE_FLATNESS_EPSILON + && depth < CURVE_MAX_DEPTH + && points.len() + 2 < CURVE_MAX_POINTS + { + flatten_cubic_bezier(points, controls, t0, p0, tm, pm, depth + 1); + flatten_cubic_bezier(points, controls, tm, pm, t1, p1, depth + 1); + } else { + points.push(p1); + } +} + +fn cubic_bezier_point(x1: f32, y1: f32, x2: f32, y2: f32, t: f32) -> CurvePoint { + fn bezier(a: f32, b: f32, t: f32) -> f32 { + let inv = 1.0 - t; + 3.0 * inv * inv * t * a + 3.0 * inv * t * t * b + t * t * t + } + CurvePoint { + x: bezier(x1, x2, t), + y: bezier(y1, y2, t), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::cmm::cmm_manager::ColorManager; + + fn retained_for_tests() -> Rc { + let color_manager = ColorManager::new(); + Rc::new(RetainedToplevel { + offset: (0, 0), + surface: RetainedSurface { + offset: (0, 0), + size: (100, 100), + content: RetainedContent::Color { + color: Color::SOLID_BLACK, + alpha: None, + color_description: color_manager.srgb_gamma22().clone(), + render_intent: RenderIntent::Perceptual, + }, + below: vec![], + above: vec![], + }, + }) + } + + #[test] + fn linear_rect_interpolation_is_symmetric() { + let a = Rect::new_sized_saturating(0, 0, 100, 100); + let b = Rect::new_sized_saturating(100, 40, 200, 80); + assert_eq!(lerp_rect(a, b, 0.25), lerp_rect(b, a, 0.75)); + } + + #[test] + fn custom_cubic_bezier_curve_is_prepared() { + let curve = AnimationCurve::from_cubic_bezier(0.0, 0.0, 1.0, 1.0).unwrap(); + assert_eq!(curve.sample(0.0), 0.0); + assert_eq!(curve.sample(1.0), 1.0); + assert!((curve.sample(0.5) - 0.5).abs() < 0.001); + + let ease_out = AnimationCurve::from_cubic_bezier(0.0, 0.0, 0.58, 1.0).unwrap(); + let mid = ease_out.sample(0.5); + assert!(mid > 0.5); + assert!(mid < 1.0); + } + + #[test] + fn invalid_custom_cubic_bezier_curve_is_rejected() { + assert!(AnimationCurve::from_cubic_bezier(-0.1, 0.0, 0.58, 1.0).is_none()); + assert!(AnimationCurve::from_cubic_bezier(0.0, 0.0, 1.1, 1.0).is_none()); + assert!(AnimationCurve::from_cubic_bezier(0.0, f32::NAN, 0.58, 1.0).is_none()); + } + + #[test] + fn spawn_out_frames_use_configured_curve_and_expire() { + let state = AnimationState::default(); + let retained = retained_for_tests(); + let from = Rect::new_sized_saturating(10, 20, 100, 80); + let to = spawn_in_start_rect(from); + let curve = AnimationCurve::from_config(3); + assert!(state.set_spawn_out( + from, + 2, + retained.clone(), + true, + RetainedExitLayer::Floating, + 0, + 160, + curve + )); + + let start = state.exit_frames(0); + assert_eq!(start.len(), 1); + assert_eq!(start[0].rect, from); + assert_eq!(start[0].source_body_size, (96, 76)); + assert!(start[0].active); + assert_eq!(start[0].layer, RetainedExitLayer::Floating); + assert!(Rc::ptr_eq(&start[0].retained, &retained)); + + let middle = state.exit_frames(80_000_000); + assert_eq!(middle.len(), 1); + assert_eq!(middle[0].rect, lerp_rect(from, to, curve.sample(0.5))); + assert_ne!(middle[0].rect, lerp_rect(from, to, 0.5)); + assert!(state.exit_frames(160_000_000).is_empty()); + } + + #[test] + fn normal_window_animations_do_not_retain_content() { + let state = AnimationState::default(); + let id = NodeId(1); + let from = Rect::new_sized_saturating(0, 0, 100, 100); + let to = Rect::new_sized_saturating(100, 0, 100, 100); + assert!(state.set_target( + id, + from, + to, + Some(retained_for_tests()), + 0, + 160, + AnimationCurve::Linear + )); + + assert!(state.retained_snapshot(id, 80_000_000).is_none()); + } + + #[test] + fn phased_window_animations_do_not_retain_content() { + let state = AnimationState::default(); + let id = NodeId(1); + let a = Rect::new_sized_saturating(0, 0, 100, 100); + let b = Rect::new_sized_saturating(0, 0, 100, 50); + let c = Rect::new_sized_saturating(100, 0, 100, 50); + assert!(state.set_phased_target( + id, + vec![(a, b), (b, c)], + Some(retained_for_tests()), + 0, + 100, + AnimationCurve::Linear + )); + + assert!(state.retained_snapshot(id, 50_000_000).is_none()); + } + + #[test] + fn phased_animation_uses_full_duration_per_phase() { + let state = AnimationState::default(); + let id = NodeId(1); + let a = Rect::new_sized_saturating(0, 0, 100, 100); + let b = Rect::new_sized_saturating(0, 0, 100, 50); + let c = Rect::new_sized_saturating(100, 0, 100, 50); + assert!(state.set_phased_target( + id, + vec![(a, b), (b, c)], + None, + 0, + 100, + AnimationCurve::Linear + )); + assert_eq!(state.visual_rect(id, c, 0), a); + assert_eq!(state.visual_rect(id, c, 50_000_000), lerp_rect(a, b, 0.5)); + assert_eq!(state.visual_rect(id, c, 100_000_000), b); + assert_eq!(state.visual_rect(id, c, 150_000_000), lerp_rect(b, c, 0.5)); + assert_eq!(state.visual_rect(id, c, 200_000_000), c); + } + + #[test] + fn phased_route_reverses_to_existing_endpoint() { + let state = AnimationState::default(); + let id = NodeId(1); + let a = Rect::new_sized_saturating(0, 0, 100, 100); + let b = Rect::new_sized_saturating(0, 0, 100, 50); + let c = Rect::new_sized_saturating(100, 0, 100, 50); + assert!(state.set_phased_target( + id, + vec![(a, b), (b, c)], + None, + 0, + 100, + AnimationCurve::Linear + )); + + let current = lerp_rect(b, c, 0.5); + assert_eq!( + state.phased_route_to(id, a, 150_000_000).unwrap(), + vec![(current, b), (b, a)] + ); + } + + #[test] + fn phased_route_continues_to_existing_endpoint() { + let state = AnimationState::default(); + let id = NodeId(1); + let a = Rect::new_sized_saturating(0, 0, 100, 100); + let b = Rect::new_sized_saturating(0, 0, 100, 50); + let c = Rect::new_sized_saturating(100, 0, 100, 50); + assert!(state.set_phased_target( + id, + vec![(a, b), (b, c)], + None, + 0, + 100, + AnimationCurve::Linear + )); + + let current = lerp_rect(a, b, 0.5); + assert_eq!( + state.phased_route_to(id, c, 50_000_000).unwrap(), + vec![(current, b), (b, c)] + ); + } + + #[test] + fn phased_route_remembers_original_path_after_retarget() { + let state = AnimationState::default(); + let id = NodeId(1); + let a = Rect::new_sized_saturating(0, 0, 100, 100); + let b = Rect::new_sized_saturating(0, 0, 100, 50); + let c = Rect::new_sized_saturating(100, 0, 100, 50); + assert!(state.set_phased_target( + id, + vec![(a, b), (b, c)], + None, + 0, + 100, + AnimationCurve::Linear + )); + + let current = lerp_rect(b, c, 0.5); + let reverse = state.phased_route_to(id, a, 150_000_000).unwrap(); + assert_eq!(reverse, vec![(current, b), (b, a)]); + assert!(state.set_phased_target( + id, + reverse, + None, + 150_000_000, + 100, + AnimationCurve::Linear + )); + + let current = lerp_rect(current, b, 0.5); + assert_eq!( + state.phased_route_to(id, c, 200_000_000).unwrap(), + vec![(current, b), (b, c)] + ); + } + + #[test] + fn linear_retarget_interrupts_phased_animation_from_current_rect() { + let state = AnimationState::default(); + let id = NodeId(1); + let a = Rect::new_sized_saturating(0, 0, 100, 100); + let b = Rect::new_sized_saturating(0, 0, 100, 50); + let c = Rect::new_sized_saturating(100, 0, 100, 50); + let d = Rect::new_sized_saturating(100, 100, 100, 50); + assert!(state.set_phased_target( + id, + vec![(a, b), (b, c)], + None, + 0, + 100, + AnimationCurve::Linear + )); + let current = lerp_rect(a, b, 0.5); + assert!(state.set_target(id, a, d, None, 50_000_000, 100, AnimationCurve::Linear)); + assert_eq!(state.visual_rect(id, d, 50_000_000), current); + assert_eq!( + state.visual_rect(id, d, 100_000_000), + lerp_rect(current, d, 0.5) + ); + } + + #[test] + fn unchanged_target_does_not_restart() { + let state = AnimationState::default(); + let id = NodeId(1); + let a = Rect::new_sized_saturating(0, 0, 100, 100); + let b = Rect::new_sized_saturating(100, 0, 100, 100); + assert!(state.set_target(id, a, b, None, 0, 160, AnimationCurve::Linear)); + assert!(!state.set_target(id, a, b, None, 80_000_000, 160, AnimationCurve::Linear)); + assert_eq!( + state.visual_rect(id, b, 80_000_000), + Rect::new_sized_saturating(50, 0, 100, 100) + ); + } + + #[test] + fn changed_target_restarts_from_current_visual_rect() { + let state = AnimationState::default(); + let id = NodeId(1); + let a = Rect::new_sized_saturating(0, 0, 100, 100); + let b = Rect::new_sized_saturating(100, 0, 100, 100); + let c = Rect::new_sized_saturating(200, 0, 100, 100); + assert!(state.set_target(id, a, b, None, 0, 160, AnimationCurve::Linear)); + assert!(state.set_target(id, a, c, None, 80_000_000, 160, AnimationCurve::Linear)); + assert_eq!( + state.visual_rect(id, c, 80_000_000), + Rect::new_sized_saturating(50, 0, 100, 100) + ); + assert_eq!( + state.visual_rect(id, c, 160_000_000), + Rect::new_sized_saturating(125, 0, 100, 100) + ); + } + + #[test] + fn spawn_in_start_rect_is_centered_and_empty() { + let target = Rect::new_sized_saturating(10, 20, 100, 50); + assert_eq!(spawn_in_start_rect(target), Rect::new_empty(60, 45)); + } + + #[test] + fn spawn_in_uses_configured_curve() { + let state = AnimationState::default(); + let id = NodeId(1); + let target = Rect::new_sized_saturating(10, 20, 100, 50); + let curve = AnimationCurve::from_config(3); + assert!(state.set_spawn_in(id, target, None, 0, 160, curve)); + assert_eq!( + state.visual_rect(id, target, 80_000_000), + lerp_rect(spawn_in_start_rect(target), target, curve.sample(0.5)) + ); + assert_ne!( + state.visual_rect(id, target, 80_000_000), + Rect::new_sized_saturating(35, 33, 50, 25) + ); + } +} diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs new file mode 100644 index 00000000..cb067241 --- /dev/null +++ b/src/animation/multiphase.rs @@ -0,0 +1,3405 @@ +use {crate::rect::Rect, crate::tree::NodeId}; + +const MIN_SHRINK_DENOMINATOR: i32 = 8; +// Integer split remainders can make swapped siblings differ by one pixel. Do +// not spend a full animation phase on that imperceptible bookkeeping step. +const SWAP_AXIS_SNAP_PX: i32 = 1; + +#[derive(Clone, Debug)] +pub struct MultiphaseRequest { + pub bounds: Rect, + pub windows: Vec, + pub clearance: i32, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct MultiphaseWindow { + pub node_id: NodeId, + pub from: Rect, + pub to: Rect, + pub hierarchy: MultiphaseWindowHierarchy, +} + +impl MultiphaseWindow { + pub fn new(node_id: NodeId, from: Rect, to: Rect) -> Self { + Self { + node_id, + from, + to, + hierarchy: Default::default(), + } + } + + pub fn with_hierarchy( + node_id: NodeId, + from: Rect, + to: Rect, + hierarchy: MultiphaseWindowHierarchy, + ) -> Self { + Self { + node_id, + from, + to, + hierarchy, + } + } +} + +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub struct MultiphaseWindowHierarchy { + pub source: MultiphaseHierarchyPosition, + pub target: MultiphaseHierarchyPosition, + pub transition: MultiphaseHierarchyTransition, +} + +impl MultiphaseWindowHierarchy { + pub fn new(source: MultiphaseHierarchyPosition, target: MultiphaseHierarchyPosition) -> Self { + let transition = if !source.parent_is_mono && target.parent_is_mono { + MultiphaseHierarchyTransition::EnteringMono + } else if source.parent_is_mono && !target.parent_is_mono { + MultiphaseHierarchyTransition::ExitingMono + } else if source.parent.is_none() || target.parent.is_none() { + MultiphaseHierarchyTransition::Unknown + } else if target.depth < source.depth { + MultiphaseHierarchyTransition::Ascending + } else if target.depth > source.depth { + MultiphaseHierarchyTransition::Descending + } else { + MultiphaseHierarchyTransition::SameLevel + }; + Self { + source, + target, + transition, + } + } + + fn reversed(self) -> Self { + Self { + source: self.target, + target: self.source, + transition: self.transition.reversed(), + } + } +} + +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub struct MultiphaseHierarchyPosition { + pub parent: Option, + pub depth: u16, + pub sibling_index: Option, + pub split_axis: Option, + pub nearest_horizontal_split_depth: Option, + pub nearest_vertical_split_depth: Option, + pub parent_is_mono: bool, + pub mono_active: bool, +} + +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub enum MultiphaseHierarchyTransition { + #[default] + Unknown, + SameLevel, + Ascending, + Descending, + EnteringMono, + ExitingMono, +} + +impl MultiphaseHierarchyTransition { + fn reversed(self) -> Self { + match self { + Self::Unknown => Self::Unknown, + Self::SameLevel => Self::SameLevel, + Self::Ascending => Self::Descending, + Self::Descending => Self::Ascending, + Self::EnteringMono => Self::ExitingMono, + Self::ExitingMono => Self::EnteringMono, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MultiphasePlan { + pub phases: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MultiphasePlanned { + pub plan: MultiphasePlan, + pub explanation: MultiphasePlanExplanation, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MultiphasePlanExplanation { + pub strategy: PlanStrategy, + pub phases: Vec, + pub validation: ValidationExplanation, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PhaseExplanation { + pub action: MultiphasePhaseAction, + pub reason: PhaseReason, + pub nodes: Vec, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct ValidationExplanation { + pub continuous_overlap_passed: bool, + pub final_rects_matched: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PlanStrategy { + NoOp, + SingleAction, + MixedSinglePhase, + HierarchyOrderedScales, + OrientationChange { from_axis: PhaseAxis }, + SwapLanes { axis: PhaseAxis }, + SpaceThenOrthogonalGrowth { axis: PhaseAxis }, + ReversedForwardPlan { original: Box }, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum PlanDirection { + Forward, + Reverse, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RejectedStrategy { + pub direction: PlanDirection, + pub strategy: PlanStrategy, + pub reason: MultiphasePlanFailure, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum PhaseReason { + SingleAction, + SameAxisRedistribution, + MixedAxisActions, + ShrinkIntoLanes { + lane_axis: PhaseAxis, + }, + MoveThroughFreedSpace, + GrowOutOfLanes, + CreateSpaceForAscendingChild, + MoveAscendingChildAfterSpaceExists, + OrthogonalGrowthAfterMove, + ParentAxisBeforeChildAxis { + parent_axis: PhaseAxis, + parent_depth: u16, + child_axis: PhaseAxis, + child_depth: u16, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MultiphasePhase { + pub action: MultiphasePhaseAction, + pub steps: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum MultiphasePhaseAction { + Uniform(PhaseAction), + Mixed(Vec), +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct MultiphaseStep { + pub node_id: NodeId, + pub from: Rect, + pub to: Rect, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct PhaseAction { + pub kind: PhaseKind, + pub axis: PhaseAxis, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum PhaseKind { + Move, + Scale, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum PhaseAxis { + Horizontal, + Vertical, +} + +impl MultiphasePhaseAction { + fn from_step_actions(actions: Vec) -> Self { + debug_assert!(!actions.is_empty()); + let first = actions[0]; + if actions.iter().all(|action| *action == first) { + Self::Uniform(first) + } else { + Self::Mixed(actions) + } + } + + fn action_for_step(&self, idx: usize) -> Option { + match self { + Self::Uniform(action) => Some(*action), + Self::Mixed(actions) => actions.get(idx).copied(), + } + } + + fn as_uniform(&self) -> Option { + match self { + Self::Uniform(action) => Some(*action), + Self::Mixed(_) => None, + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum MultiphaseError { + EmptyBounds, + EmptyWindow, + DuplicateWindow, + InitialOverlap, + FinalOverlap, + NoPlan, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MultiphasePlanDiagnostic { + pub forward: MultiphasePlanFailure, + pub reverse: Option, + pub attempted: Vec, +} + +impl MultiphasePlanDiagnostic { + fn legacy_error(self) -> MultiphaseError { + match self.forward { + MultiphasePlanFailure::Request(error) => error, + _ => MultiphaseError::NoPlan, + } + } +} + +impl ValidationExplanation { + fn passed() -> Self { + Self { + continuous_overlap_passed: true, + final_rects_matched: true, + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum MultiphasePlanFailure { + Request(MultiphaseError), + NoPattern, + ShrinkBound { + axis: PhaseAxis, + available: i32, + required: i32, + }, + InvalidPhaseStep { + action: PhaseAction, + node_id: NodeId, + }, + Validation(MultiphaseValidationError), +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum MultiphaseValidationError { + DuplicatePhaseStep { + phase: usize, + node_id: NodeId, + }, + PhaseActionCount { + phase: usize, + actions: usize, + steps: usize, + }, + UnknownPhaseStep { + phase: usize, + node_id: NodeId, + }, + StaleStepStart { + phase: usize, + node_id: NodeId, + }, + PhaseOverlap { + phase: usize, + a: NodeId, + b: NodeId, + }, + FinalMismatch { + node_id: NodeId, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct PlanForwardFailure { + reason: MultiphasePlanFailure, + attempted: Vec, +} + +pub fn plan_no_overlap(request: &MultiphaseRequest) -> Result { + plan_no_overlap_with_diagnostics(request).map_err(|diagnostic| diagnostic.legacy_error()) +} + +pub fn plan_no_overlap_with_diagnostics( + request: &MultiphaseRequest, +) -> Result { + plan_no_overlap_explained(request).map(|planned| planned.plan) +} + +pub fn plan_no_overlap_explained( + request: &MultiphaseRequest, +) -> Result { + if let Err(error) = validate_request(request) { + return Err(MultiphasePlanDiagnostic { + forward: MultiphasePlanFailure::Request(error), + reverse: None, + attempted: vec![], + }); + } + if request + .windows + .iter() + .all(|window| window.from == window.to) + { + return Ok(MultiphasePlanned { + plan: MultiphasePlan { phases: vec![] }, + explanation: MultiphasePlanExplanation { + strategy: PlanStrategy::NoOp, + phases: vec![], + validation: ValidationExplanation::passed(), + }, + }); + } + if let Some(failure) = target_shrink_bound_failure(request) { + return Err(MultiphasePlanDiagnostic { + forward: failure, + reverse: None, + attempted: vec![], + }); + } + let forward = match plan_forward(request, PlanDirection::Forward) { + Ok(plan) => return Ok(plan), + Err(error) => error, + }; + let reversed = reverse_request(request); + match plan_forward(&reversed, PlanDirection::Reverse) { + Ok(plan) => Ok(reverse_planned(plan)), + Err(reverse) => { + let mut attempted = forward.attempted; + attempted.extend(reverse.attempted); + Err(MultiphasePlanDiagnostic { + forward: forward.reason, + reverse: Some(reverse.reason), + attempted, + }) + } + } +} + +pub(crate) fn validate_phase_paths( + request: &MultiphaseRequest, + paths: &[Vec<(Rect, Rect)>], +) -> Result { + if paths.len() != request.windows.len() { + return Err(MultiphasePlanFailure::NoPattern); + } + let phase_count = paths.iter().map(Vec::len).max().unwrap_or(0); + if phase_count == 0 { + return Err(MultiphasePlanFailure::NoPattern); + } + let mut phases = vec![]; + for phase_idx in 0..phase_count { + let mut steps = vec![]; + let mut actions = vec![]; + for (window_idx, path) in paths.iter().enumerate() { + let Some((from, to)) = path.get(phase_idx).copied() else { + continue; + }; + if from == to { + continue; + } + let step = MultiphaseStep { + node_id: request.windows[window_idx].node_id, + from, + to, + }; + let Some(action) = classify_step(step) else { + return Err(MultiphasePlanFailure::NoPattern); + }; + steps.push(step); + actions.push(action); + } + if !steps.is_empty() { + phases.push(MultiphasePhase { + action: MultiphasePhaseAction::from_step_actions(actions), + steps, + }); + } + } + let plan = MultiphasePlan { phases }; + validate_plan_continuous_diagnostic(request, &plan) + .map(|_| plan) + .map_err(MultiphasePlanFailure::Validation) +} + +pub(crate) fn partition_motion_groups( + windows: &[MultiphaseWindow], + clearance: i32, +) -> Vec> { + let clearance = clearance.max(0); + let mut groups = vec![]; + let mut seen = vec![false; windows.len()]; + for start in 0..windows.len() { + if seen[start] { + continue; + } + seen[start] = true; + let mut group = vec![]; + let mut pending = vec![start]; + while let Some(idx) = pending.pop() { + group.push(idx); + let bounds = motion_bounds_with_clearance(windows[idx], clearance); + for other in 0..windows.len() { + if seen[other] + || !bounds.intersects(&motion_bounds_with_clearance(windows[other], clearance)) + { + continue; + } + seen[other] = true; + pending.push(other); + } + } + group.sort_unstable(); + groups.push(group); + } + groups +} + +fn validate_request(request: &MultiphaseRequest) -> Result<(), MultiphaseError> { + if request.bounds.is_empty() { + return Err(MultiphaseError::EmptyBounds); + } + for (idx, window) in request.windows.iter().enumerate() { + if window.from.is_empty() || window.to.is_empty() { + return Err(MultiphaseError::EmptyWindow); + } + for other in &request.windows[..idx] { + if other.node_id == window.node_id { + return Err(MultiphaseError::DuplicateWindow); + } + } + } + if overlaps(request.windows.iter().map(|window| window.from)) { + return Err(MultiphaseError::InitialOverlap); + } + if overlaps(request.windows.iter().map(|window| window.to)) { + return Err(MultiphaseError::FinalOverlap); + } + Ok(()) +} + +fn target_shrink_bound_failure(request: &MultiphaseRequest) -> Option { + let min_width = sane_min_size(request.bounds.width()); + let min_height = sane_min_size(request.bounds.height()); + for window in &request.windows { + if window.to.width() < min_width { + return Some(MultiphasePlanFailure::ShrinkBound { + axis: PhaseAxis::Horizontal, + available: window.to.width(), + required: min_width, + }); + } + if window.to.height() < min_height { + return Some(MultiphasePlanFailure::ShrinkBound { + axis: PhaseAxis::Vertical, + available: window.to.height(), + required: min_height, + }); + } + } + None +} + +fn plan_forward( + request: &MultiphaseRequest, + direction: PlanDirection, +) -> Result { + let mut rejection = None; + let mut attempted = vec![]; + match plan_single_action_phase(request) { + Ok(plan) => return Ok(plan), + Err(error) => { + record_rejection(&mut attempted, direction, PlanStrategy::SingleAction, error); + if error != MultiphasePlanFailure::NoPattern { + rejection.get_or_insert(error); + } + } + } + for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { + match plan_space_then_orthogonal_growth(request, axis) { + Ok(plan) => return Ok(plan), + Err(error) => { + record_rejection( + &mut attempted, + direction, + PlanStrategy::SpaceThenOrthogonalGrowth { axis }, + error, + ); + if error != MultiphasePlanFailure::NoPattern { + rejection.get_or_insert(error); + } + } + } + } + match plan_hierarchy_ordered_axis_scales(request) { + Ok(plan) => return Ok(plan), + Err(error) => { + record_rejection( + &mut attempted, + direction, + PlanStrategy::HierarchyOrderedScales, + error, + ); + if error != MultiphasePlanFailure::NoPattern { + rejection.get_or_insert(error); + } + } + } + for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { + match plan_orientation_change(request, axis) { + Ok(plan) => return Ok(plan), + Err(error) => { + record_rejection( + &mut attempted, + direction, + PlanStrategy::OrientationChange { from_axis: axis }, + error, + ); + if error != MultiphasePlanFailure::NoPattern { + rejection.get_or_insert(error); + } + } + } + } + for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { + match plan_axis_crossing_lanes(request, axis) { + Ok(plan) => return Ok(plan), + Err(error) => { + record_rejection( + &mut attempted, + direction, + PlanStrategy::SwapLanes { axis }, + error, + ); + if error != MultiphasePlanFailure::NoPattern { + rejection.get_or_insert(error); + } + } + } + } + Err(PlanForwardFailure { + reason: rejection.unwrap_or(MultiphasePlanFailure::NoPattern), + attempted, + }) +} + +fn record_rejection( + attempted: &mut Vec, + direction: PlanDirection, + strategy: PlanStrategy, + reason: MultiphasePlanFailure, +) { + attempted.push(RejectedStrategy { + direction, + strategy, + reason, + }); +} + +fn plan_single_action_phase( + request: &MultiphaseRequest, +) -> Result { + let mut uniform_action = None; + let mut is_uniform = true; + let mut steps = vec![]; + let mut step_actions = vec![]; + for window in &request.windows { + if window.from == window.to { + continue; + } + let step = MultiphaseStep { + node_id: window.node_id, + from: window.from, + to: window.to, + }; + let Some(step_action) = classify_step(step) else { + return Err(MultiphasePlanFailure::NoPattern); + }; + if step_action.kind == PhaseKind::Scale { + let (available, required) = match step_action.axis { + PhaseAxis::Horizontal => (window.to.width(), sane_min_size(request.bounds.width())), + PhaseAxis::Vertical => (window.to.height(), sane_min_size(request.bounds.height())), + }; + if available < required { + return Err(MultiphasePlanFailure::ShrinkBound { + axis: step_action.axis, + available, + required, + }); + } + } + if uniform_action.is_some_and(|action| action != step_action) { + is_uniform = false; + } + uniform_action.get_or_insert(step_action); + steps.push(step); + step_actions.push(step_action); + } + if steps.is_empty() { + return Err(MultiphasePlanFailure::NoPattern); + } + if !is_uniform { + return build_validated_plan( + request, + PlanStrategy::MixedSinglePhase, + [phase_draft_mixed( + steps, + step_actions, + PhaseReason::MixedAxisActions, + )], + ); + } + let action = uniform_action.unwrap(); + build_validated_plan( + request, + PlanStrategy::SingleAction, + [phase_draft_uniform( + action, + steps, + single_action_reason(action), + )], + ) +} + +fn plan_hierarchy_ordered_axis_scales( + request: &MultiphaseRequest, +) -> Result { + let mut changed_axes = vec![]; + for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { + if request + .windows + .iter() + .any(|window| interval_changed(window.from, window.to, axis)) + { + changed_axes.push(axis); + } + } + let [first_axis, second_axis] = changed_axes + .try_into() + .map_err(|_| MultiphasePlanFailure::NoPattern)?; + let order = hierarchy_scale_axis_order(request, first_axis, second_axis) + .ok_or(MultiphasePlanFailure::NoPattern)?; + let mut current: Vec<_> = request + .windows + .iter() + .map(|window| (window.node_id, window.from)) + .collect(); + let mut phases = vec![]; + let reason = PhaseReason::ParentAxisBeforeChildAxis { + parent_axis: order.axes[0], + parent_depth: order.depths[0], + child_axis: order.axes[1], + child_depth: order.depths[1], + }; + for axis in order.axes { + let mut steps = vec![]; + for window in &request.windows { + let (_, rect) = current + .iter_mut() + .find(|(node_id, _)| *node_id == window.node_id) + .unwrap(); + let next = with_main_interval( + *rect, + axis, + main_start(window.to, axis), + main_end(window.to, axis), + ); + if next == *rect { + continue; + } + if main_size(*rect, axis) == main_size(next, axis) { + return Err(MultiphasePlanFailure::NoPattern); + } + steps.push(MultiphaseStep { + node_id: window.node_id, + from: *rect, + to: next, + }); + *rect = next; + } + if steps.is_empty() { + return Err(MultiphasePlanFailure::NoPattern); + } + phases.push(phase_draft(PhaseKind::Scale, axis, steps, reason)); + } + let [first, second] = phases + .try_into() + .map_err(|_| MultiphasePlanFailure::NoPattern)?; + build_validated_plan( + request, + PlanStrategy::HierarchyOrderedScales, + [first, second], + ) +} + +fn hierarchy_scale_axis_order( + request: &MultiphaseRequest, + first_axis: PhaseAxis, + second_axis: PhaseAxis, +) -> Option { + let first_priority = hierarchy_axis_priority(request, first_axis)?; + let second_priority = hierarchy_axis_priority(request, second_axis)?; + match first_priority.cmp(&second_priority) { + std::cmp::Ordering::Less => Some(HierarchyScaleAxisOrder { + axes: [first_axis, second_axis], + depths: [first_priority, second_priority], + }), + std::cmp::Ordering::Greater => Some(HierarchyScaleAxisOrder { + axes: [second_axis, first_axis], + depths: [second_priority, first_priority], + }), + std::cmp::Ordering::Equal => None, + } +} + +#[derive(Copy, Clone)] +struct HierarchyScaleAxisOrder { + axes: [PhaseAxis; 2], + depths: [u16; 2], +} + +fn hierarchy_axis_priority(request: &MultiphaseRequest, axis: PhaseAxis) -> Option { + request + .windows + .iter() + .filter(|window| interval_changed(window.from, window.to, axis)) + .flat_map(|window| { + [ + split_depth_for_axis(window.hierarchy.source, axis), + split_depth_for_axis(window.hierarchy.target, axis), + ] + }) + .flatten() + .min() +} + +fn plan_axis_crossing_lanes( + request: &MultiphaseRequest, + axis: PhaseAxis, +) -> Result { + let moving_windows: Vec<_> = request + .windows + .iter() + .copied() + .filter(|window| window.from != window.to) + .collect(); + if moving_windows.len() < 2 { + return Err(MultiphasePlanFailure::NoPattern); + } + let orth_min = request + .windows + .iter() + .map(|window| orth_start(window.from, axis)) + .min() + .ok_or(MultiphasePlanFailure::NoPattern)?; + let orth_max = request + .windows + .iter() + .map(|window| orth_end(window.from, axis)) + .max() + .ok_or(MultiphasePlanFailure::NoPattern)?; + if moving_windows.iter().any(|window| { + orth_start(window.from, axis) != orth_min + || orth_end(window.from, axis) != orth_max + || orth_start(window.to, axis) != orth_min + || orth_end(window.to, axis) != orth_max + || main_start(window.from, axis) == main_start(window.to, axis) + }) { + return Err(MultiphasePlanFailure::NoPattern); + } + let clearance = request.clearance.max(0); + let lane_count = moving_windows.len() as i32; + let available = (orth_max - orth_min) - clearance.saturating_mul(lane_count - 1); + if available <= 0 { + return Err(MultiphasePlanFailure::ShrinkBound { + axis: axis.other(), + available: 0, + required: sane_min_size(orth_max - orth_min), + }); + } + let lane_size = available / lane_count; + let mut lane_remainder = available % lane_count; + let required = sane_min_size(orth_max - orth_min); + if lane_size < required { + return Err(MultiphasePlanFailure::ShrinkBound { + axis: axis.other(), + available: lane_size, + required, + }); + } + + let mut windows = moving_windows; + windows.sort_by_key(|window| lane_sort_key(*window, axis)); + let mut phase1 = vec![]; + let mut phase2 = vec![]; + let mut phase3 = vec![]; + let mut phase4 = vec![]; + let mut lane_start = orth_min; + for (idx, window) in windows.iter().enumerate() { + let extra = if lane_remainder > 0 { + lane_remainder -= 1; + 1 + } else { + 0 + }; + let lane_end = lane_start + lane_size + extra; + let lane_from = with_orth_interval(window.from, axis, lane_start, lane_end); + let lane_to = with_main_interval( + lane_from, + axis, + main_start(window.to, axis), + main_end(window.to, axis), + ); + let mut lane_move = crossing_lane_move_rect(lane_from, window.to, axis); + if same_axis_delta_within(lane_move, lane_to, axis, SWAP_AXIS_SNAP_PX) { + lane_move = lane_to; + } + push_step(&mut phase1, window.node_id, window.from, lane_from); + push_step(&mut phase2, window.node_id, lane_from, lane_move); + push_step(&mut phase3, window.node_id, lane_move, lane_to); + push_step(&mut phase4, window.node_id, lane_to, window.to); + if idx + 1 < windows.len() { + lane_start = lane_end + clearance; + } + } + build_validated_plan( + request, + PlanStrategy::SwapLanes { axis }, + [ + phase_draft( + PhaseKind::Scale, + axis.other(), + phase1, + PhaseReason::ShrinkIntoLanes { + lane_axis: axis.other(), + }, + ), + phase_draft_classified( + phase2, + PhaseReason::MoveThroughFreedSpace, + )?, + phase_draft( + PhaseKind::Scale, + axis, + phase3, + PhaseReason::SameAxisRedistribution, + ), + phase_draft( + PhaseKind::Scale, + axis.other(), + phase4, + PhaseReason::GrowOutOfLanes, + ), + ], + ) +} + +fn phase_draft_classified( + steps: Vec, + reason: PhaseReason, +) -> Result { + let actions = steps + .iter() + .map(|step| classify_step(*step).ok_or(MultiphasePlanFailure::NoPattern)) + .collect::, _>>()?; + Ok(phase_draft_mixed(steps, actions, reason)) +} + +fn crossing_lane_move_rect(from: Rect, target: Rect, axis: PhaseAxis) -> Rect { + let size = main_size(from, axis); + if main_start(target, axis) > main_start(from, axis) { + let end = main_end(target, axis); + with_main_interval(from, axis, end - size, end) + } else { + let start = main_start(target, axis); + with_main_interval(from, axis, start, start + size) + } +} + +fn same_axis_delta_within(from: Rect, to: Rect, axis: PhaseAxis, max_delta: i32) -> bool { + let start_delta = (main_start(from, axis) - main_start(to, axis)).abs(); + let end_delta = (main_end(from, axis) - main_end(to, axis)).abs(); + start_delta.max(end_delta) <= max_delta +} + +fn lane_sort_key(window: MultiphaseWindow, axis: PhaseAxis) -> (usize, i32, i32, u32) { + let delta = main_start(window.to, axis) - main_start(window.from, axis); + let direction = match delta.cmp(&0) { + std::cmp::Ordering::Greater => 0, + std::cmp::Ordering::Less => 1, + std::cmp::Ordering::Equal => 2, + }; + ( + direction, + main_start(window.from, axis), + main_start(window.to, axis), + window.node_id.0, + ) +} + +fn plan_space_then_orthogonal_growth( + request: &MultiphaseRequest, + axis: PhaseAxis, +) -> Result { + if request.windows.len() < 2 { + return Err(MultiphasePlanFailure::NoPattern); + } + let orth_axis = axis.other(); + let min_width = sane_min_size(request.bounds.width()); + let min_height = sane_min_size(request.bounds.height()); + let mut phase1 = vec![]; + let mut phase2 = vec![]; + let mut phase3 = vec![]; + for window in &request.windows { + if window.to.width() < min_width { + return Err(MultiphasePlanFailure::ShrinkBound { + axis: PhaseAxis::Horizontal, + available: window.to.width(), + required: min_width, + }); + } + if window.to.height() < min_height { + return Err(MultiphasePlanFailure::ShrinkBound { + axis: PhaseAxis::Vertical, + available: window.to.height(), + required: min_height, + }); + } + let main_changes = main_start(window.from, axis) != main_start(window.to, axis) + || main_end(window.from, axis) != main_end(window.to, axis); + let orth_changes = orth_start(window.from, axis) != orth_start(window.to, axis) + || orth_end(window.from, axis) != orth_end(window.to, axis); + let mut orth_from = window.from; + if main_changes && main_size(window.from, axis) == main_size(window.to, axis) { + let after_move = with_main_interval( + window.from, + axis, + main_start(window.to, axis), + main_end(window.to, axis), + ); + push_step(&mut phase2, window.node_id, window.from, after_move); + orth_from = after_move; + } else if main_changes { + let target_size = main_size(window.to, axis); + let after_main_scale = if main_start(window.from, axis) == main_start(window.to, axis) + || main_end(window.from, axis) == main_end(window.to, axis) + { + with_main_interval( + window.from, + axis, + main_start(window.to, axis), + main_end(window.to, axis), + ) + } else if main_start(window.to, axis) < main_start(window.from, axis) { + with_main_interval( + window.from, + axis, + main_end(window.from, axis) - target_size, + main_end(window.from, axis), + ) + } else { + with_main_interval( + window.from, + axis, + main_start(window.from, axis), + main_start(window.from, axis) + target_size, + ) + }; + push_step(&mut phase1, window.node_id, window.from, after_main_scale); + orth_from = after_main_scale; + if main_start(after_main_scale, axis) != main_start(window.to, axis) + || main_end(after_main_scale, axis) != main_end(window.to, axis) + { + let after_move = with_main_interval( + after_main_scale, + axis, + main_start(window.to, axis), + main_end(window.to, axis), + ); + push_step(&mut phase2, window.node_id, after_main_scale, after_move); + orth_from = after_move; + } + } + if orth_changes { + push_step(&mut phase3, window.node_id, orth_from, window.to); + } + } + if phase1.is_empty() || phase2.is_empty() || phase3.is_empty() { + return Err(MultiphasePlanFailure::NoPattern); + } + build_validated_plan( + request, + PlanStrategy::SpaceThenOrthogonalGrowth { axis }, + [ + phase_draft( + PhaseKind::Scale, + axis, + phase1, + PhaseReason::CreateSpaceForAscendingChild, + ), + phase_draft( + PhaseKind::Move, + axis, + phase2, + PhaseReason::MoveAscendingChildAfterSpaceExists, + ), + phase_draft( + PhaseKind::Scale, + orth_axis, + phase3, + PhaseReason::OrthogonalGrowthAfterMove, + ), + ], + ) +} + +fn plan_orientation_change( + request: &MultiphaseRequest, + from_axis: PhaseAxis, +) -> Result { + if request.windows.len() < 2 { + return Err(MultiphasePlanFailure::NoPattern); + } + let to_axis = from_axis.other(); + let min_lane_size = sane_min_size(main_size(request.bounds, to_axis)); + let target_start = request + .windows + .first() + .map(|window| main_start(window.to, from_axis)) + .ok_or(MultiphasePlanFailure::NoPattern)?; + let target_end = request + .windows + .first() + .map(|window| main_end(window.to, from_axis)) + .ok_or(MultiphasePlanFailure::NoPattern)?; + let source_start = request + .windows + .first() + .map(|window| main_start(window.from, to_axis)) + .ok_or(MultiphasePlanFailure::NoPattern)?; + let source_end = request + .windows + .first() + .map(|window| main_end(window.from, to_axis)) + .ok_or(MultiphasePlanFailure::NoPattern)?; + if request.windows.iter().any(|window| { + main_start(window.from, to_axis) != source_start + || main_end(window.from, to_axis) != source_end + || main_start(window.to, from_axis) != target_start + || main_end(window.to, from_axis) != target_end + || main_size(window.to, to_axis) < min_lane_size + }) { + return Err(MultiphasePlanFailure::NoPattern); + } + + let mut phase1 = vec![]; + let mut phase2 = vec![]; + let mut phase3 = vec![]; + for window in &request.windows { + let lane = with_main_interval( + window.from, + to_axis, + main_start(window.to, to_axis), + main_end(window.to, to_axis), + ); + let moved = with_main_interval( + lane, + from_axis, + main_start(window.to, from_axis), + main_start(window.to, from_axis) + main_size(lane, from_axis), + ); + push_step(&mut phase1, window.node_id, window.from, lane); + push_step(&mut phase2, window.node_id, lane, moved); + push_step(&mut phase3, window.node_id, moved, window.to); + } + if phase1.is_empty() || phase3.is_empty() { + return Err(MultiphasePlanFailure::NoPattern); + } + build_validated_plan( + request, + PlanStrategy::OrientationChange { from_axis }, + [ + phase_draft( + PhaseKind::Scale, + to_axis, + phase1, + PhaseReason::ShrinkIntoLanes { lane_axis: to_axis }, + ), + phase_draft( + PhaseKind::Move, + from_axis, + phase2, + PhaseReason::MoveThroughFreedSpace, + ), + phase_draft( + PhaseKind::Scale, + from_axis, + phase3, + PhaseReason::GrowOutOfLanes, + ), + ], + ) +} + +struct MultiphasePhaseDraft { + action: MultiphasePhaseActionDraft, + steps: Vec, + reason: PhaseReason, +} + +enum MultiphasePhaseActionDraft { + Uniform(PhaseAction), + Mixed(Vec), +} + +fn phase_draft_uniform( + action: PhaseAction, + steps: Vec, + reason: PhaseReason, +) -> MultiphasePhaseDraft { + MultiphasePhaseDraft { + action: MultiphasePhaseActionDraft::Uniform(action), + steps, + reason, + } +} + +fn phase_draft( + kind: PhaseKind, + axis: PhaseAxis, + steps: Vec, + reason: PhaseReason, +) -> MultiphasePhaseDraft { + phase_draft_uniform(PhaseAction { kind, axis }, steps, reason) +} + +fn phase_draft_mixed( + steps: Vec, + actions: Vec, + reason: PhaseReason, +) -> MultiphasePhaseDraft { + MultiphasePhaseDraft { + action: MultiphasePhaseActionDraft::Mixed(actions), + steps, + reason, + } +} + +fn build_validated_plan( + request: &MultiphaseRequest, + strategy: PlanStrategy, + phases: [MultiphasePhaseDraft; N], +) -> Result { + let mut explanations = vec![]; + let phases: Vec<_> = phases + .into_iter() + .filter_map(|draft| { + if draft.steps.is_empty() { + return None; + } + let mut nodes: Vec<_> = draft.steps.iter().map(|step| step.node_id).collect(); + nodes.sort_by_key(|node_id| node_id.0); + let action = match draft.action { + MultiphasePhaseActionDraft::Uniform(action) => { + MultiphasePhaseAction::Uniform(action) + } + MultiphasePhaseActionDraft::Mixed(actions) => { + debug_assert_eq!(actions.len(), draft.steps.len()); + MultiphasePhaseAction::from_step_actions(actions) + } + }; + explanations.push(PhaseExplanation { + action: action.clone(), + reason: draft.reason, + nodes, + }); + Some(MultiphasePhase { + action, + steps: draft.steps, + }) + }) + .collect(); + for phase in &phases { + for (idx, step) in phase.steps.iter().enumerate() { + let action = phase.action.action_for_step(idx).unwrap(); + if classify_step(*step) != Some(action) { + return Err(MultiphasePlanFailure::InvalidPhaseStep { + action, + node_id: step.node_id, + }); + } + } + } + let plan = MultiphasePlan { phases }; + validate_plan_continuous_diagnostic(request, &plan) + .map(|_| MultiphasePlanned { + plan, + explanation: MultiphasePlanExplanation { + strategy, + phases: explanations, + validation: ValidationExplanation::passed(), + }, + }) + .map_err(MultiphasePlanFailure::Validation) +} + +fn validate_plan_continuous(request: &MultiphaseRequest, plan: &MultiphasePlan) -> bool { + validate_plan_continuous_diagnostic(request, plan).is_ok() +} + +fn validate_plan_continuous_diagnostic( + request: &MultiphaseRequest, + plan: &MultiphasePlan, +) -> Result<(), MultiphaseValidationError> { + let mut current: Vec<_> = request + .windows + .iter() + .map(|window| (window.node_id, window.from)) + .collect(); + for (phase_idx, phase) in plan.phases.iter().enumerate() { + if let MultiphasePhaseAction::Mixed(actions) = &phase.action + && actions.len() != phase.steps.len() + { + return Err(MultiphaseValidationError::PhaseActionCount { + phase: phase_idx, + actions: actions.len(), + steps: phase.steps.len(), + }); + } + for (idx, step) in phase.steps.iter().enumerate() { + if phase.steps[..idx] + .iter() + .any(|prev| prev.node_id == step.node_id) + { + return Err(MultiphaseValidationError::DuplicatePhaseStep { + phase: phase_idx, + node_id: step.node_id, + }); + } + let Some((_, rect)) = current.iter().find(|(node_id, _)| *node_id == step.node_id) + else { + return Err(MultiphaseValidationError::UnknownPhaseStep { + phase: phase_idx, + node_id: step.node_id, + }); + }; + if *rect != step.from { + return Err(MultiphaseValidationError::StaleStepStart { + phase: phase_idx, + node_id: step.node_id, + }); + } + } + let motions: Vec<_> = current + .iter() + .map(|(node_id, rect)| { + let to = phase + .steps + .iter() + .find(|step| step.node_id == *node_id) + .map(|step| step.to) + .unwrap_or(*rect); + RectMotion { from: *rect, to } + }) + .collect(); + for (idx, motion) in motions.iter().enumerate() { + if let Some((other_idx, _)) = motions[idx + 1..] + .iter() + .enumerate() + .find(|(_, other)| motions_overlap_during_phase(*motion, **other)) + { + return Err(MultiphaseValidationError::PhaseOverlap { + phase: phase_idx, + a: current[idx].0, + b: current[idx + 1 + other_idx].0, + }); + } + } + for step in &phase.steps { + let (_, rect) = current + .iter_mut() + .find(|(node_id, _)| *node_id == step.node_id) + .unwrap(); + *rect = step.to; + } + } + for window in &request.windows { + if !current + .iter() + .find(|(node_id, _)| *node_id == window.node_id) + .is_some_and(|(_, rect)| *rect == window.to) + { + return Err(MultiphaseValidationError::FinalMismatch { + node_id: window.node_id, + }); + } + } + Ok(()) +} + +#[derive(Copy, Clone)] +struct RectMotion { + from: Rect, + to: Rect, +} + +fn motions_overlap_during_phase(a: RectMotion, b: RectMotion) -> bool { + let mut interval = TimeInterval::unit(); + interval.intersect_less_than(edge_delta(a.from.x1(), a.to.x1(), b.from.x2(), b.to.x2())) + && interval.intersect_less_than(edge_delta(b.from.x1(), b.to.x1(), a.from.x2(), a.to.x2())) + && interval.intersect_less_than(edge_delta(a.from.y1(), a.to.y1(), b.from.y2(), b.to.y2())) + && interval.intersect_less_than(edge_delta(b.from.y1(), b.to.y1(), a.from.y2(), a.to.y2())) + && interval.is_non_empty() +} + +fn edge_delta(a0: i32, a1: i32, b0: i32, b1: i32) -> LinearDelta { + let from = a0 as i64 - b0 as i64; + let to = a1 as i64 - b1 as i64; + LinearDelta { + start: from, + velocity: to - from, + } +} + +#[derive(Copy, Clone)] +struct LinearDelta { + start: i64, + velocity: i64, +} + +#[derive(Copy, Clone)] +struct TimeInterval { + lower: Rational, + lower_open: bool, + upper: Rational, + upper_open: bool, +} + +impl TimeInterval { + fn unit() -> Self { + Self { + lower: Rational::new(0, 1), + lower_open: false, + upper: Rational::new(1, 1), + upper_open: false, + } + } + + fn intersect_less_than(&mut self, delta: LinearDelta) -> bool { + if delta.velocity == 0 { + return delta.start < 0; + } + let boundary = Rational::new(-delta.start, delta.velocity); + if delta.velocity > 0 { + self.tighten_upper(boundary, true); + } else { + self.tighten_lower(boundary, true); + } + self.is_non_empty() + } + + fn tighten_lower(&mut self, value: Rational, open: bool) { + match value.cmp(&self.lower) { + std::cmp::Ordering::Greater => { + self.lower = value; + self.lower_open = open; + } + std::cmp::Ordering::Equal => { + self.lower_open |= open; + } + std::cmp::Ordering::Less => {} + } + } + + fn tighten_upper(&mut self, value: Rational, open: bool) { + match value.cmp(&self.upper) { + std::cmp::Ordering::Less => { + self.upper = value; + self.upper_open = open; + } + std::cmp::Ordering::Equal => { + self.upper_open |= open; + } + std::cmp::Ordering::Greater => {} + } + } + + fn is_non_empty(&self) -> bool { + match self.lower.cmp(&self.upper) { + std::cmp::Ordering::Less => true, + std::cmp::Ordering::Equal => !self.lower_open && !self.upper_open, + std::cmp::Ordering::Greater => false, + } + } +} + +#[derive(Copy, Clone, Eq, PartialEq)] +struct Rational { + num: i64, + den: i64, +} + +impl Rational { + fn new(mut num: i64, mut den: i64) -> Self { + if den < 0 { + num = -num; + den = -den; + } + Self { num, den } + } + + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + (self.num as i128 * other.den as i128).cmp(&(other.num as i128 * self.den as i128)) + } +} + +fn classify_step(step: MultiphaseStep) -> Option { + let same_x = step.from.x1() == step.to.x1() && step.from.x2() == step.to.x2(); + let same_y = step.from.y1() == step.to.y1() && step.from.y2() == step.to.y2(); + let same_size = step.from.size() == step.to.size(); + match (same_x, same_y, same_size) { + (false, true, true) => Some(PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }), + (true, false, true) => Some(PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Vertical, + }), + (false, true, false) => Some(PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal, + }), + (true, false, false) => Some(PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }), + _ => None, + } +} + +fn single_action_reason(action: PhaseAction) -> PhaseReason { + match action.kind { + PhaseKind::Move => PhaseReason::SingleAction, + PhaseKind::Scale => PhaseReason::SameAxisRedistribution, + } +} + +fn reverse_request(request: &MultiphaseRequest) -> MultiphaseRequest { + MultiphaseRequest { + bounds: request.bounds, + clearance: request.clearance, + windows: request + .windows + .iter() + .map(|window| MultiphaseWindow { + node_id: window.node_id, + from: window.to, + to: window.from, + hierarchy: window.hierarchy.reversed(), + }) + .collect(), + } +} + +fn reverse_plan(plan: MultiphasePlan) -> MultiphasePlan { + MultiphasePlan { + phases: plan + .phases + .into_iter() + .rev() + .map(|phase| MultiphasePhase { + action: phase.action, + steps: phase + .steps + .into_iter() + .map(|step| MultiphaseStep { + node_id: step.node_id, + from: step.to, + to: step.from, + }) + .collect(), + }) + .collect(), + } +} + +fn reverse_planned(planned: MultiphasePlanned) -> MultiphasePlanned { + let mut phases = planned.explanation.phases; + phases.reverse(); + MultiphasePlanned { + plan: reverse_plan(planned.plan), + explanation: MultiphasePlanExplanation { + strategy: PlanStrategy::ReversedForwardPlan { + original: Box::new(planned.explanation.strategy), + }, + phases, + validation: planned.explanation.validation, + }, + } +} + +fn overlaps(rects: impl IntoIterator) -> bool { + let rects: Vec<_> = rects.into_iter().collect(); + for (idx, rect) in rects.iter().enumerate() { + if rects[idx + 1..].iter().any(|other| rect.intersects(other)) { + return true; + } + } + false +} + +fn motion_bounds(window: MultiphaseWindow) -> Rect { + window.from.union(window.to) +} + +fn motion_bounds_with_clearance(window: MultiphaseWindow, clearance: i32) -> Rect { + let bounds = motion_bounds(window); + Rect::new_saturating( + bounds.x1().saturating_sub(clearance), + bounds.y1().saturating_sub(clearance), + bounds.x2().saturating_add(clearance), + bounds.y2().saturating_add(clearance), + ) +} + +fn interval_changed(from: Rect, to: Rect, axis: PhaseAxis) -> bool { + main_start(from, axis) != main_start(to, axis) || main_end(from, axis) != main_end(to, axis) +} + +fn split_depth_for_axis(position: MultiphaseHierarchyPosition, axis: PhaseAxis) -> Option { + match axis { + PhaseAxis::Horizontal => position.nearest_horizontal_split_depth, + PhaseAxis::Vertical => position.nearest_vertical_split_depth, + } +} + +fn push_step(steps: &mut Vec, node_id: NodeId, from: Rect, to: Rect) { + if from != to { + steps.push(MultiphaseStep { node_id, from, to }); + } +} + +fn sane_min_size(size: i32) -> i32 { + (size / MIN_SHRINK_DENOMINATOR).max(1) +} + +fn main_start(rect: Rect, axis: PhaseAxis) -> i32 { + match axis { + PhaseAxis::Horizontal => rect.x1(), + PhaseAxis::Vertical => rect.y1(), + } +} + +fn main_end(rect: Rect, axis: PhaseAxis) -> i32 { + match axis { + PhaseAxis::Horizontal => rect.x2(), + PhaseAxis::Vertical => rect.y2(), + } +} + +fn main_size(rect: Rect, axis: PhaseAxis) -> i32 { + main_end(rect, axis) - main_start(rect, axis) +} + +fn orth_start(rect: Rect, axis: PhaseAxis) -> i32 { + main_start(rect, axis.other()) +} + +fn orth_end(rect: Rect, axis: PhaseAxis) -> i32 { + main_end(rect, axis.other()) +} + +fn with_main_interval(rect: Rect, axis: PhaseAxis, start: i32, end: i32) -> Rect { + match axis { + PhaseAxis::Horizontal => Rect::new_saturating(start, rect.y1(), end, rect.y2()), + PhaseAxis::Vertical => Rect::new_saturating(rect.x1(), start, rect.x2(), end), + } +} + +fn with_orth_interval(rect: Rect, axis: PhaseAxis, start: i32, end: i32) -> Rect { + with_main_interval(rect, axis.other(), start, end) +} + +impl PhaseAxis { + fn other(self) -> Self { + match self { + Self::Horizontal => Self::Vertical, + Self::Vertical => Self::Horizontal, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn id(raw: u32) -> NodeId { + NodeId(raw) + } + + fn rect(x1: i32, y1: i32, x2: i32, y2: i32) -> Rect { + Rect::new_saturating(x1, y1, x2, y2) + } + + fn window(raw: u32, from: Rect, to: Rect) -> MultiphaseWindow { + MultiphaseWindow::new(id(raw), from, to) + } + + #[derive(Clone)] + enum TestTree { + Leaf(u32), + Split { + id: u32, + axis: PhaseAxis, + weights: Vec, + children: Vec, + }, + } + + struct TestLeaf { + node_id: NodeId, + rect: Rect, + hierarchy: MultiphaseHierarchyPosition, + } + + fn leaf(raw: u32) -> TestTree { + TestTree::Leaf(raw) + } + + fn split(id: u32, axis: PhaseAxis, weights: &[i32], children: Vec) -> TestTree { + TestTree::Split { + id, + axis, + weights: weights.to_vec(), + children, + } + } + + fn layout_tree(tree: &TestTree, bounds: Rect) -> Vec { + let mut leaves = vec![]; + layout_tree_inner( + tree, + bounds, + TestHierarchy { + parent: None, + depth: 0, + sibling_index: None, + split_axis: None, + nearest_horizontal_split_depth: None, + nearest_vertical_split_depth: None, + }, + &mut leaves, + ); + leaves.sort_by_key(|leaf| leaf.node_id.0); + leaves + } + + #[derive(Copy, Clone)] + struct TestHierarchy { + parent: Option, + depth: u16, + sibling_index: Option, + split_axis: Option, + nearest_horizontal_split_depth: Option, + nearest_vertical_split_depth: Option, + } + + fn layout_tree_inner( + tree: &TestTree, + bounds: Rect, + hierarchy: TestHierarchy, + leaves: &mut Vec, + ) { + match tree { + TestTree::Leaf(raw) => leaves.push(TestLeaf { + node_id: id(*raw), + rect: bounds, + hierarchy: MultiphaseHierarchyPosition { + parent: hierarchy.parent, + depth: hierarchy.depth, + sibling_index: hierarchy.sibling_index, + split_axis: hierarchy.split_axis, + nearest_horizontal_split_depth: hierarchy.nearest_horizontal_split_depth, + nearest_vertical_split_depth: hierarchy.nearest_vertical_split_depth, + ..Default::default() + }, + }), + TestTree::Split { + id: split_id, + axis, + weights, + children, + } => { + assert_eq!(weights.len(), children.len()); + let rects = split_rect_by_weights(bounds, *axis, weights); + for (idx, (child, rect)) in children.iter().zip(rects).enumerate() { + let depth = hierarchy.depth.saturating_add(1); + let mut child_hierarchy = TestHierarchy { + parent: Some(id(*split_id)), + depth, + sibling_index: Some(idx.min(u16::MAX as usize) as u16), + split_axis: Some(*axis), + nearest_horizontal_split_depth: hierarchy.nearest_horizontal_split_depth, + nearest_vertical_split_depth: hierarchy.nearest_vertical_split_depth, + }; + match axis { + PhaseAxis::Horizontal => { + child_hierarchy.nearest_horizontal_split_depth = Some(depth); + } + PhaseAxis::Vertical => { + child_hierarchy.nearest_vertical_split_depth = Some(depth); + } + } + layout_tree_inner(child, rect, child_hierarchy, leaves); + } + } + } + } + + fn split_rect_by_weights(bounds: Rect, axis: PhaseAxis, weights: &[i32]) -> Vec { + let total_weight: i32 = weights.iter().sum(); + assert!(total_weight > 0); + let total_size = match axis { + PhaseAxis::Horizontal => bounds.width(), + PhaseAxis::Vertical => bounds.height(), + }; + let mut pos = match axis { + PhaseAxis::Horizontal => bounds.x1(), + PhaseAxis::Vertical => bounds.y1(), + }; + let mut remaining_size = total_size; + let mut remaining_weight = total_weight; + let mut rects = vec![]; + for (idx, weight) in weights.iter().enumerate() { + let size = if idx + 1 == weights.len() { + remaining_size + } else { + total_size * *weight / total_weight + }; + let rect = match axis { + PhaseAxis::Horizontal => { + Rect::new_sized_saturating(pos, bounds.y1(), size, bounds.height()) + } + PhaseAxis::Vertical => { + Rect::new_sized_saturating(bounds.x1(), pos, bounds.width(), size) + } + }; + rects.push(rect); + pos += size; + remaining_size -= size; + remaining_weight -= *weight; + if remaining_weight == 0 { + assert_eq!(remaining_size, 0); + } + } + rects + } + + fn generated_request(old: &TestTree, new: &TestTree, bounds: Rect) -> MultiphaseRequest { + let old_leaves = layout_tree(old, bounds); + let new_leaves = layout_tree(new, bounds); + assert_eq!(old_leaves.len(), new_leaves.len()); + let mut windows = vec![]; + for old_leaf in &old_leaves { + let new_leaf = new_leaves + .iter() + .find(|leaf| leaf.node_id == old_leaf.node_id) + .unwrap(); + windows.push(MultiphaseWindow::with_hierarchy( + old_leaf.node_id, + old_leaf.rect, + new_leaf.rect, + MultiphaseWindowHierarchy::new(old_leaf.hierarchy, new_leaf.hierarchy), + )); + } + MultiphaseRequest { + bounds, + windows, + clearance: 0, + } + } + + fn assert_generated_case_plans(old: &TestTree, new: &TestTree, bounds: Rect) { + assert_generated_case_plans_deterministically(old, new, bounds); + } + + fn assert_generated_case_plans_deterministically( + old: &TestTree, + new: &TestTree, + bounds: Rect, + ) -> MultiphasePlanned { + let req = generated_request(old, new, bounds); + assert!(!overlaps(req.windows.iter().map(|window| window.from))); + assert!(!overlaps(req.windows.iter().map(|window| window.to))); + let first = plan_no_overlap_explained(&req).unwrap(); + let second = plan_no_overlap_explained(&req).unwrap(); + assert_eq!(first, second); + assert_eq!(first.plan.phases.len(), first.explanation.phases.len()); + assert_eq!( + first.explanation.validation, + ValidationExplanation::passed() + ); + for phase in &first.explanation.phases { + assert!(phase.nodes.windows(2).all(|pair| pair[0].0 < pair[1].0)); + } + assert!(validate_plan_continuous(&req, &first.plan)); + first + } + + fn bounds_for_axis(axis: PhaseAxis) -> Rect { + match axis { + PhaseAxis::Horizontal => rect(0, 0, 400, 100), + PhaseAxis::Vertical => rect(0, 0, 100, 400), + } + } + + fn push_generated_case_bidirectional( + cases: &mut Vec<(TestTree, TestTree, Rect)>, + old: TestTree, + new: TestTree, + bounds: Rect, + ) { + cases.push((old.clone(), new.clone(), bounds)); + cases.push((new, old, bounds)); + } + + fn request(windows: Vec) -> MultiphaseRequest { + let bounds = windows + .iter() + .map(|window| window.from.union(window.to)) + .reduce(|bounds, rect| bounds.union(rect)) + .unwrap_or_else(|| rect(0, 0, 1, 1)); + MultiphaseRequest { + bounds, + windows, + clearance: 0, + } + } + + fn actions(plan: &MultiphasePlan) -> Vec { + plan.phases + .iter() + .map(|phase| phase.action.as_uniform().unwrap()) + .collect() + } + + fn step_to(plan: &MultiphasePlan, phase: usize, node_id: NodeId) -> Rect { + plan.phases[phase] + .steps + .iter() + .find(|step| step.node_id == node_id) + .unwrap() + .to + } + + fn no_pattern_attempts(direction: PlanDirection) -> Vec { + vec![ + RejectedStrategy { + direction, + strategy: PlanStrategy::SingleAction, + reason: MultiphasePlanFailure::NoPattern, + }, + RejectedStrategy { + direction, + strategy: PlanStrategy::SpaceThenOrthogonalGrowth { + axis: PhaseAxis::Horizontal, + }, + reason: MultiphasePlanFailure::NoPattern, + }, + RejectedStrategy { + direction, + strategy: PlanStrategy::SpaceThenOrthogonalGrowth { + axis: PhaseAxis::Vertical, + }, + reason: MultiphasePlanFailure::NoPattern, + }, + RejectedStrategy { + direction, + strategy: PlanStrategy::HierarchyOrderedScales, + reason: MultiphasePlanFailure::NoPattern, + }, + RejectedStrategy { + direction, + strategy: PlanStrategy::OrientationChange { + from_axis: PhaseAxis::Horizontal, + }, + reason: MultiphasePlanFailure::NoPattern, + }, + RejectedStrategy { + direction, + strategy: PlanStrategy::OrientationChange { + from_axis: PhaseAxis::Vertical, + }, + reason: MultiphasePlanFailure::NoPattern, + }, + RejectedStrategy { + direction, + strategy: PlanStrategy::SwapLanes { + axis: PhaseAxis::Horizontal, + }, + reason: MultiphasePlanFailure::NoPattern, + }, + RejectedStrategy { + direction, + strategy: PlanStrategy::SwapLanes { + axis: PhaseAxis::Vertical, + }, + reason: MultiphasePlanFailure::NoPattern, + }, + ] + } + + #[test] + fn horizontal_swap_shrinks_moves_then_grows_without_overlap() { + let req = request(vec![ + window(1, rect(0, 0, 100, 100), rect(100, 0, 200, 100)), + MultiphaseWindow { + node_id: id(2), + from: rect(100, 0, 200, 100), + to: rect(0, 0, 100, 100), + hierarchy: Default::default(), + }, + ]); + let planned = plan_no_overlap_explained(&req).unwrap(); + let plan = &planned.plan; + assert_eq!( + actions(plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical + }, + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical + }, + ] + ); + assert_eq!(plan.phases[0].steps[0].to, rect(0, 0, 100, 50)); + assert_eq!(plan.phases[0].steps[1].to, rect(100, 50, 200, 100)); + assert_eq!( + planned.explanation.strategy, + PlanStrategy::SwapLanes { + axis: PhaseAxis::Horizontal + } + ); + assert_eq!( + planned + .explanation + .phases + .iter() + .map(|phase| phase.reason) + .collect::>(), + vec![ + PhaseReason::ShrinkIntoLanes { + lane_axis: PhaseAxis::Vertical + }, + PhaseReason::MoveThroughFreedSpace, + PhaseReason::GrowOutOfLanes, + ] + ); + assert_eq!(planned.explanation.phases[0].nodes, vec![id(1), id(2)]); + assert!(validate_plan_continuous(&req, plan)); + } + + #[test] + fn horizontal_swap_reverse_uses_equivalent_lanes() { + let req = request(vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(100, 0, 200, 100), + to: rect(0, 0, 100, 100), + hierarchy: Default::default(), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(0, 0, 100, 100), + to: rect(100, 0, 200, 100), + hierarchy: Default::default(), + }, + ]); + let plan = plan_no_overlap(&req).unwrap(); + assert_eq!(step_to(&plan, 0, id(2)), rect(0, 0, 100, 50)); + assert_eq!(step_to(&plan, 0, id(1)), rect(100, 50, 200, 100)); + assert!(validate_plan_continuous(&req, &plan)); + } + + #[test] + fn horizontal_swap_lanes_follow_motion_direction_not_node_id() { + let req = request(vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(100, 0, 200, 100), + to: rect(0, 0, 100, 100), + hierarchy: Default::default(), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(0, 0, 100, 100), + to: rect(100, 0, 200, 100), + hierarchy: Default::default(), + }, + ]); + let plan = plan_no_overlap(&req).unwrap(); + assert_eq!(step_to(&plan, 0, id(2)), rect(0, 0, 100, 50)); + assert_eq!(step_to(&plan, 0, id(1)), rect(100, 50, 200, 100)); + assert!(validate_plan_continuous(&req, &plan)); + } + + #[test] + fn swap_lanes_respect_requested_clearance() { + let mut req = request(vec![ + window(1, rect(0, 0, 100, 100), rect(100, 0, 200, 100)), + window(2, rect(100, 0, 200, 100), rect(0, 0, 100, 100)), + ]); + req.clearance = 10; + + let plan = plan_no_overlap(&req).unwrap(); + assert_eq!(step_to(&plan, 0, id(1)), rect(0, 0, 100, 45)); + assert_eq!(step_to(&plan, 0, id(2)), rect(100, 55, 200, 100)); + assert!(validate_plan_continuous(&req, &plan)); + } + + #[test] + fn swap_lanes_tolerate_stationary_siblings_in_request() { + let req = request(vec![ + window(1, rect(0, 0, 100, 100), rect(100, 0, 200, 100)), + window(2, rect(100, 0, 200, 100), rect(0, 0, 100, 100)), + window(3, rect(200, 0, 300, 100), rect(200, 0, 300, 100)), + ]); + + let planned = plan_no_overlap_explained(&req).unwrap(); + assert_eq!( + planned.explanation.strategy, + PlanStrategy::SwapLanes { + axis: PhaseAxis::Horizontal, + } + ); + assert!(validate_plan_continuous(&req, &planned.plan)); + } + + #[test] + fn one_pixel_uneven_swap_lanes_fold_remainder_into_crossing_phase() { + let req = request(vec![ + window(1, rect(0, 0, 101, 100), rect(101, 0, 201, 100)), + window(2, rect(101, 0, 201, 100), rect(0, 0, 101, 100)), + ]); + let planned = plan_no_overlap_explained(&req).unwrap(); + let plan = &planned.plan; + + assert_eq!( + planned.explanation.strategy, + PlanStrategy::SwapLanes { + axis: PhaseAxis::Horizontal, + } + ); + assert_eq!( + actions(plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + ] + ); + assert_eq!(step_to(plan, 1, id(1)), rect(101, 0, 201, 50)); + assert_eq!(step_to(plan, 1, id(2)), rect(0, 50, 101, 100)); + assert!(validate_plan_continuous(&req, plan)); + } + + #[test] + fn large_uneven_swap_lanes_split_move_and_same_axis_scale() { + let req = request(vec![ + window(1, rect(0, 0, 120, 100), rect(120, 0, 200, 100)), + window(2, rect(120, 0, 200, 100), rect(0, 0, 120, 100)), + ]); + let planned = plan_no_overlap_explained(&req).unwrap(); + let plan = &planned.plan; + + assert_eq!( + planned.explanation.strategy, + PlanStrategy::SwapLanes { + axis: PhaseAxis::Horizontal, + } + ); + assert_eq!( + actions(plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + ] + ); + assert_eq!(step_to(plan, 1, id(1)), rect(80, 0, 200, 50)); + assert_eq!(step_to(plan, 1, id(2)), rect(0, 50, 80, 100)); + assert_eq!(step_to(plan, 2, id(1)), rect(120, 0, 200, 50)); + assert_eq!(step_to(plan, 2, id(2)), rect(0, 50, 120, 100)); + assert!(validate_plan_continuous(&req, plan)); + } + + #[test] + fn uneven_swap_lanes_tolerate_stationary_sibling_in_odd_layout() { + let req = request(vec![ + window(1, rect(0, 0, 101, 100), rect(101, 0, 201, 100)), + window(2, rect(101, 0, 201, 100), rect(0, 0, 101, 100)), + window(3, rect(201, 0, 300, 100), rect(201, 0, 300, 100)), + ]); + let planned = plan_no_overlap_explained(&req).unwrap(); + + assert_eq!( + planned.explanation.strategy, + PlanStrategy::SwapLanes { + axis: PhaseAxis::Horizontal, + } + ); + assert_eq!( + actions(&planned.plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + ] + ); + assert!(validate_plan_continuous(&req, &planned.plan)); + } + + #[test] + fn horizontal_rotation_uses_crossing_lanes() { + let req = request(vec![ + window(1, rect(0, 0, 100, 100), rect(100, 0, 200, 100)), + window(2, rect(100, 0, 200, 100), rect(200, 0, 300, 100)), + window(3, rect(200, 0, 300, 100), rect(0, 0, 100, 100)), + ]); + let planned = plan_no_overlap_explained(&req).unwrap(); + + assert_eq!( + planned.explanation.strategy, + PlanStrategy::SwapLanes { + axis: PhaseAxis::Horizontal, + } + ); + assert_eq!( + actions(&planned.plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + ] + ); + assert!(validate_plan_continuous(&req, &planned.plan)); + } + + #[test] + fn vertical_swap_lanes_follow_motion_direction_not_node_id() { + let req = request(vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 100, 100, 200), + to: rect(0, 0, 100, 100), + hierarchy: Default::default(), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(0, 0, 100, 100), + to: rect(0, 100, 100, 200), + hierarchy: Default::default(), + }, + ]); + let plan = plan_no_overlap(&req).unwrap(); + assert_eq!(step_to(&plan, 0, id(2)), rect(0, 0, 50, 100)); + assert_eq!(step_to(&plan, 0, id(1)), rect(50, 100, 100, 200)); + assert!(validate_plan_continuous(&req, &plan)); + } + + #[test] + fn generated_sibling_swaps_plan_for_both_axes() { + let bounds = rect(0, 0, 240, 240); + for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { + let old = split(10, axis, &[1, 1], vec![leaf(1), leaf(2)]); + let new = split(10, axis, &[1, 1], vec![leaf(2), leaf(1)]); + assert_generated_case_plans(&old, &new, bounds); + } + } + + #[test] + fn generated_size_redistributions_plan_as_single_axis_scale() { + let horizontal_old = split( + 10, + PhaseAxis::Horizontal, + &[1, 1, 1], + vec![leaf(1), leaf(2), leaf(3)], + ); + let horizontal_new = split( + 10, + PhaseAxis::Horizontal, + &[1, 2, 1], + vec![leaf(1), leaf(2), leaf(3)], + ); + let horizontal_req = + generated_request(&horizontal_old, &horizontal_new, rect(0, 0, 400, 100)); + let horizontal_plan = plan_no_overlap(&horizontal_req).unwrap(); + assert_eq!( + actions(&horizontal_plan), + vec![PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal, + }] + ); + assert!(validate_plan_continuous(&horizontal_req, &horizontal_plan)); + + let vertical_old = split( + 10, + PhaseAxis::Vertical, + &[1, 1, 1], + vec![leaf(1), leaf(2), leaf(3)], + ); + let vertical_new = split( + 10, + PhaseAxis::Vertical, + &[1, 2, 1], + vec![leaf(1), leaf(2), leaf(3)], + ); + let vertical_req = generated_request(&vertical_old, &vertical_new, rect(0, 0, 100, 400)); + let vertical_plan = plan_no_overlap(&vertical_req).unwrap(); + assert_eq!( + actions(&vertical_plan), + vec![PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }] + ); + assert!(validate_plan_continuous(&vertical_req, &vertical_plan)); + } + + #[test] + fn mixed_single_phase_accepts_distinct_axis_actions_when_proven() { + let req = request(vec![ + window(1, rect(0, 0, 100, 100), rect(0, 0, 150, 100)), + window(2, rect(200, 0, 300, 100), rect(200, 0, 300, 150)), + ]); + let planned = plan_no_overlap_explained(&req).unwrap(); + + assert_eq!(planned.explanation.strategy, PlanStrategy::MixedSinglePhase); + assert_eq!(planned.plan.phases.len(), 1); + assert_eq!( + planned.plan.phases[0].action, + MultiphasePhaseAction::Mixed(vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + ]) + ); + assert_eq!( + planned.explanation.phases[0].reason, + PhaseReason::MixedAxisActions + ); + assert_eq!(planned.explanation.phases[0].nodes, vec![id(1), id(2)]); + assert!(validate_plan_continuous(&req, &planned.plan)); + } + + #[test] + fn mixed_single_phase_accepts_move_and_scale_when_proven() { + let req = request(vec![ + window(1, rect(0, 0, 80, 80), rect(40, 0, 120, 80)), + window(2, rect(200, 0, 280, 80), rect(200, 0, 280, 120)), + ]); + let planned = plan_no_overlap_explained(&req).unwrap(); + + assert_eq!(planned.explanation.strategy, PlanStrategy::MixedSinglePhase); + assert_eq!( + planned.plan.phases[0].action, + MultiphasePhaseAction::Mixed(vec![ + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + ]) + ); + assert_eq!( + planned.explanation.phases[0].reason, + PhaseReason::MixedAxisActions + ); + assert_eq!(planned.explanation.phases[0].nodes, vec![id(1), id(2)]); + assert!(validate_plan_continuous(&req, &planned.plan)); + } + + #[test] + fn single_window_one_axis_group_is_still_multiphase_plannable() { + let req = request(vec![window(1, rect(0, 0, 100, 100), rect(40, 0, 140, 100))]); + let planned = plan_no_overlap_explained(&req).unwrap(); + + assert_eq!(planned.explanation.strategy, PlanStrategy::SingleAction); + assert_eq!( + planned.plan.phases[0].action, + MultiphasePhaseAction::Uniform(PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }) + ); + assert_eq!(planned.explanation.phases[0].nodes, vec![id(1)]); + assert!(validate_plan_continuous(&req, &planned.plan)); + } + + #[test] + fn mixed_single_phase_still_rejects_diagonal_per_window_motion() { + let req = request(vec![ + window(1, rect(0, 0, 80, 80), rect(40, 40, 120, 120)), + window(2, rect(200, 0, 280, 80), rect(200, 0, 280, 120)), + ]); + + assert_eq!(plan_no_overlap(&req), Err(MultiphaseError::NoPlan)); + let diagnostic = plan_no_overlap_with_diagnostics(&req).unwrap_err(); + let rejection = MultiphasePlanFailure::InvalidPhaseStep { + action: PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal, + }, + node_id: id(1), + }; + assert_eq!(diagnostic.forward, rejection); + assert_eq!(diagnostic.reverse, Some(rejection)); + } + + #[test] + fn generated_nested_size_redistribution_scales_parent_axis_first() { + let old = split( + 10, + PhaseAxis::Horizontal, + &[1, 1], + vec![ + leaf(1), + split(11, PhaseAxis::Vertical, &[1, 1], vec![leaf(2), leaf(3)]), + ], + ); + let new = split( + 10, + PhaseAxis::Horizontal, + &[1, 3], + vec![ + leaf(1), + split(11, PhaseAxis::Vertical, &[3, 1], vec![leaf(2), leaf(3)]), + ], + ); + let req = generated_request(&old, &new, rect(0, 0, 400, 100)); + let planned = plan_no_overlap_explained(&req).unwrap(); + let plan = &planned.plan; + assert_eq!( + actions(plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + ] + ); + assert_eq!(step_to(plan, 0, id(1)), rect(0, 0, 100, 100)); + assert_eq!(step_to(plan, 0, id(2)), rect(100, 0, 400, 50)); + assert_eq!(step_to(plan, 0, id(3)), rect(100, 50, 400, 100)); + assert_eq!( + planned.explanation.strategy, + PlanStrategy::HierarchyOrderedScales + ); + assert_eq!( + planned.explanation.phases[0].reason, + PhaseReason::ParentAxisBeforeChildAxis { + parent_axis: PhaseAxis::Horizontal, + parent_depth: 1, + child_axis: PhaseAxis::Vertical, + child_depth: 2, + } + ); + assert_eq!( + planned.explanation.phases[0].nodes, + vec![id(1), id(2), id(3)] + ); + assert_eq!(planned.explanation.phases[1].nodes, vec![id(2), id(3)]); + assert_eq!( + planned.explanation.validation, + ValidationExplanation::passed() + ); + assert!(validate_plan_continuous(&req, plan)); + } + + #[test] + fn orientation_change_shrinks_moves_then_grows() { + let req = request(vec![ + window(1, rect(0, 0, 200, 400), rect(0, 0, 400, 200)), + window(2, rect(200, 0, 400, 400), rect(0, 200, 400, 400)), + ]); + let planned = plan_no_overlap_explained(&req).unwrap(); + let plan = &planned.plan; + + assert_eq!( + planned.explanation.strategy, + PlanStrategy::OrientationChange { + from_axis: PhaseAxis::Horizontal, + } + ); + assert_eq!( + actions(plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal, + }, + ] + ); + assert_eq!(step_to(plan, 0, id(1)), rect(0, 0, 200, 200)); + assert_eq!(step_to(plan, 0, id(2)), rect(200, 200, 400, 400)); + assert_eq!(step_to(plan, 1, id(2)), rect(0, 200, 200, 400)); + assert_eq!(step_to(plan, 2, id(1)), rect(0, 0, 400, 200)); + assert_eq!(step_to(plan, 2, id(2)), rect(0, 200, 400, 400)); + assert!(validate_plan_continuous(&req, plan)); + } + + #[test] + fn two_axis_redistribution_without_hierarchy_still_falls_back() { + let req = request(vec![ + window(1, rect(0, 0, 200, 100), rect(0, 0, 100, 100)), + window(2, rect(200, 0, 400, 50), rect(100, 0, 400, 75)), + window(3, rect(200, 50, 400, 100), rect(100, 75, 400, 100)), + ]); + assert_eq!(plan_no_overlap(&req), Err(MultiphaseError::NoPlan)); + } + + #[test] + fn generated_stack_extractions_plan_for_both_axes_and_directions() { + let horizontal_old = split( + 10, + PhaseAxis::Horizontal, + &[1, 1], + vec![ + leaf(1), + split(11, PhaseAxis::Vertical, &[1, 1], vec![leaf(2), leaf(3)]), + ], + ); + let horizontal_new = split( + 10, + PhaseAxis::Horizontal, + &[1, 2, 1], + vec![leaf(1), leaf(2), leaf(3)], + ); + assert_generated_case_plans(&horizontal_old, &horizontal_new, rect(0, 0, 400, 100)); + assert_generated_case_plans(&horizontal_new, &horizontal_old, rect(0, 0, 400, 100)); + + let vertical_old = split( + 20, + PhaseAxis::Vertical, + &[1, 1], + vec![ + leaf(1), + split(21, PhaseAxis::Horizontal, &[1, 1], vec![leaf(2), leaf(3)]), + ], + ); + let vertical_new = split( + 20, + PhaseAxis::Vertical, + &[1, 2, 1], + vec![leaf(1), leaf(2), leaf(3)], + ); + assert_generated_case_plans(&vertical_old, &vertical_new, rect(0, 0, 100, 400)); + assert_generated_case_plans(&vertical_new, &vertical_old, rect(0, 0, 100, 400)); + } + + #[test] + fn stack_extraction_with_resized_moving_child_still_moves_before_growth() { + let old = split( + 10, + PhaseAxis::Horizontal, + &[1, 1], + vec![ + leaf(1), + split(11, PhaseAxis::Vertical, &[1, 1], vec![leaf(2), leaf(3)]), + ], + ); + let new = split( + 10, + PhaseAxis::Horizontal, + &[1, 1, 1], + vec![leaf(1), leaf(2), leaf(3)], + ); + let req = generated_request(&old, &new, rect(0, 0, 300, 120)); + let planned = plan_no_overlap_explained(&req).unwrap(); + let plan = &planned.plan; + + assert_eq!( + planned.explanation.strategy, + PlanStrategy::SpaceThenOrthogonalGrowth { + axis: PhaseAxis::Horizontal, + } + ); + assert_eq!( + actions(plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + ] + ); + assert_eq!(step_to(plan, 0, id(1)), rect(0, 0, 100, 120)); + assert_eq!(step_to(plan, 0, id(2)), rect(200, 0, 300, 60)); + assert_eq!(step_to(plan, 0, id(3)), rect(200, 60, 300, 120)); + assert_eq!(step_to(plan, 1, id(2)), rect(100, 0, 200, 60)); + assert_eq!(step_to(plan, 2, id(2)), rect(100, 0, 200, 120)); + assert_eq!(step_to(plan, 2, id(3)), rect(200, 0, 300, 120)); + assert!(validate_plan_continuous(&req, plan)); + } + + #[test] + fn three_child_stack_extraction_plans_without_linear_fallback() { + let old = split( + 10, + PhaseAxis::Horizontal, + &[1, 1], + vec![ + leaf(1), + split( + 11, + PhaseAxis::Vertical, + &[1, 1, 1], + vec![leaf(2), leaf(3), leaf(4)], + ), + ], + ); + let new = split( + 10, + PhaseAxis::Horizontal, + &[1, 1, 1], + vec![ + leaf(1), + leaf(3), + split(11, PhaseAxis::Vertical, &[1, 1], vec![leaf(2), leaf(4)]), + ], + ); + let req = generated_request(&old, &new, rect(0, 0, 600, 300)); + let planned = plan_no_overlap_explained(&req).unwrap(); + + assert_eq!( + planned.explanation.strategy, + PlanStrategy::SpaceThenOrthogonalGrowth { + axis: PhaseAxis::Horizontal, + } + ); + assert_eq!( + actions(&planned.plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + ] + ); + assert!(validate_plan_continuous(&req, &planned.plan)); + } + + #[test] + fn validated_phase_paths_accept_interrupted_reverse_route() { + let a_current = rect(50, 0, 150, 50); + let b_current = rect(50, 50, 150, 100); + let req = request(vec![ + window(1, a_current, rect(0, 0, 100, 100)), + window(2, b_current, rect(100, 0, 200, 100)), + ]); + let paths = vec![ + vec![ + (a_current, rect(0, 0, 100, 50)), + (rect(0, 0, 100, 50), rect(0, 0, 100, 100)), + ], + vec![ + (b_current, rect(100, 50, 200, 100)), + (rect(100, 50, 200, 100), rect(100, 0, 200, 100)), + ], + ]; + + let plan = validate_phase_paths(&req, &paths).unwrap(); + assert_eq!( + actions(&plan), + vec![ + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical, + }, + ] + ); + assert!(validate_plan_continuous(&req, &plan)); + } + + #[test] + fn bounded_generated_supported_split_tree_corpus_is_deterministic() { + let mut cases = vec![]; + for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] { + let child_axis = axis.other(); + let bounds = bounds_for_axis(axis); + + push_generated_case_bidirectional( + &mut cases, + split(10, axis, &[1, 1], vec![leaf(1), leaf(2)]), + split(10, axis, &[1, 1], vec![leaf(2), leaf(1)]), + bounds, + ); + push_generated_case_bidirectional( + &mut cases, + split(10, axis, &[1, 1, 1], vec![leaf(1), leaf(2), leaf(3)]), + split(10, axis, &[1, 2, 1], vec![leaf(1), leaf(2), leaf(3)]), + bounds, + ); + push_generated_case_bidirectional( + &mut cases, + split( + 10, + axis, + &[1, 1], + vec![ + leaf(1), + split(11, child_axis, &[1, 1], vec![leaf(2), leaf(3)]), + ], + ), + split(10, axis, &[1, 2, 1], vec![leaf(1), leaf(2), leaf(3)]), + bounds, + ); + push_generated_case_bidirectional( + &mut cases, + split( + 10, + axis, + &[1, 1], + vec![ + split(11, child_axis, &[1, 1], vec![leaf(1), leaf(2)]), + leaf(3), + ], + ), + split(10, axis, &[1, 2, 1], vec![leaf(1), leaf(2), leaf(3)]), + bounds, + ); + push_generated_case_bidirectional( + &mut cases, + split( + 10, + axis, + &[1, 1], + vec![ + leaf(1), + split(11, child_axis, &[1, 1], vec![leaf(2), leaf(3)]), + ], + ), + split( + 10, + axis, + &[1, 3], + vec![ + leaf(1), + split(11, child_axis, &[3, 1], vec![leaf(2), leaf(3)]), + ], + ), + bounds, + ); + push_generated_case_bidirectional( + &mut cases, + split( + 10, + axis, + &[1, 1], + vec![ + split(11, child_axis, &[1, 1], vec![leaf(1), leaf(2)]), + leaf(3), + ], + ), + split( + 10, + axis, + &[3, 1], + vec![ + split(11, child_axis, &[1, 3], vec![leaf(1), leaf(2)]), + leaf(3), + ], + ), + bounds, + ); + } + + assert_eq!(cases.len(), 24); + for (old, new, bounds) in cases { + assert_generated_case_plans_deterministically(&old, &new, bounds); + } + } + + #[test] + fn stack_extraction_creates_space_before_moving_child() { + let req = request(vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 200, 100), + to: rect(0, 0, 100, 100), + hierarchy: Default::default(), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(200, 0, 400, 50), + to: rect(100, 0, 300, 100), + hierarchy: Default::default(), + }, + MultiphaseWindow { + node_id: id(3), + from: rect(200, 50, 400, 100), + to: rect(300, 0, 400, 100), + hierarchy: Default::default(), + }, + ]); + let plan = plan_no_overlap(&req).unwrap(); + assert_eq!( + actions(&plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal + }, + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical + }, + ] + ); + assert_eq!(plan.phases[0].steps[0].to, rect(0, 0, 100, 100)); + assert_eq!(plan.phases[0].steps[1].to, rect(300, 50, 400, 100)); + assert_eq!(plan.phases[1].steps[0].to, rect(100, 0, 300, 50)); + assert_eq!(plan.phases[2].steps[0].to, rect(100, 0, 300, 100)); + assert_eq!(plan.phases[2].steps[1].to, rect(300, 0, 400, 100)); + assert!(validate_plan_continuous(&req, &plan)); + } + + #[test] + fn stack_extraction_reverse_replays_phases_in_reverse() { + let req = request(vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 100, 100), + to: rect(0, 0, 200, 100), + hierarchy: Default::default(), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(100, 0, 300, 100), + to: rect(200, 0, 400, 50), + hierarchy: Default::default(), + }, + MultiphaseWindow { + node_id: id(3), + from: rect(300, 0, 400, 100), + to: rect(200, 50, 400, 100), + hierarchy: Default::default(), + }, + ]); + let planned = plan_no_overlap_explained(&req).unwrap(); + let plan = &planned.plan; + assert_eq!( + actions(plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical + }, + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal + }, + ] + ); + assert_eq!( + planned.explanation.strategy, + PlanStrategy::ReversedForwardPlan { + original: Box::new(PlanStrategy::SpaceThenOrthogonalGrowth { + axis: PhaseAxis::Horizontal + }) + } + ); + assert!(validate_plan_continuous(&req, plan)); + } + + #[test] + fn vertical_stack_extraction_creates_space_before_moving_child() { + let req = request(vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 100, 200), + to: rect(0, 0, 100, 100), + hierarchy: Default::default(), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(0, 200, 50, 400), + to: rect(0, 100, 100, 300), + hierarchy: Default::default(), + }, + MultiphaseWindow { + node_id: id(3), + from: rect(50, 200, 100, 400), + to: rect(0, 300, 100, 400), + hierarchy: Default::default(), + }, + ]); + let plan = plan_no_overlap(&req).unwrap(); + assert_eq!( + actions(&plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical + }, + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Vertical + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal + }, + ] + ); + assert_eq!(plan.phases[0].steps[0].to, rect(0, 0, 100, 100)); + assert_eq!(plan.phases[0].steps[1].to, rect(50, 300, 100, 400)); + assert_eq!(plan.phases[1].steps[0].to, rect(0, 100, 50, 300)); + assert_eq!(plan.phases[2].steps[0].to, rect(0, 100, 100, 300)); + assert_eq!(plan.phases[2].steps[1].to, rect(0, 300, 100, 400)); + assert!(validate_plan_continuous(&req, &plan)); + } + + #[test] + fn vertical_stack_extraction_with_clearance_still_plans() { + let old = split( + 20, + PhaseAxis::Vertical, + &[1, 1], + vec![ + leaf(1), + split(21, PhaseAxis::Horizontal, &[1, 1], vec![leaf(2), leaf(3)]), + ], + ); + let new = split( + 20, + PhaseAxis::Vertical, + &[1, 2, 1], + vec![leaf(1), leaf(2), leaf(3)], + ); + let mut req = generated_request(&old, &new, rect(0, 0, 100, 400)); + req.clearance = 10; + let planned = plan_no_overlap_explained(&req).unwrap(); + + assert_eq!( + planned.explanation.strategy, + PlanStrategy::SpaceThenOrthogonalGrowth { + axis: PhaseAxis::Vertical, + } + ); + assert!(validate_plan_continuous(&req, &planned.plan)); + } + + #[test] + fn vertical_stack_extraction_reverse_replays_phases_in_reverse() { + let req = request(vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 100, 100), + to: rect(0, 0, 100, 200), + hierarchy: Default::default(), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(0, 100, 100, 300), + to: rect(0, 200, 50, 400), + hierarchy: Default::default(), + }, + MultiphaseWindow { + node_id: id(3), + from: rect(0, 300, 100, 400), + to: rect(50, 200, 100, 400), + hierarchy: Default::default(), + }, + ]); + let plan = plan_no_overlap(&req).unwrap(); + assert_eq!( + actions(&plan), + vec![ + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Horizontal + }, + PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Vertical + }, + PhaseAction { + kind: PhaseKind::Scale, + axis: PhaseAxis::Vertical + }, + ] + ); + assert!(validate_plan_continuous(&req, &plan)); + } + + #[test] + fn unsupported_diagonal_motion_falls_back_to_linear() { + let req = request(vec![MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 100, 100), + to: rect(100, 100, 200, 200), + hierarchy: Default::default(), + }]); + assert_eq!(plan_no_overlap(&req), Err(MultiphaseError::NoPlan)); + let diagnostic = plan_no_overlap_with_diagnostics(&req).unwrap_err(); + assert_eq!(diagnostic.forward, MultiphasePlanFailure::NoPattern); + assert_eq!(diagnostic.reverse, Some(MultiphasePlanFailure::NoPattern)); + let mut expected = no_pattern_attempts(PlanDirection::Forward); + expected.extend(no_pattern_attempts(PlanDirection::Reverse)); + assert_eq!(diagnostic.attempted, expected); + } + + #[test] + fn diagnostics_report_shrink_bound_rejections() { + let req = MultiphaseRequest { + bounds: rect(0, 0, 400, 100), + clearance: 0, + windows: vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 200, 100), + to: rect(0, 0, 10, 100), + hierarchy: Default::default(), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(200, 0, 400, 100), + to: rect(10, 0, 400, 100), + hierarchy: Default::default(), + }, + ], + }; + + assert!(matches!( + plan_no_overlap_with_diagnostics(&req).unwrap_err().forward, + MultiphasePlanFailure::ShrinkBound { + axis: PhaseAxis::Horizontal, + available: 10, + required: 50, + } + )); + } + + #[test] + fn diagnostics_report_candidate_validation_rejections() { + let req = request(vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 60, 60), + to: rect(180, 0, 240, 60), + hierarchy: Default::default(), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(90, 0, 150, 60), + to: rect(90, 0, 150, 60), + hierarchy: Default::default(), + }, + ]); + let rejection = + MultiphasePlanFailure::Validation(MultiphaseValidationError::PhaseOverlap { + phase: 0, + a: id(1), + b: id(2), + }); + let diagnostic = plan_no_overlap_with_diagnostics(&req).unwrap_err(); + + assert_eq!(diagnostic.forward, rejection); + assert_eq!(diagnostic.reverse, Some(rejection)); + assert_eq!( + diagnostic.attempted[0], + RejectedStrategy { + direction: PlanDirection::Forward, + strategy: PlanStrategy::SingleAction, + reason: rejection, + } + ); + assert!(diagnostic.attempted.iter().any(|attempt| *attempt + == RejectedStrategy { + direction: PlanDirection::Reverse, + strategy: PlanStrategy::SingleAction, + reason: rejection, + })); + } + + #[test] + fn hierarchy_metadata_classifies_depth_and_mono_transitions() { + let source = MultiphaseHierarchyPosition { + parent: Some(id(10)), + depth: 2, + sibling_index: Some(0), + split_axis: Some(PhaseAxis::Vertical), + nearest_horizontal_split_depth: Some(1), + nearest_vertical_split_depth: Some(2), + ..Default::default() + }; + let target = MultiphaseHierarchyPosition { + parent: Some(id(11)), + depth: 1, + sibling_index: Some(2), + split_axis: Some(PhaseAxis::Horizontal), + nearest_horizontal_split_depth: Some(1), + ..Default::default() + }; + assert_eq!( + MultiphaseWindowHierarchy::new(source, target).transition, + MultiphaseHierarchyTransition::Ascending + ); + assert_eq!(source.nearest_vertical_split_depth, Some(2)); + + let entering_mono = MultiphaseWindowHierarchy::new( + source, + MultiphaseHierarchyPosition { + parent_is_mono: true, + mono_active: true, + ..target + }, + ); + assert_eq!( + entering_mono.transition, + MultiphaseHierarchyTransition::EnteringMono + ); + assert_eq!( + entering_mono.reversed().transition, + MultiphaseHierarchyTransition::ExitingMono + ); + } + + #[test] + fn continuous_validation_rejects_narrow_mid_phase_overlap() { + let req = request(vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 10, 10), + to: rect(100, 0, 110, 10), + hierarchy: Default::default(), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(13, 0, 14, 10), + to: rect(13, 0, 14, 10), + hierarchy: Default::default(), + }, + ]); + let plan = MultiphasePlan { + phases: vec![MultiphasePhase { + action: MultiphasePhaseAction::Uniform(PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }), + steps: vec![MultiphaseStep { + node_id: id(1), + from: rect(0, 0, 10, 10), + to: rect(100, 0, 110, 10), + }], + }], + }; + + assert_eq!( + validate_plan_continuous_diagnostic(&req, &plan), + Err(MultiphaseValidationError::PhaseOverlap { + phase: 0, + a: id(1), + b: id(2), + }) + ); + } + + #[test] + fn continuous_validation_allows_edge_touching_motion() { + let req = request(vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 10, 10), + to: rect(10, 0, 20, 10), + hierarchy: Default::default(), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(20, 0, 30, 10), + to: rect(20, 0, 30, 10), + hierarchy: Default::default(), + }, + ]); + let plan = MultiphasePlan { + phases: vec![MultiphasePhase { + action: MultiphasePhaseAction::Uniform(PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }), + steps: vec![MultiphaseStep { + node_id: id(1), + from: rect(0, 0, 10, 10), + to: rect(10, 0, 20, 10), + }], + }], + }; + + assert!(validate_plan_continuous(&req, &plan)); + } + + #[test] + fn continuous_validation_rejects_mixed_phase_action_count_mismatch() { + let req = request(vec![ + window(1, rect(0, 0, 40, 40), rect(40, 0, 80, 40)), + window(2, rect(100, 0, 140, 40), rect(100, 0, 140, 80)), + ]); + let plan = MultiphasePlan { + phases: vec![MultiphasePhase { + action: MultiphasePhaseAction::Mixed(vec![PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }]), + steps: vec![ + MultiphaseStep { + node_id: id(1), + from: rect(0, 0, 40, 40), + to: rect(40, 0, 80, 40), + }, + MultiphaseStep { + node_id: id(2), + from: rect(100, 0, 140, 40), + to: rect(100, 0, 140, 80), + }, + ], + }], + }; + + assert_eq!( + validate_plan_continuous_diagnostic(&req, &plan), + Err(MultiphaseValidationError::PhaseActionCount { + phase: 0, + actions: 1, + steps: 2, + }) + ); + } + + #[test] + fn continuous_validation_rejects_stale_step_start_rect() { + let req = request(vec![MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 10, 10), + to: rect(20, 0, 30, 10), + hierarchy: Default::default(), + }]); + let plan = MultiphasePlan { + phases: vec![MultiphasePhase { + action: MultiphasePhaseAction::Uniform(PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }), + steps: vec![MultiphaseStep { + node_id: id(1), + from: rect(5, 0, 15, 10), + to: rect(20, 0, 30, 10), + }], + }], + }; + + assert_eq!( + validate_plan_continuous_diagnostic(&req, &plan), + Err(MultiphaseValidationError::StaleStepStart { + phase: 0, + node_id: id(1), + }) + ); + } + + #[test] + fn motion_groups_split_disjoint_layout_changes() { + let windows = vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 100, 100), + to: rect(100, 0, 200, 100), + hierarchy: Default::default(), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(100, 0, 200, 100), + to: rect(0, 0, 100, 100), + hierarchy: Default::default(), + }, + MultiphaseWindow { + node_id: id(3), + from: rect(300, 0, 400, 100), + to: rect(400, 0, 500, 100), + hierarchy: Default::default(), + }, + ]; + assert_eq!(partition_motion_groups(&windows, 0), vec![vec![0, 1], vec![2]]); + } + + #[test] + fn motion_groups_are_transitive() { + let windows = vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 100, 100), + to: rect(80, 0, 180, 100), + hierarchy: Default::default(), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(170, 0, 270, 100), + to: rect(250, 0, 350, 100), + hierarchy: Default::default(), + }, + MultiphaseWindow { + node_id: id(3), + from: rect(90, 0, 180, 100), + to: rect(180, 0, 260, 100), + hierarchy: Default::default(), + }, + ]; + assert_eq!(partition_motion_groups(&windows, 0), vec![vec![0, 1, 2]]); + } + + #[test] + fn motion_groups_join_across_animation_clearance() { + let windows = vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 100, 100), + to: rect(0, 0, 80, 100), + hierarchy: Default::default(), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(120, 0, 220, 100), + to: rect(110, 0, 210, 100), + hierarchy: Default::default(), + }, + ]; + assert_eq!(partition_motion_groups(&windows, 0), vec![vec![0], vec![1]]); + assert_eq!(partition_motion_groups(&windows, 10), vec![vec![0, 1]]); + } +} diff --git a/src/compositor.rs b/src/compositor.rs index 45d2a018..11f23808 100644 --- a/src/compositor.rs +++ b/src/compositor.rs @@ -360,6 +360,13 @@ fn start_compositor2( cpu_worker, ui_drag_enabled: Cell::new(true), ui_drag_threshold_squared: Cell::new(10), + animations: Default::default(), + layout_animations_requested: Default::default(), + layout_animations_active: Default::default(), + layout_animation_curve_override: Default::default(), + layout_animation_style_override: Default::default(), + layout_animation_batch: Default::default(), + suppress_animations_for_next_layout: Default::default(), toplevels: Default::default(), const_40hz_latch: Default::default(), tray_item_ids: Default::default(), diff --git a/src/config/handler.rs b/src/config/handler.rs index 526c1cde..336da9ff 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -658,17 +658,23 @@ impl ConfigProxyHandler { } fn handle_seat_move(&self, seat: Seat, direction: Direction) -> Result<(), CphError> { - let seat = self.get_seat(seat)?; - seat.move_focused(direction.into()); - Ok(()) + self.state.with_layout_animations(|| { + let seat = self.get_seat(seat)?; + seat.move_focused(direction.into()); + Ok(()) + }) } fn handle_window_move(&self, window: Window, direction: Direction) -> Result<(), CphError> { - let window = self.get_window(window)?; - if let Some(c) = toplevel_parent_container(&*window) { - c.move_child(window, direction.into()); - } - Ok(()) + self.state.with_layout_animations(|| { + let window = self.get_window(window)?; + if let Some(float) = window.tl_data().float.get() { + float.move_by_direction(direction.into()); + } else if let Some(c) = toplevel_parent_container(&*window) { + c.move_child(window, direction.into()); + } + Ok(()) + }) } fn handle_get_repeat_rate(&self, seat: Seat) -> Result<(), CphError> { @@ -986,6 +992,31 @@ impl ConfigProxyHandler { self.state.set_ui_drag_threshold(threshold.max(1)); } + fn handle_set_animations_enabled(&self, enabled: bool) { + self.state.set_animations_enabled(enabled); + } + + fn handle_set_animation_duration_ms(&self, duration_ms: u32) { + self.state + .set_animation_duration_ms(duration_ms.min(10_000)); + } + + fn handle_set_animation_curve(&self, curve: u32) { + self.state.set_animation_curve(curve); + } + + fn handle_set_animation_style(&self, style: u32) { + if !self.state.set_animation_style(style) { + log::warn!("Ignoring invalid animation style"); + } + } + + fn handle_set_animation_cubic_bezier(&self, x1: f32, y1: f32, x2: f32, y2: f32) { + if !self.state.set_animation_cubic_bezier(x1, y1, x2, y2) { + log::warn!("Ignoring invalid animation cubic-bezier curve"); + } + } + fn handle_set_direct_scanout_enabled( &self, device: Option, @@ -1724,9 +1755,11 @@ impl ConfigProxyHandler { } fn handle_set_seat_mono(&self, seat: Seat, mono: bool) -> Result<(), CphError> { - let seat = self.get_seat(seat)?; - seat.set_mono(mono); - Ok(()) + self.state.with_layout_animations(|| { + let seat = self.get_seat(seat)?; + seat.set_mono(mono); + Ok(()) + }) } fn handle_get_window_mono(&self, window: Window) -> Result<(), CphError> { @@ -1740,11 +1773,13 @@ impl ConfigProxyHandler { } fn handle_set_window_mono(&self, window: Window, mono: bool) -> Result<(), CphError> { - let window = self.get_window(window)?; - if let Some(c) = toplevel_parent_container(&*window) { - c.set_mono(mono.then_some(window.as_ref())); - } - Ok(()) + self.state.with_layout_animations(|| { + let window = self.get_window(window)?; + if let Some(c) = toplevel_parent_container(&*window) { + c.set_mono(mono.then_some(window.as_ref())); + } + Ok(()) + }) } fn handle_get_seat_split(&self, seat: Seat) -> Result<(), CphError> { @@ -1759,15 +1794,19 @@ impl ConfigProxyHandler { } fn handle_set_seat_split(&self, seat: Seat, axis: Axis) -> Result<(), CphError> { - let seat = self.get_seat(seat)?; - seat.set_split(axis.into()); - Ok(()) + self.state.with_layout_animations(|| { + let seat = self.get_seat(seat)?; + seat.set_split(axis.into()); + Ok(()) + }) } fn handle_seat_toggle_tab(&self, seat: Seat) -> Result<(), CphError> { - let seat = self.get_seat(seat)?; - seat.toggle_tab(); - Ok(()) + self.state.with_layout_animations(|| { + let seat = self.get_seat(seat)?; + seat.toggle_tab(); + Ok(()) + }) } fn handle_seat_make_group( @@ -1776,27 +1815,35 @@ impl ConfigProxyHandler { axis: Axis, ephemeral: bool, ) -> Result<(), CphError> { - let seat = self.get_seat(seat)?; - seat.make_group(axis.into(), ephemeral); - Ok(()) + self.state.with_layout_animations(|| { + let seat = self.get_seat(seat)?; + seat.make_group(axis.into(), ephemeral); + Ok(()) + }) } fn handle_seat_change_group_opposite(&self, seat: Seat) -> Result<(), CphError> { - let seat = self.get_seat(seat)?; - seat.change_group_opposite(); - Ok(()) + self.state.with_layout_animations(|| { + let seat = self.get_seat(seat)?; + seat.change_group_opposite(); + Ok(()) + }) } fn handle_seat_equalize(&self, seat: Seat, recursive: bool) -> Result<(), CphError> { - let seat = self.get_seat(seat)?; - seat.equalize(recursive); - Ok(()) + self.state.with_layout_animations(|| { + let seat = self.get_seat(seat)?; + seat.equalize(recursive); + Ok(()) + }) } fn handle_seat_move_tab(&self, seat: Seat, right: bool) -> Result<(), CphError> { - let seat = self.get_seat(seat)?; - seat.move_tab(right); - Ok(()) + self.state.with_layout_animations(|| { + let seat = self.get_seat(seat)?; + seat.move_tab(right); + Ok(()) + }) } fn handle_get_window_split(&self, window: Window) -> Result<(), CphError> { @@ -1811,11 +1858,13 @@ impl ConfigProxyHandler { } fn handle_set_window_split(&self, window: Window, axis: Axis) -> Result<(), CphError> { - let window = self.get_window(window)?; - if let Some(c) = toplevel_parent_container(&*window) { - c.set_split(axis.into()); - } - Ok(()) + self.state.with_layout_animations(|| { + let window = self.get_window(window)?; + if let Some(c) = toplevel_parent_container(&*window) { + c.set_split(axis.into()); + } + Ok(()) + }) } fn handle_add_shortcut( @@ -1955,9 +2004,11 @@ impl ConfigProxyHandler { } fn handle_set_seat_floating(&self, seat: Seat, floating: bool) -> Result<(), CphError> { - let seat = self.get_seat(seat)?; - seat.set_floating(floating); - Ok(()) + self.state.with_linear_layout_animations(|| { + let seat = self.get_seat(seat)?; + seat.set_floating(floating); + Ok(()) + }) } fn handle_get_window_floating(&self, window: Window) -> Result<(), CphError> { @@ -1969,9 +2020,11 @@ impl ConfigProxyHandler { } fn handle_set_window_floating(&self, window: Window, floating: bool) -> Result<(), CphError> { - let window = self.get_window(window)?; - toplevel_set_floating(&self.state, window, floating); - Ok(()) + self.state.with_linear_layout_animations(|| { + let window = self.get_window(window)?; + toplevel_set_floating(&self.state, window, floating); + Ok(()) + }) } fn handle_add_pollable(self: &Rc, fd: i32) -> Result<(), CphError> { @@ -2721,8 +2774,10 @@ impl ConfigProxyHandler { dx2: i32, dy2: i32, ) -> Result<(), CphError> { - self.get_window(window)?.tl_resize(dx1, dy1, dx2, dy2); - Ok(()) + self.state.with_layout_animations(|| { + self.get_window(window)?.tl_resize(dx1, dy1, dx2, dy2); + Ok(()) + }) } fn handle_window_exists(&self, window: Window) { @@ -3193,6 +3248,17 @@ impl ConfigProxyHandler { ClientMessage::SetUiDragThreshold { threshold } => { self.handle_set_ui_drag_threshold(threshold) } + ClientMessage::SetAnimationsEnabled { enabled } => { + self.handle_set_animations_enabled(enabled) + } + ClientMessage::SetAnimationDurationMs { duration_ms } => { + self.handle_set_animation_duration_ms(duration_ms) + } + ClientMessage::SetAnimationCurve { curve } => self.handle_set_animation_curve(curve), + ClientMessage::SetAnimationStyle { style } => self.handle_set_animation_style(style), + ClientMessage::SetAnimationCubicBezier { x1, y1, x2, y2 } => { + self.handle_set_animation_cubic_bezier(x1, y1, x2, y2) + } ClientMessage::SetXScalingMode { mode } => self .handle_set_x_scaling_mode(mode) .wrn("set_x_scaling_mode")?, diff --git a/src/ifs/wl_seat.rs b/src/ifs/wl_seat.rs index 5fba889c..74ff4eda 100644 --- a/src/ifs/wl_seat.rs +++ b/src/ifs/wl_seat.rs @@ -936,6 +936,9 @@ impl WlSeatGlobal { { c.move_child(tl, direction); self.maybe_schedule_warp_mouse_to_focus(); + } else if let Some(float) = data.float.get() { + float.move_by_direction(direction); + self.maybe_schedule_warp_mouse_to_focus(); } } diff --git a/src/ifs/wl_surface/commit_timeline.rs b/src/ifs/wl_surface/commit_timeline.rs index 80ac2b4f..93372993 100644 --- a/src/ifs/wl_surface/commit_timeline.rs +++ b/src/ifs/wl_surface/commit_timeline.rs @@ -628,6 +628,11 @@ fn schedule_async_upload( { back_tex_opt = None; } + if let Some(back_tex) = &back_tex_opt + && Rc::strong_count(back_tex) > 1 + { + back_tex_opt = None; + } let damage_full = || { back.damage.clear(); back.damage.damage(slice::from_ref(&buf.rect)); diff --git a/src/ifs/wl_surface/x_surface.rs b/src/ifs/wl_surface/x_surface.rs index 1c3e295c..4f6db63c 100644 --- a/src/ifs/wl_surface/x_surface.rs +++ b/src/ifs/wl_surface/x_surface.rs @@ -1,7 +1,7 @@ use { crate::{ ifs::wl_surface::{ - SurfaceExt, WlSurface, WlSurfaceError, + PendingState, SurfaceExt, WlSurface, WlSurfaceError, x_surface::{xwayland_surface_v1::XwaylandSurfaceV1, xwindow::Xwindow}, }, leaks::Tracker, @@ -30,6 +30,22 @@ impl SurfaceExt for XSurface { win.node_layer() } + fn before_apply_commit( + self: Rc, + pending: &mut PendingState, + ) -> Result<(), WlSurfaceError> { + if pending + .buffer + .as_ref() + .is_some_and(|buffer| buffer.is_none()) + && self.surface.buffer.is_some() + && let Some(xwindow) = self.xwindow.get() + { + xwindow.queue_spawn_out(); + } + Ok(()) + } + fn after_apply_commit(self: Rc) { if let Some(xwindow) = self.xwindow.get() { xwindow.map_status_changed(); @@ -45,6 +61,7 @@ impl SurfaceExt for XSurface { } self.surface.unset_ext(); if let Some(xwindow) = self.xwindow.take() { + xwindow.queue_spawn_out(); xwindow.tl_destroy(); xwindow.data.window.set(None); xwindow.data.surface_id.set(None); diff --git a/src/ifs/wl_surface/x_surface/xwindow.rs b/src/ifs/wl_surface/x_surface/xwindow.rs index f1c68730..80ea8b1b 100644 --- a/src/ifs/wl_surface/x_surface/xwindow.rs +++ b/src/ifs/wl_surface/x_surface/xwindow.rs @@ -1,5 +1,6 @@ use { crate::{ + animation::RetainedToplevel, client::Client, cursor::KnownCursor, fixed::Fixed, @@ -252,6 +253,11 @@ impl Xwindow { self.x.surface.buffer.is_some() && self.data.info.mapped.get() } + pub fn queue_spawn_out(&self) { + self.toplevel_data + .queue_spawn_out(self, self.tl_animation_snapshot()); + } + fn map_change(&self) -> Change { match (self.may_be_mapped(), self.is_mapped()) { (true, false) => Change::Map, @@ -274,6 +280,7 @@ impl Xwindow { match map_change { Change::None => return, Change::Unmap => { + self.queue_spawn_out(); self.data .info .pending_extents @@ -514,6 +521,10 @@ impl ToplevelNodeBase for Xwindow { Some(self.x.surface.clone()) } + fn tl_animation_snapshot(&self) -> Option> { + RetainedToplevel::capture_surface(&self.x.surface, (0, 0)) + } + fn tl_admits_children(&self) -> bool { false } diff --git a/src/ifs/wl_surface/xdg_surface.rs b/src/ifs/wl_surface/xdg_surface.rs index 9b5130d7..ad87c951 100644 --- a/src/ifs/wl_surface/xdg_surface.rs +++ b/src/ifs/wl_surface/xdg_surface.rs @@ -226,6 +226,10 @@ pub trait XdgSurfaceExt: Debug { // nothing } + fn prepare_unmap(&self) { + // nothing + } + fn extents_changed(&self) { // nothing } @@ -664,6 +668,15 @@ impl SurfaceExt for XdgSurface { if let Some(serial) = pending.serial.take() { self.applied_serial.set(serial); } + if pending + .buffer + .as_ref() + .is_some_and(|buffer| buffer.is_none()) + && self.surface.buffer.is_some() + && let Some(ext) = self.ext.get() + { + ext.prepare_unmap(); + } Ok(()) } diff --git a/src/ifs/wl_surface/xdg_surface/xdg_toplevel.rs b/src/ifs/wl_surface/xdg_surface/xdg_toplevel.rs index 768a8367..6a7f395f 100644 --- a/src/ifs/wl_surface/xdg_surface/xdg_toplevel.rs +++ b/src/ifs/wl_surface/xdg_surface/xdg_toplevel.rs @@ -2,6 +2,7 @@ pub mod xdg_dialog_v1; use { crate::{ + animation::RetainedToplevel, bugs, bugs::Bugs, client::{Client, ClientError}, @@ -259,6 +260,7 @@ impl XdgToplevelRequestHandler for XdgToplevel { type Error = XdgToplevelError; fn destroy(&self, _req: Destroy, _slf: &Rc) -> Result<(), Self::Error> { + self.queue_spawn_out(); self.tl_destroy(); self.xdg.unset_ext(); { @@ -398,6 +400,11 @@ impl XdgToplevelRequestHandler for XdgToplevel { } impl XdgToplevel { + fn queue_spawn_out(&self) { + self.toplevel_data + .queue_spawn_out(self, self.tl_animation_snapshot()); + } + fn map( self: &Rc, parent: Option<&XdgToplevel>, @@ -779,6 +786,11 @@ impl ToplevelNodeBase for XdgToplevel { Some(self.xdg.surface.clone()) } + fn tl_animation_snapshot(&self) -> Option> { + let geo = self.xdg.geometry(); + RetainedToplevel::capture_surface(&self.xdg.surface, (-geo.x1(), -geo.y1())) + } + fn tl_restack_popups(&self) { self.xdg.restack_popups(); } @@ -818,6 +830,10 @@ impl XdgSurfaceExt for XdgToplevel { self.after_commit(None); } + fn prepare_unmap(&self) { + self.queue_spawn_out(); + } + fn extents_changed(&self) { self.toplevel_data.pos.set(self.xdg.extents.get()); self.tl_extents_changed(); diff --git a/src/main.rs b/src/main.rs index 5a566f9b..161d3d99 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,6 +48,7 @@ mod leaks; mod tracy; mod acceptor; mod allocator; +mod animation; mod async_engine; mod backend; mod backends; diff --git a/src/renderer.rs b/src/renderer.rs index e601a0e0..b80e3f18 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -1,7 +1,11 @@ use { crate::{ + animation::{ + RetainedContent, RetainedExitFrame, RetainedExitLayer, RetainedSurface, + RetainedToplevel, + }, cmm::cmm_render_intent::RenderIntent, - gfx_api::{AcquireSync, AlphaMode, GfxApiOpt, ReleaseSync, SampleRect}, + gfx_api::{AcquireSync, AlphaMode, BufferResv, GfxApiOpt, ReleaseSync, SampleRect}, ifs::wl_surface::{ SurfaceBuffer, WlSurface, x_surface::xwindow::Xwindow, @@ -14,8 +18,8 @@ use { state::State, theme::{Color, CornerRadius}, tree::{ - ContainerNode, DisplayNode, FloatNode, OutputNode, PlaceholderNode, ToplevelData, - ToplevelNodeBase, WorkspaceNode, tab_bar::TabBar, + ContainerNode, DisplayNode, FloatNode, Node, OutputNode, PlaceholderNode, ToplevelData, + ToplevelNode, ToplevelNodeBase, WorkspaceNode, tab_bar::TabBar, }, }, std::{ops::Deref, rc::Rc, slice}, @@ -200,14 +204,22 @@ impl Renderer<'_> { self.render_workspace(&ws, x, y); } } + let now = self.state.now_nsec(); + let exit_frames = self.state.animations.exit_frames(now); + self.render_exit_frames(&exit_frames, RetainedExitLayer::Tiled, &opos); macro_rules! render_stacked { ($stack:expr) => { for stacked in $stack.iter() { if stacked.node_visible() { self.base.sync(); let pos = stacked.node_absolute_position(); - if pos.intersects(&opos) { - let (x, y) = opos.translate(pos.x1(), pos.y1()); + let visual = self.state.animations.visual_rect( + stacked.node_id(), + pos, + self.state.now_nsec(), + ); + if visual.intersects(&opos) { + let (x, y) = opos.translate(visual.x1(), visual.y1()); stacked.node_render(self, x, y, None); } } @@ -215,6 +227,7 @@ impl Renderer<'_> { }; } render_stacked!(self.state.root.stacked); + self.render_exit_frames(&exit_frames, RetainedExitLayer::Floating, &opos); // Flush RoundedFillRect ops from container/float borders so they don't // sort after (and render on top of) layer-shell CopyTexture ops. self.base.sync(); @@ -453,6 +466,265 @@ impl Renderer<'_> { .fill_boxes2(&rd.border_rects, &c, srgb, perceptual, x, y); } + fn presentation_child_body( + &self, + container: &ContainerNode, + child: &Rc, + body: Rect, + ) -> Rect { + let abs = body.move_(container.abs_x1.get(), container.abs_y1.get()); + let visual = self + .state + .animations + .visual_rect(child.node_id(), abs, self.state.now_nsec()); + visual.move_(-container.abs_x1.get(), -container.abs_y1.get()) + } + + fn render_child_or_snapshot( + &mut self, + child: &Rc, + x: i32, + y: i32, + bounds: Option<&Rect>, + ) { + if let Some(retained) = self + .state + .animations + .retained_snapshot(child.node_id(), self.state.now_nsec()) + { + self.render_retained_toplevel(&retained, x, y, bounds); + } else { + child.node_render(self, x, y, bounds); + } + } + + fn render_retained_toplevel( + &mut self, + retained: &RetainedToplevel, + x: i32, + y: i32, + bounds: Option<&Rect>, + ) { + let (x, y) = self + .base + .scale_point(x + retained.offset.0, y + retained.offset.1); + self.render_retained_surface_scaled(&retained.surface, x, y, None, bounds); + } + + fn render_exit_frames( + &mut self, + frames: &[RetainedExitFrame], + layer: RetainedExitLayer, + output_rect: &Rect, + ) { + for frame in frames { + if frame.layer != layer || !frame.rect.intersects(output_rect) { + continue; + } + self.render_exit_frame(frame, output_rect); + } + } + + fn render_exit_frame(&mut self, frame: &RetainedExitFrame, output_rect: &Rect) { + let (x, y) = output_rect.translate(frame.rect.x1(), frame.rect.y1()); + let inset = frame.frame_inset; + if inset > 0 { + let color = if frame.active { + self.state.theme.colors.active_border.get() + } else { + self.state.theme.colors.border.get() + }; + self.render_rounded_frame( + Rect::new_sized_saturating(0, 0, frame.rect.width(), frame.rect.height()), + &color, + self.state.theme.corner_radius.get(), + inset, + x, + y, + ); + } + let body = Rect::new_sized_saturating( + x + inset, + y + inset, + frame.rect.width() - 2 * inset, + frame.rect.height() - 2 * inset, + ); + if body.is_empty() { + return; + } + if inset > 0 && !self.state.theme.corner_radius.get().is_zero() { + let inner_cr = self.scale_corner_radius( + self.state + .theme + .corner_radius + .get() + .expanded_by(-(inset as f32)), + ); + self.corner_radius = Some(inner_cr); + } + self.render_window_body_background(body); + let bounds = self.base.scale_rect(body); + self.stretch = if frame.source_body_size != body.size() { + Some(self.base.scale_point(body.width(), body.height())) + } else { + None + }; + self.render_retained_toplevel(&frame.retained, body.x1(), body.y1(), Some(&bounds)); + self.stretch = None; + self.corner_radius = None; + } + + fn render_window_body_background(&mut self, body: Rect) { + if body.is_empty() { + return; + } + let color = self.state.theme.colors.background.get(); + let srgb_srgb = self.state.color_manager.srgb_gamma22(); + let srgb = &srgb_srgb.linear; + let perceptual = RenderIntent::Perceptual; + self.base.sync(); + if let Some(cr) = self.corner_radius + && !cr.is_zero() + { + self.base + .fill_rounded_rect(body, &color, None, srgb, perceptual, cr, 0.0); + } else { + let bounds = self.base.scale_rect(body); + self.base + .fill_scaled_boxes(slice::from_ref(&bounds), &color, None, srgb, perceptual); + } + } + + fn render_retained_surface_scaled( + &mut self, + retained: &RetainedSurface, + x: i32, + y: i32, + pos_rel: Option<(i32, i32)>, + bounds: Option<&Rect>, + ) { + let stretch = self.stretch.take(); + let corner_radius = self.corner_radius.take(); + let mut size = retained.size; + if let Some((x_rel, y_rel)) = pos_rel { + let (x, y) = self.base.scale_point(x_rel, y_rel); + let (w, h) = self.base.scale_point(x_rel + size.0, y_rel + size.1); + size = (w - x, h - y); + } else { + size = self.base.scale_point(size.0, size.1); + } + let mut stretched_source = None; + if let Some(s) = stretch { + if let RetainedContent::Texture { source, .. } = &retained.content { + let mut source = *source; + if size.0 > 0 && size.1 > 0 { + let sx = s.0 as f32 / size.0 as f32; + let sy = s.1 as f32 / size.1 as f32; + source.x2 *= sx; + source.y2 *= sy; + } + stretched_source = Some(source); + } + size = s; + } + for child in &retained.below { + let (x1, y1) = self.base.scale_point(child.offset.0, child.offset.1); + self.render_retained_surface_scaled(child, x + x1, y + y1, Some(child.offset), bounds); + } + self.corner_radius = corner_radius; + self.render_retained_content(retained, stretched_source, x, y, size, bounds); + for child in &retained.above { + let (x1, y1) = self.base.scale_point(child.offset.0, child.offset.1); + self.render_retained_surface_scaled(child, x + x1, y + y1, Some(child.offset), bounds); + } + } + + fn render_retained_content( + &mut self, + retained: &RetainedSurface, + stretched_source: Option, + x: i32, + y: i32, + size: (i32, i32), + bounds: Option<&Rect>, + ) { + let corner_radius = self.corner_radius.take(); + match &retained.content { + RetainedContent::Texture { + texture, + buffer, + source, + alpha, + color_description, + render_intent, + alpha_mode, + opaque, + } => { + let source = stretched_source.unwrap_or(*source); + if let Some(cr) = corner_radius { + self.base.render_rounded_texture( + texture, + *alpha, + x, + y, + Some(source), + Some(size), + self.base.scale, + bounds, + Some(buffer.clone() as Rc), + AcquireSync::Unnecessary, + buffer.release_sync, + color_description, + *render_intent, + *alpha_mode, + cr, + ); + } else { + self.base.render_texture( + texture, + *alpha, + x, + y, + Some(source), + Some(size), + self.base.scale, + bounds, + Some(buffer.clone() as Rc), + AcquireSync::Unnecessary, + buffer.release_sync, + *opaque, + color_description, + *render_intent, + *alpha_mode, + ); + } + } + RetainedContent::Color { + color, + alpha, + color_description, + render_intent, + } => { + if let Some(rect) = Rect::new_sized(x, y, size.0, size.1) { + let rect = match bounds { + None => rect, + Some(bounds) => rect.intersect(*bounds), + }; + if !rect.is_empty() { + self.base.sync(); + self.base.fill_scaled_boxes( + &[rect], + color, + *alpha, + &color_description.linear, + *render_intent, + ); + } + } + } + } + } + pub fn render_container(&mut self, container: &ContainerNode, x: i32, y: i32) { self.render_container_decorations(container, x, y); @@ -465,6 +737,7 @@ impl Renderer<'_> { } } let mb = container.mono_body.get(); + let visual_mb = self.presentation_child_body(container, &child.node, mb); if self.state.theme.sizes.gap.get() != 0 { let bw = self.state.theme.sizes.border_width.get(); let border_color = self.state.theme.colors.border.get(); @@ -476,10 +749,10 @@ impl Renderer<'_> { }; if !child.node.node_is_container() { let frame = Rect::new_sized_saturating( - mb.x1() - bw, - mb.y1() - bw, - mb.width() + 2 * bw, - mb.height() + 2 * bw, + visual_mb.x1() - bw, + visual_mb.y1() - bw, + visual_mb.width() + 2 * bw, + visual_mb.height() + 2 * bw, ); self.render_rounded_frame( frame, @@ -491,14 +764,17 @@ impl Renderer<'_> { ); } } - let body = mb.move_(x, y); - let body = self.base.scale_rect(body); - let content = container.mono_content.get(); - self.stretch = if content.width() != mb.width() || content.height() != mb.height() { - Some(self.base.scale_point(mb.width(), mb.height())) - } else { - None - }; + let body = visual_mb.move_(x, y); + let content = container + .mono_content + .get() + .at_point(visual_mb.x1(), visual_mb.y1()); + self.stretch = + if content.width() != visual_mb.width() || content.height() != visual_mb.height() { + Some(self.base.scale_point(visual_mb.width(), visual_mb.height())) + } else { + None + }; if self.state.theme.sizes.gap.get() != 0 && !child.node.node_is_container() { let cr = self.state.theme.corner_radius.get(); if !cr.is_zero() { @@ -507,9 +783,16 @@ impl Renderer<'_> { self.corner_radius = Some(inner_cr); } } - child - .node - .node_render(self, x + content.x1(), y + content.y1(), Some(&body)); + if !child.node.node_is_container() { + self.render_window_body_background(body); + } + let body = self.base.scale_rect(body); + self.render_child_or_snapshot( + &child.node, + x + content.x1(), + y + content.y1(), + Some(&body), + ); self.stretch = None; self.corner_radius = None; } else { @@ -524,10 +807,13 @@ impl Renderer<'_> { }; let cr = self.state.theme.corner_radius.get(); for child in container.children.iter() { - let body = child.body.get(); - if body.x1() >= container.width.get() || body.y1() >= container.height.get() { + let layout_body = child.body.get(); + if layout_body.x1() >= container.width.get() + || layout_body.y1() >= container.height.get() + { break; } + let body = self.presentation_child_body(container, &child.node, layout_body); if gap != 0 { let c = if child.border_color_is_focused.get() { &focused_border_color @@ -544,7 +830,7 @@ impl Renderer<'_> { self.render_rounded_frame(frame, c, cr, bw, x, y); } } - let content = child.content.get(); + let content = child.content.get().at_point(body.x1(), body.y1()); self.stretch = if content.width() != body.width() || content.height() != body.height() { Some(self.base.scale_point(body.width(), body.height())) @@ -556,10 +842,16 @@ impl Renderer<'_> { self.corner_radius = Some(inner_cr); } let body = body.move_(x, y); + if !child.node.node_is_container() { + self.render_window_body_background(body); + } let body = self.base.scale_rect(body); - child - .node - .node_render(self, x + content.x1(), y + content.y1(), Some(&body)); + self.render_child_or_snapshot( + &child.node, + x + content.x1(), + y + content.y1(), + Some(&body), + ); self.stretch = None; self.corner_radius = None; } @@ -793,6 +1085,10 @@ impl Renderer<'_> { _ => return, }; let pos = floating.position.get(); + let visual = + self.state + .animations + .visual_rect(floating.node_id(), pos, self.state.now_nsec()); let theme = &self.state.theme; let bw = theme.sizes.border_width.get(); let bc = if floating.active.get() { @@ -801,16 +1097,27 @@ impl Renderer<'_> { theme.colors.border.get() }; let cr = theme.corner_radius.get(); - let outer = Rect::new_sized_saturating(0, 0, pos.width(), pos.height()); + let outer = Rect::new_sized_saturating(0, 0, visual.width(), visual.height()); self.render_rounded_frame(outer, &bc, cr, bw, x, y); - let body = - Rect::new_sized_saturating(x + bw, y + bw, pos.width() - 2 * bw, pos.height() - 2 * bw); + let body = Rect::new_sized_saturating( + x + bw, + y + bw, + visual.width() - 2 * bw, + visual.height() - 2 * bw, + ); let scissor_body = self.base.scale_rect(body); + self.stretch = if pos.width() != visual.width() || pos.height() != visual.height() { + Some(self.base.scale_point(body.width(), body.height())) + } else { + None + }; if !cr.is_zero() { let inner_cr = self.scale_corner_radius(cr.expanded_by(-(bw as f32))); self.corner_radius = Some(inner_cr); } - child.node_render(self, body.x1(), body.y1(), Some(&scissor_body)); + self.render_window_body_background(body); + self.render_child_or_snapshot(&child, body.x1(), body.y1(), Some(&scissor_body)); + self.stretch = None; self.corner_radius = None; } diff --git a/src/state.rs b/src/state.rs index 4ae761a0..42dd909d 100644 --- a/src/state.rs +++ b/src/state.rs @@ -2,6 +2,17 @@ use { crate::{ acceptor::Acceptor, allocator::BufferObject, + animation::{ + AnimationCurve, AnimationState, AnimationStyle, AnimationTick, RetainedExitLayer, + RetainedToplevel, + expand_damage_rect, + multiphase::{ + MultiphasePhase, MultiphasePlan, MultiphasePlanFailure, MultiphaseRequest, + MultiphaseWindow, MultiphaseWindowHierarchy, + partition_motion_groups, plan_no_overlap_with_diagnostics, validate_phase_paths, + }, + spawn_in_start_rect, + }, async_engine::{AsyncEngine, SpawnedFuture}, backend::{ Backend, BackendConnectorState, BackendConnectorStateSerials, BackendDrmDevice, @@ -102,11 +113,10 @@ use { time::Time, tree::{ ContainerNode, ContainerSplit, Direction, DisplayNode, FindTreeUsecase, FloatNode, - FoundNode, LatchListener, Node, NodeIds, NodeVisitorBase, OutputNode, PlaceholderNode, - TearingMode, TileState, ToplevelData, ToplevelIdentifier, ToplevelNode, - ToplevelNodeBase, Transform, VrrMode, WorkspaceDisplayOrder, WorkspaceNode, - WorkspaceNodeId, - WsMoveConfig, generic_node_visitor, move_ws_to_output, + FoundNode, LatchListener, Node, NodeId, NodeIds, NodeVisitorBase, OutputNode, + PlaceholderNode, TearingMode, TileState, ToplevelData, ToplevelIdentifier, + ToplevelNode, ToplevelNodeBase, Transform, VrrMode, WorkspaceDisplayOrder, + WorkspaceNode, WorkspaceNodeId, WsMoveConfig, generic_node_visitor, move_ws_to_output, }, udmabuf::UdmabufHolder, utils::{ @@ -154,6 +164,98 @@ use { uapi::{OwnedFd, c}, }; +#[derive(Clone)] +pub(crate) struct LayoutAnimationCandidate { + node_id: NodeId, + old: Rect, + new: Rect, + curve: AnimationCurve, + style: AnimationStyle, + hierarchy: MultiphaseWindowHierarchy, +} + +fn coalesce_layout_animation_candidates( + candidates: Vec, +) -> Vec { + let mut merged: Vec = vec![]; + for candidate in candidates { + if let Some(existing) = merged + .iter_mut() + .find(|existing| existing.node_id == candidate.node_id) + { + existing.new = candidate.new; + existing.curve = candidate.curve; + existing.style = candidate.style; + existing.hierarchy = MultiphaseWindowHierarchy::new( + existing.hierarchy.source, + candidate.hierarchy.target, + ); + } else { + merged.push(candidate); + } + } + merged +} + +fn layout_animation_group_uses_plain( + candidates: &[LayoutAnimationCandidate], + group: &[usize], +) -> bool { + group + .iter() + .any(|&idx| candidates[idx].style == AnimationStyle::Plain) +} + +fn bridged_retarget_plan( + request: &MultiphaseRequest, + candidates: &[LayoutAnimationCandidate], + group: &[usize], + bridge_paths: &[Vec<(Rect, Rect)>], + bridge_phase_count: usize, + follow_phases: &[MultiphasePhase], +) -> Result { + let mut paths = vec![]; + for (group_pos, &idx) in group.iter().enumerate() { + let candidate = &candidates[idx]; + let window = request.windows[group_pos]; + let Some(bridge_path) = bridge_paths.get(group_pos) else { + return Err(MultiphasePlanFailure::NoPattern); + }; + let mut path = bridge_path.clone(); + let mut current = path + .last() + .map(|(_, to)| *to) + .unwrap_or(window.from); + while path.len() < bridge_phase_count { + path.push((current, current)); + } + if current != candidate.old { + return Err(MultiphasePlanFailure::NoPattern); + } + for phase in follow_phases { + match phase + .steps + .iter() + .find(|step| step.node_id == candidate.node_id) + { + Some(step) => { + if step.from != current { + return Err(MultiphasePlanFailure::NoPattern); + } + path.push((step.from, step.to)); + current = step.to; + } + None => path.push((current, current)), + } + } + if current != window.to { + return Err(MultiphasePlanFailure::NoPattern); + } + paths.push(path); + } + validate_phase_paths(request, &paths) +} + pub struct State { pub pid: c::pid_t, pub kb_ctx: KbvmContext, @@ -264,6 +366,13 @@ pub struct State { pub cpu_worker: Rc, pub ui_drag_enabled: Cell, pub ui_drag_threshold_squared: Cell, + pub animations: AnimationState, + pub layout_animations_requested: Cell, + pub layout_animations_active: Cell, + pub layout_animation_curve_override: Cell>, + pub layout_animation_style_override: Cell>, + pub(crate) layout_animation_batch: RefCell>>, + pub suppress_animations_for_next_layout: Cell, pub toplevels: CopyHashMap>, pub const_40hz_latch: EventSource, pub tray_item_ids: TrayItemIds, @@ -812,7 +921,14 @@ impl State { pub fn map_tiled(self: &Rc, node: Rc) { let seat = self.seat_queue.last(); - self.do_map_tiled(seat.as_deref(), node.clone()); + let animate_new_app_map = node.tl_data().parent.is_none() + && node.tl_data().kind.is_app_window() + && !node.tl_data().visible.get(); + if animate_new_app_map { + self.with_layout_animations(|| self.do_map_tiled(seat.as_deref(), node.clone())); + } else { + self.do_map_tiled(seat.as_deref(), node.clone()); + } self.focus_after_map(node, seat.as_deref()); } @@ -847,7 +963,7 @@ impl State { mut height: i32, workspace: &Rc, abs_pos: Option<(i32, i32)>, - ) { + ) -> Rc { width += 2 * self.theme.sizes.border_width.get(); height += 2 * self.theme.sizes.border_width.get() + self.theme.title_plus_underline_height(); @@ -878,8 +994,9 @@ impl State { } Rect::new_sized_saturating(x1, y1, width, height) }; - FloatNode::new(self, workspace, position, node.clone()); + let float = FloatNode::new(self, workspace, position, node.clone()); self.focus_after_map(node, self.seat_queue.last().as_deref()); + float } fn focus_after_map(&self, node: Rc, seat: Option<&Rc>) { @@ -1115,6 +1232,12 @@ impl State { self.pending_screencast_reallocs_or_reconfigures.clear(); self.pending_placeholder_render_textures.clear(); self.pending_container_tab_render_textures.clear(); + self.animations.clear(); + self.layout_animations_requested.set(false); + self.layout_animations_active.set(false); + self.layout_animation_curve_override.set(None); + self.layout_animation_style_override.set(None); + self.suppress_animations_for_next_layout.set(false); self.render_ctx_watchers.clear(); self.workspace_watchers.clear(); self.toplevel_lists.clear(); @@ -1461,6 +1584,532 @@ impl State { self.eng.now().msec() } + pub fn queue_tiled_animation( + self: &Rc, + node_id: NodeId, + old: Rect, + new: Rect, + ) { + let curve = self + .layout_animation_curve_override + .get() + .unwrap_or_else(|| self.animations.curve.get()); + self.queue_layout_animation( + node_id, + old, + new, + curve, + MultiphaseWindowHierarchy::default(), + ); + } + + pub fn queue_tiled_animation_with_hierarchy( + self: &Rc, + node_id: NodeId, + old: Rect, + new: Rect, + hierarchy: MultiphaseWindowHierarchy, + ) { + let curve = self + .layout_animation_curve_override + .get() + .unwrap_or_else(|| self.animations.curve.get()); + self.queue_layout_animation(node_id, old, new, curve, hierarchy); + } + + pub fn queue_linear_layout_animation( + self: &Rc, + node_id: NodeId, + old: Rect, + new: Rect, + ) { + self.queue_layout_animation( + node_id, + old, + new, + AnimationCurve::Linear, + MultiphaseWindowHierarchy::default(), + ); + } + + fn queue_layout_animation( + self: &Rc, + node_id: NodeId, + old: Rect, + new: Rect, + curve: AnimationCurve, + hierarchy: MultiphaseWindowHierarchy, + ) { + if !self.animations.enabled.get() + || !self.layout_animations_active.get() + || self.suppress_animations_for_next_layout.get() + { + return; + } + let (old_output, old_scale) = { + let (x, y) = old.center(); + let (output, _, _) = self.find_closest_output(x, y); + (output.id, output.global.persistent.scale.get()) + }; + let (new_output, new_scale) = { + let (x, y) = new.center(); + let (output, _, _) = self.find_closest_output(x, y); + (output.id, output.global.persistent.scale.get()) + }; + if old_output != new_output || old_scale != new_scale { + return; + } + let candidate = LayoutAnimationCandidate { + node_id, + old, + new, + curve, + style: self + .layout_animation_style_override + .get() + .unwrap_or_else(|| self.animations.style.get()), + hierarchy, + }; + if let Some(batch) = self.layout_animation_batch.borrow_mut().as_mut() { + batch.push(candidate); + return; + } + self.start_layout_animation_candidate(candidate, self.now_nsec()); + } + + fn start_layout_animation_candidate( + self: &Rc, + candidate: LayoutAnimationCandidate, + now_nsec: u64, + ) { + let started = self.animations.set_target( + candidate.node_id, + candidate.old, + candidate.new, + None, + now_nsec, + self.animations.duration_ms.get(), + candidate.curve, + ); + if started { + self.damage(expand_damage_rect( + candidate.old.union(candidate.new), + self.theme.sizes.border_width.get().max(0), + )); + self.ensure_animation_tick(); + } + } + + pub fn begin_layout_animation_batch(&self) { + self.layout_animation_batch + .borrow_mut() + .get_or_insert_with(Vec::new); + } + + pub fn finish_layout_animation_batch(self: &Rc) { + let Some(candidates) = self.layout_animation_batch.borrow_mut().take() else { + return; + }; + let candidates = coalesce_layout_animation_candidates(candidates); + if candidates.is_empty() { + return; + } + let now = self.now_nsec(); + let windows: Vec<_> = candidates + .iter() + .map(|candidate| { + MultiphaseWindow::with_hierarchy( + candidate.node_id, + self.animations + .visual_rect(candidate.node_id, candidate.old, now), + candidate.new, + candidate.hierarchy, + ) + }) + .collect(); + for group in partition_motion_groups(&windows, self.layout_animation_clearance()) { + if layout_animation_group_uses_plain(&candidates, &group) { + for idx in group { + self.start_layout_animation_candidate(candidates[idx].clone(), now); + } + continue; + } + if self.start_multiphase_layout_animation(&candidates, &windows, &group, now) { + continue; + } + for idx in group { + self.start_layout_animation_candidate(candidates[idx].clone(), now); + } + } + } + + fn layout_animation_clearance(&self) -> i32 { + let border = self.theme.sizes.border_width.get().max(0); + let gap = self.theme.sizes.gap.get().max(0); + if gap == 0 { border } else { gap + 2 * border } + } + + fn start_multiphase_layout_animation( + self: &Rc, + candidates: &[LayoutAnimationCandidate], + windows: &[MultiphaseWindow], + group: &[usize], + now_nsec: u64, + ) -> bool { + let request_windows: Vec<_> = group.iter().map(|&idx| windows[idx]).collect(); + let Some(first) = request_windows.first() else { + return false; + }; + let mut bounds = first.from.union(first.to); + for window in &request_windows[1..] { + bounds = bounds.union(window.from).union(window.to); + } + let request = MultiphaseRequest { + bounds, + windows: request_windows, + clearance: self.layout_animation_clearance(), + }; + if self.start_existing_phased_retarget(candidates, windows, group, &request, now_nsec) { + return true; + } + if self.start_bridged_phased_retarget(candidates, windows, group, &request, now_nsec) { + return true; + } + let plan = match plan_no_overlap_with_diagnostics(&request) { + Ok(plan) => plan, + Err(diagnostic) => { + log::debug!( + "falling back to plain layout animation for group {:?}: {:?}", + group, + diagnostic + ); + return false; + } + }; + self.start_multiphase_plan(candidates, windows, group, &plan.phases, now_nsec) + } + + fn start_existing_phased_retarget( + self: &Rc, + candidates: &[LayoutAnimationCandidate], + windows: &[MultiphaseWindow], + group: &[usize], + request: &MultiphaseRequest, + now_nsec: u64, + ) -> bool { + let mut paths = vec![]; + for &idx in group { + let candidate = &candidates[idx]; + let window = windows[idx]; + let Some(path) = + self.animations + .phased_route_to(candidate.node_id, window.to, now_nsec) + else { + return false; + }; + paths.push(path); + } + let plan = match validate_phase_paths(request, &paths) { + Ok(plan) => plan, + Err(error) => { + log::debug!( + "existing phased retarget rejected for group {:?}: {:?}", + group, + error + ); + return false; + } + }; + log::debug!("retargeting active phased animation for group {:?}", group); + self.start_multiphase_plan(candidates, windows, group, &plan.phases, now_nsec) + } + + fn start_bridged_phased_retarget( + self: &Rc, + candidates: &[LayoutAnimationCandidate], + windows: &[MultiphaseWindow], + group: &[usize], + request: &MultiphaseRequest, + now_nsec: u64, + ) -> bool { + let mut bridge_paths = vec![]; + let mut bridge_phase_count = 0; + let mut has_bridge = false; + for &idx in group { + let candidate = &candidates[idx]; + let window = windows[idx]; + if window.from == candidate.old { + bridge_paths.push(vec![]); + continue; + } + let Some(path) = + self.animations + .phased_route_to(candidate.node_id, candidate.old, now_nsec) + else { + return false; + }; + if !path.is_empty() { + has_bridge = true; + bridge_phase_count = bridge_phase_count.max(path.len()); + } + bridge_paths.push(path); + } + if !has_bridge { + return false; + } + + let settled_windows: Vec<_> = group + .iter() + .map(|&idx| { + let candidate = &candidates[idx]; + MultiphaseWindow::with_hierarchy( + candidate.node_id, + candidate.old, + candidate.new, + candidate.hierarchy, + ) + }) + .collect(); + let Some(first) = settled_windows.first() else { + return false; + }; + let mut bounds = first.from.union(first.to); + for window in &settled_windows[1..] { + bounds = bounds.union(window.from).union(window.to); + } + let settled_request = MultiphaseRequest { + bounds, + windows: settled_windows, + clearance: self.layout_animation_clearance(), + }; + let follow_plan = match plan_no_overlap_with_diagnostics(&settled_request) { + Ok(plan) => plan, + Err(diagnostic) => { + log::debug!( + "bridged phased retarget follow-up rejected for group {:?}: {:?}", + group, + diagnostic + ); + return false; + } + }; + let plan = match bridged_retarget_plan( + request, + candidates, + group, + &bridge_paths, + bridge_phase_count, + &follow_plan.phases, + ) { + Ok(plan) => plan, + Err(error) => { + log::debug!( + "bridged phased retarget rejected for group {:?}: {:?}", + group, + error + ); + return false; + } + }; + log::debug!("bridging active phased animation for group {:?}", group); + self.start_multiphase_plan(candidates, windows, group, &plan.phases, now_nsec) + } + + fn start_multiphase_plan( + self: &Rc, + candidates: &[LayoutAnimationCandidate], + windows: &[MultiphaseWindow], + group: &[usize], + plan_phases: &[crate::animation::multiphase::MultiphasePhase], + now_nsec: u64, + ) -> bool { + if plan_phases.is_empty() { + return false; + } + let mut entries = vec![]; + for &idx in group { + let candidate = &candidates[idx]; + let window = windows[idx]; + let mut current = window.from; + let mut damage = current.union(window.to); + let mut phases = vec![]; + for phase in plan_phases { + match phase + .steps + .iter() + .find(|step| step.node_id == candidate.node_id) + { + Some(step) => { + phases.push((step.from, step.to)); + damage = damage.union(step.from).union(step.to); + current = step.to; + } + None => phases.push((current, current)), + } + } + if current != window.to { + return false; + } + entries.push((candidate.clone(), phases, damage)); + } + let mut started_any = false; + for (candidate, phases, damage) in entries { + if self.animations.set_phased_target( + candidate.node_id, + phases, + None, + now_nsec, + self.animations.duration_ms.get(), + candidate.curve, + ) { + started_any = true; + self.damage(expand_damage_rect( + damage, + self.theme.sizes.border_width.get().max(0), + )); + } + } + if started_any { + self.ensure_animation_tick(); + } + started_any + } + + pub fn queue_spawn_in_animation( + self: &Rc, + node_id: NodeId, + target: Rect, + ) { + if !self.animations.enabled.get() || target.is_empty() { + return; + } + let start = spawn_in_start_rect(target); + let now = self.now_nsec(); + let started = self.animations.set_spawn_in( + node_id, + target, + None, + now, + self.animations.duration_ms.get(), + self.animations.curve.get(), + ); + if started { + self.damage(expand_damage_rect( + start.union(target), + self.theme.sizes.border_width.get().max(0), + )); + self.ensure_animation_tick(); + } + } + + pub fn queue_spawn_out_animation( + self: &Rc, + from: Rect, + frame_inset: i32, + retained: Rc, + active: bool, + layer: RetainedExitLayer, + ) { + if !self.animations.enabled.get() || from.is_empty() { + return; + } + let now = self.now_nsec(); + let started = self.animations.set_spawn_out( + from, + frame_inset, + retained, + active, + layer, + now, + self.animations.duration_ms.get(), + self.animations.curve.get(), + ); + if started { + self.damage(expand_damage_rect( + from, + self.theme.sizes.border_width.get().max(0), + )); + self.ensure_animation_tick(); + } + } + + pub fn set_animations_enabled(&self, enabled: bool) { + if self.animations.enabled.replace(enabled) && !enabled { + self.animations.clear(); + self.damage(self.root.extents.get()); + } + } + + pub fn set_animation_duration_ms(&self, duration_ms: u32) { + self.animations.duration_ms.set(duration_ms); + } + + pub fn set_animation_curve(&self, curve: u32) { + self.animations + .curve + .set(AnimationCurve::from_config(curve)); + } + + pub fn set_animation_style(&self, style: u32) -> bool { + let Some(style) = AnimationStyle::from_config(style) else { + return false; + }; + self.animations.style.set(style); + true + } + + pub fn set_animation_cubic_bezier(&self, x1: f32, y1: f32, x2: f32, y2: f32) -> bool { + let Some(curve) = AnimationCurve::from_cubic_bezier(x1, y1, x2, y2) else { + return false; + }; + self.animations.curve.set(curve); + true + } + + pub fn with_layout_animations(&self, f: impl FnOnce() -> T) -> T { + let prev_requested = self.layout_animations_requested.replace(true); + let prev_active = self.layout_animations_active.replace(true); + let res = f(); + self.layout_animations_requested.set(prev_requested); + self.layout_animations_active.set(prev_active); + res + } + + pub fn with_linear_layout_animations(&self, f: impl FnOnce() -> T) -> T { + let prev_requested = self.layout_animations_requested.replace(true); + let prev_active = self.layout_animations_active.replace(true); + let prev_curve = self + .layout_animation_curve_override + .replace(Some(AnimationCurve::Linear)); + let prev_style = self + .layout_animation_style_override + .replace(Some(AnimationStyle::Plain)); + let res = f(); + self.layout_animations_requested.set(prev_requested); + self.layout_animations_active.set(prev_active); + self.layout_animation_curve_override.set(prev_curve); + self.layout_animation_style_override.set(prev_style); + res + } + + fn ensure_animation_tick(self: &Rc) { + if self.animations.tick_is_active() { + return; + } + let outputs: Vec<_> = self.root.outputs.lock().values().cloned().collect(); + if outputs.is_empty() { + return; + } + let tick = Rc::new_cyclic(|weak| AnimationTick::new(self, weak)); + for output in &outputs { + tick.attach(output); + } + self.animations.set_tick(tick); + for output in &outputs { + self.damage(output.global.pos.get()); + } + } + pub fn output_extents_changed(&self) { self.root.update_extents(); for seat in self.globals.seats.lock().values() { @@ -1989,6 +2638,227 @@ impl State { } } +#[cfg(test)] +mod tests { + use { + super::*, + crate::animation::multiphase::MultiphaseHierarchyPosition, + }; + + fn rect(x1: i32, y1: i32, x2: i32, y2: i32) -> Rect { + Rect::new_saturating(x1, y1, x2, y2) + } + + fn hierarchy( + source: MultiphaseHierarchyPosition, + target: MultiphaseHierarchyPosition, + ) -> MultiphaseWindowHierarchy { + MultiphaseWindowHierarchy::new(source, target) + } + + fn candidate(node_id: u32, style: AnimationStyle) -> LayoutAnimationCandidate { + candidate_rects( + node_id, + rect(0, 0, 100, 100), + rect(100, 0, 200, 100), + style, + ) + } + + fn candidate_rects( + node_id: u32, + old: Rect, + new: Rect, + style: AnimationStyle, + ) -> LayoutAnimationCandidate { + LayoutAnimationCandidate { + node_id: NodeId(node_id), + old, + new, + curve: AnimationCurve::Linear, + style, + hierarchy: MultiphaseWindowHierarchy::default(), + } + } + + #[test] + fn plain_style_candidate_forces_group_plain() { + let candidates = vec![ + candidate(1, AnimationStyle::Multiphase), + candidate(2, AnimationStyle::Plain), + ]; + + assert!(!layout_animation_group_uses_plain(&candidates, &[0])); + assert!(layout_animation_group_uses_plain(&candidates, &[0, 1])); + } + + #[test] + fn bridged_retarget_handles_second_rotation_interrupt() { + let a_left = rect(0, 0, 100, 100); + let c_mid = rect(100, 0, 200, 100); + let c_left = a_left; + let a_mid = c_mid; + let c_current = rect(150, 50, 250, 100); + let c_mid_lane = rect(100, 50, 200, 100); + let candidates = vec![ + candidate_rects(1, a_left, a_mid, AnimationStyle::Multiphase), + candidate_rects(3, c_mid, c_left, AnimationStyle::Multiphase), + ]; + let request = MultiphaseRequest { + bounds: rect(0, 0, 250, 100), + windows: vec![ + MultiphaseWindow::new(NodeId(1), a_left, a_mid), + MultiphaseWindow::new(NodeId(3), c_current, c_left), + ], + clearance: 0, + }; + let settled_request = MultiphaseRequest { + bounds: rect(0, 0, 200, 100), + windows: vec![ + MultiphaseWindow::new(NodeId(1), a_left, a_mid), + MultiphaseWindow::new(NodeId(3), c_mid, c_left), + ], + clearance: 0, + }; + let follow_plan = plan_no_overlap_with_diagnostics(&settled_request).unwrap(); + let bridge_paths = vec![vec![], vec![(c_current, c_mid_lane), (c_mid_lane, c_mid)]]; + + let plan = bridged_retarget_plan( + &request, + &candidates, + &[0, 1], + &bridge_paths, + 2, + &follow_plan.phases, + ) + .unwrap(); + + assert!(plan + .phases + .iter() + .any(|phase| phase.steps.iter().any(|step| step.node_id == NodeId(1)))); + assert!(plan + .phases + .iter() + .any(|phase| phase.steps.iter().any(|step| step.node_id == NodeId(3)))); + } + + #[test] + fn layout_animation_candidates_coalesce_duplicate_nodes() { + let source = MultiphaseHierarchyPosition { + parent: Some(NodeId(10)), + depth: 2, + sibling_index: Some(1), + ..Default::default() + }; + let intermediate = MultiphaseHierarchyPosition { + parent: Some(NodeId(11)), + depth: 1, + sibling_index: Some(0), + ..Default::default() + }; + let target = MultiphaseHierarchyPosition { + parent: Some(NodeId(12)), + depth: 0, + sibling_index: Some(2), + ..Default::default() + }; + let second_source = MultiphaseHierarchyPosition { + parent: Some(NodeId(20)), + depth: 1, + sibling_index: Some(0), + ..Default::default() + }; + let second_target = MultiphaseHierarchyPosition { + parent: Some(NodeId(20)), + depth: 1, + sibling_index: Some(1), + ..Default::default() + }; + + let candidates = vec![ + LayoutAnimationCandidate { + node_id: NodeId(1), + old: rect(0, 0, 100, 100), + new: rect(0, 0, 80, 100), + curve: AnimationCurve::Linear, + style: AnimationStyle::Multiphase, + hierarchy: hierarchy(source, intermediate), + }, + LayoutAnimationCandidate { + node_id: NodeId(2), + old: rect(100, 0, 200, 100), + new: rect(120, 0, 220, 100), + curve: AnimationCurve::Linear, + style: AnimationStyle::Multiphase, + hierarchy: hierarchy(second_source, second_target), + }, + LayoutAnimationCandidate { + node_id: NodeId(1), + old: rect(0, 0, 80, 100), + new: rect(0, 0, 60, 100), + curve: AnimationCurve::from_config(4), + style: AnimationStyle::Plain, + hierarchy: hierarchy(intermediate, target), + }, + ]; + + let merged = coalesce_layout_animation_candidates(candidates); + + assert_eq!(merged.len(), 2); + assert_eq!(merged[0].node_id, NodeId(1)); + assert_eq!(merged[0].old, rect(0, 0, 100, 100)); + assert_eq!(merged[0].new, rect(0, 0, 60, 100)); + assert_eq!(merged[0].curve, AnimationCurve::from_config(4)); + assert_eq!(merged[0].style, AnimationStyle::Plain); + assert_eq!(merged[0].hierarchy, hierarchy(source, target)); + assert_eq!(merged[1].node_id, NodeId(2)); + assert_eq!(merged[1].old, rect(100, 0, 200, 100)); + assert_eq!(merged[1].new, rect(120, 0, 220, 100)); + assert_eq!(merged[1].hierarchy, hierarchy(second_source, second_target)); + } + + #[test] + fn layout_animation_candidates_keep_coalesced_layout_noops() { + let hierarchy = MultiphaseWindowHierarchy::default(); + let candidates = vec![ + LayoutAnimationCandidate { + node_id: NodeId(1), + old: rect(0, 0, 100, 100), + new: rect(0, 0, 80, 100), + curve: AnimationCurve::Linear, + style: AnimationStyle::Multiphase, + hierarchy, + }, + LayoutAnimationCandidate { + node_id: NodeId(1), + old: rect(0, 0, 80, 100), + new: rect(0, 0, 100, 100), + curve: AnimationCurve::Linear, + style: AnimationStyle::Plain, + hierarchy, + }, + LayoutAnimationCandidate { + node_id: NodeId(2), + old: rect(100, 0, 200, 100), + new: rect(120, 0, 220, 100), + curve: AnimationCurve::Linear, + style: AnimationStyle::Multiphase, + hierarchy, + }, + ]; + + let merged = coalesce_layout_animation_candidates(candidates); + + assert_eq!(merged.len(), 2); + assert_eq!(merged[0].node_id, NodeId(1)); + assert_eq!(merged[0].old, rect(0, 0, 100, 100)); + assert_eq!(merged[0].new, rect(0, 0, 100, 100)); + assert_eq!(merged[0].style, AnimationStyle::Plain); + assert_eq!(merged[1].node_id, NodeId(2)); + } +} + #[derive(Debug, Error)] pub enum ShmScreencopyError { #[error("There is no render context")] diff --git a/src/tree/container.rs b/src/tree/container.rs index 61ec00d1..b8de7b25 100644 --- a/src/tree/container.rs +++ b/src/tree/container.rs @@ -131,6 +131,8 @@ pub struct ContainerNode { pub content_height: Cell, pub sum_factors: Cell, pub layout_scheduled: Cell, + animate_next_layout: Cell, + pub mono_transition_animation_pending: Cell, compute_render_positions_scheduled: Cell, num_children: NumCell, pub children: LinkedList, @@ -238,6 +240,8 @@ impl ContainerNode { content_height: Cell::new(0), sum_factors: Cell::new(1.0), layout_scheduled: Cell::new(false), + animate_next_layout: Cell::new(false), + mono_transition_animation_pending: Cell::new(false), compute_render_positions_scheduled: Cell::new(false), num_children: NumCell::new(1), children, @@ -436,6 +440,10 @@ impl ContainerNode { } fn schedule_layout(self: &Rc) { + if self.state.layout_animations_requested.get() || self.state.layout_animations_active.get() + { + self.animate_next_layout.set(true); + } if !self.layout_scheduled.replace(true) { self.state.pending_container_layout.push(self.clone()); } @@ -467,6 +475,7 @@ impl ContainerNode { fn perform_layout(self: &Rc) { self.layout_scheduled.set(false); if self.num_children.get() == 0 { + self.mono_transition_animation_pending.set(false); return; } if let Some(child) = self.mono_child.get() { @@ -484,6 +493,7 @@ impl ContainerNode { self.damage(); } } + self.mono_transition_animation_pending.set(false); } fn perform_mono_layout(self: &Rc, child: &ContainerChild) { @@ -656,6 +666,7 @@ impl ContainerNode { op.child.factor.set(child_factor); self.sum_factors.set(sum_factors); // log::info!("pointer_move"); + self.state.suppress_animations_for_next_layout.set(true); self.schedule_layout_immediate(); } } @@ -816,6 +827,7 @@ impl ContainerNode { } } self.mono_child.set(child.clone()); + self.mono_transition_animation_pending.set(true); if child.is_some() { self.rebuild_tab_bar(); } else { @@ -1759,10 +1771,42 @@ enum SeatOpKind { pub async fn container_layout(state: Rc) { loop { - let container = state.pending_container_layout.pop().await; - if container.layout_scheduled.get() { - container.perform_layout(); + let first = state.pending_container_layout.pop().await; + let mut containers = vec![first]; + while let Some(container) = state.pending_container_layout.try_pop() { + containers.push(container); } + let mut animated = vec![]; + let mut immediate = vec![]; + for container in containers { + if !container.layout_scheduled.get() { + continue; + } + let animate = container.animate_next_layout.replace(false) + && !state.suppress_animations_for_next_layout.get(); + if animate { + animated.push(container); + } else { + immediate.push(container); + } + } + if !animated.is_empty() { + let prev_active = state.layout_animations_active.replace(true); + state.begin_layout_animation_batch(); + for container in animated { + container.perform_layout(); + } + state.finish_layout_animation_batch(); + state.layout_animations_active.set(prev_active); + } + if !immediate.is_empty() { + let prev_active = state.layout_animations_active.replace(false); + for container in immediate { + container.perform_layout(); + } + state.layout_animations_active.set(prev_active); + } + state.suppress_animations_for_next_layout.set(false); } } @@ -2259,6 +2303,11 @@ impl ContainingNode for ContainerNode { } // log::info!("cnode_remove_child2"); self.rebuild_tab_bar(); + if self.state.animations.enabled.get() + && !self.state.suppress_animations_for_next_layout.get() + { + self.animate_next_layout.set(true); + } self.schedule_layout(); self.cancel_seat_ops(); self.child_removed.trigger(); diff --git a/src/tree/float.rs b/src/tree/float.rs index dc0b44f4..a57c2b91 100644 --- a/src/tree/float.rs +++ b/src/tree/float.rs @@ -31,6 +31,9 @@ use { }; tree_id!(FloatNodeId); + +const COMMAND_MOVE_DELTA: i32 = 100; + pub struct FloatNode { pub id: FloatNodeId, pub state: Rc, @@ -153,6 +156,13 @@ impl FloatNode { _ => return, }; let pos = self.position.get(); + let spawn_in_pending = { + let data = child.tl_data(); + data.spawn_in_pending.get() && data.kind.is_app_window() && !data.is_fullscreen.get() + }; + if spawn_in_pending && self.visible.get() { + self.state.queue_spawn_in_animation(self.id.into(), pos); + } let theme = &self.state.theme; let bw = theme.sizes.border_width.get(); let cpos = Rect::new_sized_saturating( @@ -363,6 +373,50 @@ impl FloatNode { y2 += y1 - pos.y1(); } let new_pos = Rect::new_saturating(x1, y1, x2, y2); + self.set_position(new_pos); + } + + pub fn move_by_direction(self: &Rc, direction: Direction) { + let (dx, dy) = match direction { + Direction::Left => (-COMMAND_MOVE_DELTA, 0), + Direction::Down => (0, COMMAND_MOVE_DELTA), + Direction::Up => (0, -COMMAND_MOVE_DELTA), + Direction::Right => (COMMAND_MOVE_DELTA, 0), + Direction::Unspecified => return, + }; + self.set_position(self.position.get().move_(dx, dy)); + } + + fn body_for_outer(&self, outer: Rect) -> Rect { + let bw = self.state.theme.sizes.border_width.get(); + Rect::new_sized_saturating( + outer.x1() + bw, + outer.y1() + bw, + outer.width() - 2 * bw, + outer.height() - 2 * bw, + ) + } + + fn queue_position_animation(&self, old_pos: Rect, new_pos: Rect) { + self.state + .clone() + .queue_tiled_animation(self.id.into(), old_pos, new_pos); + let Some(child) = self.child.get() else { + return; + }; + self.state.clone().queue_tiled_animation( + child.node_id(), + self.body_for_outer(old_pos), + self.body_for_outer(new_pos), + ); + } + + fn set_position(self: &Rc, new_pos: Rect) { + let pos = self.position.get(); + if new_pos == pos { + return; + } + self.queue_position_animation(pos, new_pos); self.position.set(new_pos); if self.visible.get() { self.state.damage(pos); @@ -791,13 +845,7 @@ impl ContainingNode for FloatNode { let bw = theme.sizes.border_width.get(); let (x, y) = (x - bw, y - bw); let pos = self.position.get(); - if pos.position() != (x, y) { - let new_pos = pos.at_point(x, y); - self.position.set(new_pos); - self.state.damage(pos); - self.state.damage(new_pos); - self.schedule_layout(); - } + self.set_position(pos.at_point(x, y)); } fn cnode_resize_child( @@ -828,14 +876,7 @@ impl ContainingNode for FloatNode { y2 = (v + bw).max(y1 + bw + bw); } let new_pos = Rect::new_saturating(x1, y1, x2, y2); - if new_pos != pos { - self.position.set(new_pos); - if self.visible.get() { - self.state.damage(pos); - self.state.damage(new_pos); - } - self.schedule_layout(); - } + self.set_position(new_pos); } fn cnode_pinned(&self) -> bool { diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index 02bba848..312b4ac6 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -1,5 +1,12 @@ use { crate::{ + animation::{ + RetainedExitLayer, RetainedToplevel, + multiphase::{ + MultiphaseHierarchyPosition, MultiphaseHierarchyTransition, + MultiphaseWindowHierarchy, PhaseAxis, + }, + }, client::{Client, ClientId}, criteria::{ CritDestroyListener, CritMatcherId, @@ -117,6 +124,7 @@ impl ToplevelNode for T { let parent_was_none = data.parent.set(Some(parent.clone())).is_none(); if parent_was_none { data.mapped_during_iteration.set(data.state.eng.iteration()); + data.spawn_in_pending.set(data.kind.is_app_window()); data.property_changed(TL_CHANGED_NEW); } let was_floating = data.parent_is_float.get(); @@ -184,6 +192,57 @@ impl ToplevelNode for T { fn tl_change_extents(self: Rc, rect: &Rect) { let data = self.tl_data(); let prev = data.desired_extents.replace(*rect); + let target_hierarchy = self.tl_multiphase_hierarchy_position(); + let hierarchy = MultiphaseWindowHierarchy::new( + data.layout_animation_position.replace(target_hierarchy), + target_hierarchy, + ); + let spawn_in_pending = data.spawn_in_pending.get(); + let spawn_in_eligible = spawn_in_pending + && !rect.is_empty() + && data.visible.get() + && !data.is_fullscreen.get() + && data.kind.is_app_window() + && !self.node_is_container(); + let parent_container = data + .parent + .get() + .and_then(|parent| parent.node_into_container()); + let parent_is_mono = parent_container + .as_ref() + .is_some_and(|container| container.mono_child.is_some()); + let parent_mono_transition = parent_container + .as_ref() + .is_some_and(|container| container.mono_transition_animation_pending.get()); + let active_mono_boundary = matches!( + hierarchy.transition, + MultiphaseHierarchyTransition::EnteringMono + | MultiphaseHierarchyTransition::ExitingMono + ) && parent_mono_transition + && (hierarchy.source.mono_active || hierarchy.target.mono_active); + if prev != *rect + && !prev.is_empty() + && !rect.is_empty() + && data.visible.get() + && !data.parent_is_float.get() + && !self.node_is_container() + && (!parent_is_mono || active_mono_boundary) + { + data.state.clone().queue_tiled_animation_with_hierarchy( + data.node_id, + prev, + *rect, + hierarchy, + ); + } + if spawn_in_eligible { + data.state + .clone() + .queue_spawn_in_animation(data.node_id, *rect); + } + if spawn_in_eligible { + data.spawn_in_pending.set(false); + } if prev.size() != rect.size() { for sc in data.jay_screencasts.lock().values() { sc.schedule_realloc_or_reconfigure(); @@ -275,6 +334,35 @@ pub trait ToplevelNodeBase: Node { true } + fn tl_multiphase_hierarchy_position(&self) -> MultiphaseHierarchyPosition { + let data = self.tl_data(); + let Some(parent) = data.parent.get() else { + return Default::default(); + }; + let mut position = MultiphaseHierarchyPosition { + parent: Some(parent.node_id()), + ..Default::default() + }; + populate_multiphase_ancestor_splits(&mut position, Some(parent.clone())); + if let Some(container) = parent.node_into_container() { + position.split_axis = Some(match container.split.get() { + ContainerSplit::Horizontal => PhaseAxis::Horizontal, + ContainerSplit::Vertical => PhaseAxis::Vertical, + }); + if let Some(mono) = container.mono_child.get() { + position.parent_is_mono = true; + position.mono_active = mono.node.node_id() == data.node_id; + } + for (idx, child) in container.children.iter().enumerate() { + if child.node.node_id() == data.node_id { + position.sibling_index = Some(idx.min(u16::MAX as usize) as u16); + break; + } + } + } + position + } + fn tl_set_active(&self, active: bool) { let _ = active; } @@ -299,6 +387,11 @@ pub trait ToplevelNodeBase: Node { fn tl_scanout_surface(&self) -> Option> { None } + + fn tl_animation_snapshot(&self) -> Option> { + None + } + fn tl_restack_popups(&self) { // nothing } @@ -339,6 +432,31 @@ pub trait ToplevelNodeBase: Node { } } +fn populate_multiphase_ancestor_splits( + position: &mut MultiphaseHierarchyPosition, + mut parent: Option>, +) { + let mut depth = 0u16; + while let Some(node) = parent { + let Some(toplevel) = node.clone().node_into_toplevel() else { + break; + }; + depth = depth.saturating_add(1); + if let Some(container) = node.node_into_container() { + match container.split.get() { + ContainerSplit::Horizontal => { + position.nearest_horizontal_split_depth.get_or_insert(depth); + } + ContainerSplit::Vertical => { + position.nearest_vertical_split_depth.get_or_insert(depth); + } + } + } + parent = toplevel.tl_data().parent.get(); + } + position.depth = depth; +} + pub struct FullscreenedData { pub placeholder: Rc, pub workspace: Rc, @@ -377,6 +495,13 @@ impl ToplevelType { ToplevelType::XWindow { .. } => window::X_WINDOW, } } + + pub fn is_app_window(&self) -> bool { + matches!( + self, + ToplevelType::XdgToplevel(_) | ToplevelType::XWindow(_) + ) + } } pub struct ToplevelData { @@ -399,8 +524,10 @@ pub struct ToplevelData { pub title: RefCell, pub parent: CloneCell>>, pub mapped_during_iteration: Cell, + pub spawn_in_pending: Cell, pub pos: Cell, pub desired_extents: Cell, + pub layout_animation_position: Cell, pub seat_state: NodeSeatState, pub wants_attention: Cell, pub requested_attention: Cell, @@ -462,8 +589,10 @@ impl ToplevelData { title: RefCell::new(title), parent: Default::default(), mapped_during_iteration: Cell::new(0), + spawn_in_pending: Cell::new(false), pos: Default::default(), desired_extents: Default::default(), + layout_animation_position: Default::default(), seat_state: Default::default(), wants_attention: Cell::new(false), requested_attention: Cell::new(false), @@ -935,6 +1064,62 @@ impl ToplevelData { self.mapped_during_iteration.get() == self.state.eng.iteration() } + pub fn queue_spawn_out(&self, node: &dyn ToplevelNode, retained: Option>) { + if !self.kind.is_app_window() + || !self.visible.get() + || self.is_fullscreen.get() + || node.node_is_container() + { + return; + } + let Some(retained) = retained else { + return; + }; + let bw = self.state.theme.sizes.border_width.get().max(0); + let now = self.state.now_nsec(); + let (outer, frame_inset, layer) = if self.parent_is_float.get() { + let Some(float) = self.float.get() else { + return; + }; + ( + self.state + .animations + .visual_rect(float.node_id(), float.position.get(), now), + bw, + RetainedExitLayer::Floating, + ) + } else { + let body = + self.state + .animations + .visual_rect(self.node_id, node.node_absolute_position(), now); + if body.is_empty() { + return; + } + if self.state.theme.sizes.gap.get() != 0 { + ( + Rect::new_sized_saturating( + body.x1() - bw, + body.y1() - bw, + body.width() + 2 * bw, + body.height() + 2 * bw, + ), + bw, + RetainedExitLayer::Tiled, + ) + } else { + (body, 0, RetainedExitLayer::Tiled) + } + }; + self.state.clone().queue_spawn_out_animation( + outer, + frame_inset, + retained, + self.active(), + layer, + ); + } + pub fn set_content_type(&self, content_type: Option) { if self.content_type.replace(content_type) != content_type { self.property_changed(TL_CHANGED_CONTENT_TY); @@ -1043,6 +1228,26 @@ pub fn toplevel_create_split(state: &Rc, tl: Rc, axis: } } +fn float_outer_for_body(state: &State, body: Rect) -> Rect { + let bw = state.theme.sizes.border_width.get(); + Rect::new_sized_saturating( + body.x1() - bw, + body.y1() - bw, + body.width() + 2 * bw, + body.height() + 2 * bw, + ) +} + +fn float_body_for_outer(state: &State, outer: Rect) -> Rect { + let bw = state.theme.sizes.border_width.get(); + Rect::new_sized_saturating( + outer.x1() + bw, + outer.y1() + bw, + outer.width() - 2 * bw, + outer.height() - 2 * bw, + ) +} + pub fn toplevel_set_floating(state: &Rc, tl: Rc, floating: bool) { let data = tl.tl_data(); if data.is_fullscreen.get() { @@ -1059,9 +1264,19 @@ pub fn toplevel_set_floating(state: &Rc, tl: Rc, floati parent.cnode_remove_child2(&*tl, true); state.map_tiled(tl); } else if let Some(ws) = data.workspace.get() { + let node_id = data.node_id; + let old_body = + state + .animations + .visual_rect(node_id, tl.node_absolute_position(), state.now_nsec()); + let old_outer = float_outer_for_body(state, old_body); parent.cnode_remove_child2(&*tl, true); let (width, height) = data.float_size(&ws); - state.map_floating(tl, width, height, &ws, None); + let floater = state.map_floating(tl, width, height, &ws, None); + let new_outer = floater.position.get(); + let new_body = float_body_for_outer(state, new_outer); + state.queue_linear_layout_animation(floater.node_id(), old_outer, new_outer); + state.queue_linear_layout_animation(node_id, old_body, new_body); } } diff --git a/src/tree/workspace.rs b/src/tree/workspace.rs index f60354a4..5e31efe6 100644 --- a/src/tree/workspace.rs +++ b/src/tree/workspace.rs @@ -197,10 +197,10 @@ impl WorkspaceNode { } self.pull_child_properties(&**container); let pos = self.position.get(); - container.clone().tl_change_extents(&pos); container.tl_set_parent(self.clone()); container.tl_set_visible(self.container_visible()); self.container.set(Some(container.clone())); + container.clone().tl_change_extents(&pos); self.state.damage(self.position.get()); } diff --git a/src/xwayland/xwm.rs b/src/xwayland/xwm.rs index b55312fe..f94645fe 100644 --- a/src/xwayland/xwm.rs +++ b/src/xwayland/xwm.rs @@ -2034,6 +2034,7 @@ impl Wm { self.windows_by_surface_serial.remove(&serial); } if let Some(window) = data.window.take() { + window.queue_spawn_out(); window.destroy(); } if let Some(parent) = data.parent.take() { diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index ba71c585..35aca02c 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -266,6 +266,20 @@ pub struct UiDrag { pub threshold: Option, } +#[derive(Debug, Clone, Default)] +pub struct Animations { + pub enabled: Option, + pub duration_ms: Option, + pub style: Option, + pub curve: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum AnimationCurveConfig { + Preset(String), + CubicBezier([f32; 4]), +} + #[derive(Debug, Clone)] pub enum OutputMatch { Any(Vec), @@ -567,6 +581,7 @@ pub struct Config { pub tearing: Option, pub libei: Libei, pub ui_drag: UiDrag, + pub animations: Animations, pub xwayland: Option, pub color_management: Option, pub float: Option, @@ -651,3 +666,26 @@ fn default_config_parses() { let input = include_bytes!("default-config.toml"); parse_config(input, &Default::default(), |_| ()).unwrap(); } + +#[test] +fn custom_animation_curve_parses() { + let input = b" + [animations] + curve = [0.25, 0.1, 0.25, 1.0] + "; + let config = parse_config(input, &Default::default(), |_| ()).unwrap(); + assert_eq!( + config.animations.curve, + Some(AnimationCurveConfig::CubicBezier([0.25, 0.1, 0.25, 1.0])) + ); +} + +#[test] +fn animation_style_parses() { + let input = b" + [animations] + style = \"plain\" + "; + let config = parse_config(input, &Default::default(), |_| ()).unwrap(); + assert_eq!(config.animations.style.as_deref(), Some("plain")); +} diff --git a/toml-config/src/config/parsers.rs b/toml-config/src/config/parsers.rs index 4c5e337b..e353a2f8 100644 --- a/toml-config/src/config/parsers.rs +++ b/toml-config/src/config/parsers.rs @@ -8,6 +8,7 @@ use { pub mod action; mod actions; +mod animations; mod capabilities; mod clean_logs_older_than; mod client_match; diff --git a/toml-config/src/config/parsers/animations.rs b/toml-config/src/config/parsers/animations.rs new file mode 100644 index 00000000..cc5cb439 --- /dev/null +++ b/toml-config/src/config/parsers/animations.rs @@ -0,0 +1,99 @@ +use { + crate::{ + config::{ + AnimationCurveConfig, Animations, + context::Context, + extractor::{Extractor, ExtractorError, bol, n32, opt, recover, str, val}, + parser::{DataType, ParseResult, Parser, UnexpectedDataType}, + }, + toml::{ + toml_span::{DespanExt, Span, Spanned, SpannedExt}, + toml_value::Value, + }, + }, + indexmap::IndexMap, + thiserror::Error, +}; + +#[derive(Debug, Error)] +pub enum AnimationsParserError { + #[error(transparent)] + Expected(#[from] UnexpectedDataType), + #[error(transparent)] + Extract(#[from] ExtractorError), + #[error("Expected animation curve to be a string or an array")] + CurveType, + #[error("Cubic-bezier animation curves must contain exactly four values")] + CubicBezierLen, + #[error("Cubic-bezier animation curve entries must be finite floats or integers")] + CubicBezierValue, + #[error("Cubic-bezier x control points must be between 0 and 1")] + CubicBezierXRange, +} + +pub struct AnimationsParser<'a>(pub &'a Context<'a>); + +impl Parser for AnimationsParser<'_> { + type Value = Animations; + type Error = AnimationsParserError; + const EXPECTED: &'static [DataType] = &[DataType::Table]; + + fn parse_table( + &mut self, + span: Span, + table: &IndexMap, Spanned>, + ) -> ParseResult { + let mut ext = Extractor::new(self.0, span, table); + let (enabled, duration_ms, style, curve) = ext.extract(( + recover(opt(bol("enabled"))), + recover(opt(n32("duration-ms"))), + recover(opt(str("style"))), + opt(val("curve")), + ))?; + let curve = match curve { + Some(curve) => Some(parse_curve(curve)?), + None => None, + }; + Ok(Animations { + enabled: enabled.despan(), + duration_ms: duration_ms.despan(), + style: style.despan().map(|style| style.to_string()), + curve, + }) + } +} + +fn parse_curve( + curve: Spanned<&Value>, +) -> Result> { + match curve.value { + Value::String(s) => Ok(AnimationCurveConfig::Preset(s.clone())), + Value::Array(values) => parse_cubic_bezier(curve.span, values), + _ => Err(AnimationsParserError::CurveType.spanned(curve.span)), + } +} + +fn parse_cubic_bezier( + span: Span, + values: &[Spanned], +) -> Result> { + if values.len() != 4 { + return Err(AnimationsParserError::CubicBezierLen.spanned(span)); + } + let mut points = [0.0; 4]; + for (idx, value) in values.iter().enumerate() { + let f = match value.value { + Value::Float(f) => f, + Value::Integer(i) => i as f64, + _ => return Err(AnimationsParserError::CubicBezierValue.spanned(value.span)), + }; + if !f.is_finite() { + return Err(AnimationsParserError::CubicBezierValue.spanned(value.span)); + } + points[idx] = f as f32; + } + if !(0.0..=1.0).contains(&points[0]) || !(0.0..=1.0).contains(&points[2]) { + return Err(AnimationsParserError::CubicBezierXRange.spanned(span)); + } + Ok(AnimationCurveConfig::CubicBezier(points)) +} diff --git a/toml-config/src/config/parsers/config.rs b/toml-config/src/config/parsers/config.rs index b9d34e74..d82be95b 100644 --- a/toml-config/src/config/parsers/config.rs +++ b/toml-config/src/config/parsers/config.rs @@ -1,13 +1,14 @@ use { crate::{ config::{ - Action, Config, Libei, Theme, UiDrag, + Action, Animations, Config, Libei, Theme, UiDrag, context::Context, extractor::{Extractor, ExtractorError, arr, bol, int, opt, recover, str, val}, parser::{DataType, ParseResult, Parser, UnexpectedDataType}, parsers::{ action::ActionParser, actions::ActionsParser, + animations::AnimationsParser, clean_logs_older_than::CleanLogsOlderThanParser, client_rule::ClientRulesParser, color_management::ColorManagementParser, @@ -153,6 +154,7 @@ impl Parser for ConfigParser<'_> { fallback_output_mode_val, clean_logs_older_than_val, mouse_follows_focus, + animations_val, ), ) = ext.extract(( ( @@ -213,6 +215,7 @@ impl Parser for ConfigParser<'_> { opt(val("fallback-output-mode")), opt(val("clean-logs-older-than")), recover(opt(bol("unstable-mouse-follows-focus"))), + opt(val("animations")), ), ))?; let mut keymap = None; @@ -429,6 +432,15 @@ impl Parser for ConfigParser<'_> { } } } + let mut animations = Animations::default(); + if let Some(value) = animations_val { + match value.parse(&mut AnimationsParser(self.0)) { + Ok(v) => animations = v, + Err(e) => { + log::warn!("Could not parse animations setting: {}", self.0.error(e)); + } + } + } let mut xwayland = None; if let Some(value) = xwayland_val { match value.parse(&mut XwaylandParser(self.0)) { @@ -587,6 +599,7 @@ impl Parser for ConfigParser<'_> { tearing, libei, ui_drag, + animations, xwayland, color_management, float, diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index 391bcee9..4dbf8e74 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -13,9 +13,9 @@ mod toml; use { crate::{ config::{ - Action, ClientRule, Config, ConfigConnector, ConfigDrmDevice, ConfigKeymap, - ConnectorMatch, DrmDeviceMatch, Exec, Input, InputMatch, Output, OutputMatch, - SimpleCommand, Status, Theme, WindowRule, parse_config, + Action, AnimationCurveConfig, ClientRule, Config, ConfigConnector, ConfigDrmDevice, + ConfigKeymap, ConnectorMatch, DrmDeviceMatch, Exec, Input, InputMatch, Output, + OutputMatch, SimpleCommand, Status, Theme, WindowRule, parse_config, }, rules::{MatcherTemp, RuleMapper}, shortcuts::ModeState, @@ -23,7 +23,7 @@ use { ahash::{AHashMap, AHashSet}, error_reporter::Report, jay_config::{ - Axis, + AnimationCurve, AnimationStyle, Axis, client::Client, config, config_dir, exec::{Command, set_env, unset_env}, @@ -37,8 +37,10 @@ use { is_reload, keyboard::Keymap, logging::{clean_logs_older_than, set_log_level}, - on_devices_enumerated, on_idle, on_unload, quit, reload, set_autotile, - set_color_management_enabled, set_corner_radius, set_default_workspace_capture, + on_devices_enumerated, on_idle, on_unload, quit, reload, set_animation_cubic_bezier, + set_animation_curve, set_animation_duration_ms, set_animation_style, + set_animations_enabled, set_autotile, set_color_management_enabled, set_corner_radius, + set_default_workspace_capture, set_explicit_sync_enabled, set_float_above_fullscreen, set_floating_titles, set_idle, set_idle_grace_period, set_middle_click_paste_enabled, set_show_bar, set_show_float_pin_icon, set_show_titles, set_tab_title_align, set_ui_drag_enabled, @@ -1649,6 +1651,38 @@ fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc set_animation_style(AnimationStyle::PLAIN), + "multiphase" => set_animation_style(AnimationStyle::MULTIPHASE), + style_name => log::warn!("Unknown animation style: {style_name}"), + } + match config + .animations + .curve + .unwrap_or_else(|| AnimationCurveConfig::Preset("ease-out".to_string())) + { + AnimationCurveConfig::Preset(curve_name) => { + let curve = match curve_name.as_str() { + "linear" => Some(AnimationCurve::LINEAR), + "ease" => Some(AnimationCurve::EASE), + "ease-in" => Some(AnimationCurve::EASE_IN), + "ease-out" => Some(AnimationCurve::EASE_OUT), + "ease-in-out" => Some(AnimationCurve::EASE_IN_OUT), + _ => { + log::warn!("Unknown animation curve: {curve_name}"); + None + } + }; + if let Some(curve) = curve { + set_animation_curve(curve); + } + } + AnimationCurveConfig::CubicBezier([x1, y1, x2, y2]) => { + set_animation_cubic_bezier(x1, y1, x2, y2); + } + } if let Some(xwayland) = config.xwayland { if let Some(enabled) = xwayland.enabled { set_x_wayland_enabled(enabled); diff --git a/toml-spec/spec/spec.generated.json b/toml-spec/spec/spec.generated.json index 930ad697..50cc8887 100644 --- a/toml-spec/spec/spec.generated.json +++ b/toml-spec/spec/spec.generated.json @@ -641,6 +641,61 @@ } ] }, + "AnimationCurve": { + "description": "Describes a window animation curve.\n", + "anyOf": [ + { + "type": "string", + "description": "One of the supported curve presets.\n", + "enum": [ + "linear", + "ease", + "ease-in", + "ease-out", + "ease-in-out" + ] + }, + { + "type": "array", + "description": "A custom CSS-style cubic-bezier curve as four numbers:\n`x1`, `y1`, `x2`, and `y2`.\n\nThe implicit endpoints are `(0, 0)` and `(1, 1)`. `x1` and `x2` must\nbe between `0` and `1`.\n", + "items": { + "type": "number", + "description": "" + } + } + ] + }, + "AnimationStyle": { + "type": "string", + "description": "Describes a tiled window movement animation style.\n", + "enum": [ + "plain", + "multiphase" + ] + }, + "Animations": { + "description": "Describes window animation settings.\n\n- Example:\n\n ```toml\n [animations]\n enabled = true\n duration-ms = 160\n style = \"multiphase\"\n curve = [0.25, 0.1, 0.25, 1.0]\n ```\n", + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enables or disables window animations.\n\nThe default is `false`.\n" + }, + "duration-ms": { + "type": "integer", + "description": "Sets the animation duration in milliseconds.\n\nThe default is `160`.\n" + }, + "style": { + "description": "Sets the animation style used for tiled window movement animations.\n\nThe default is `multiphase`.\n", + "$ref": "#/$defs/AnimationStyle" + }, + "curve": { + "description": "Sets the animation curve.\n\nThe default is `ease-out`.\n", + "$ref": "#/$defs/AnimationCurve" + } + }, + "required": [] + }, "BarPosition": { "type": "string", "description": "The position of the bar.", @@ -1085,6 +1140,10 @@ "description": "Configures the ui-drag settings.\n\n- Example:\n\n ```toml\n ui-drag = { enabled = false, threshold = 20 }\n ```\n", "$ref": "#/$defs/UiDrag" }, + "animations": { + "description": "Configures window animations.\n\nAnimations are disabled by default.\n\n- Example:\n\n ```toml\n [animations]\n enabled = true\n duration-ms = 160\n style = \"multiphase\"\n curve = \"ease-out\"\n ```\n", + "$ref": "#/$defs/Animations" + }, "xwayland": { "description": "Configures the Xwayland settings.\n\n- Example:\n\n ```toml\n xwayland = { scaling-mode = \"downscaled\" }\n ```\n", "$ref": "#/$defs/Xwayland" diff --git a/toml-spec/spec/spec.generated.md b/toml-spec/spec/spec.generated.md index 43e9f20d..a31a3767 100644 --- a/toml-spec/spec/spec.generated.md +++ b/toml-spec/spec/spec.generated.md @@ -942,6 +942,125 @@ This table is a tagged union. The variant is determined by the `type` field. It The numbers should be integers. + +### `AnimationCurve` + +Describes a window animation curve. + +Values of this type should have one of the following forms: + +#### A string + +One of the supported curve presets. + +The string should have one of the following values: + +- `linear`: + + No easing. + +- `ease`: + + The CSS `ease` curve. + +- `ease-in`: + + The CSS `ease-in` curve. + +- `ease-out`: + + The CSS `ease-out` curve. + +- `ease-in-out`: + + The CSS `ease-in-out` curve. + + +#### An array + +A custom CSS-style cubic-bezier curve as four numbers: +`x1`, `y1`, `x2`, and `y2`. + +The implicit endpoints are `(0, 0)` and `(1, 1)`. `x1` and `x2` must +be between `0` and `1`. + +Each element of this array should be a number. + + + +### `AnimationStyle` + +Describes a tiled window movement animation style. + +Values of this type should be strings. + +The string should have one of the following values: + +- `plain`: + + Uses a single interpolated movement from each window's current visual + rectangle to its destination rectangle. + +- `multiphase`: + + Uses the no-overlap multiphase planner for tiled window movement when a + supported plan exists. + + + +### `Animations` + +Describes window animation settings. + +- Example: + + ```toml + [animations] + enabled = true + duration-ms = 160 + style = "multiphase" + curve = [0.25, 0.1, 0.25, 1.0] + ``` + +Values of this type should be tables. + +The table has the following fields: + +- `enabled` (optional): + + Enables or disables window animations. + + The default is `false`. + + The value of this field should be a boolean. + +- `duration-ms` (optional): + + Sets the animation duration in milliseconds. + + The default is `160`. + + The value of this field should be a number. + + The numbers should be integers. + +- `style` (optional): + + Sets the animation style used for tiled window movement animations. + + The default is `multiphase`. + + The value of this field should be a [AnimationStyle](#types-AnimationStyle). + +- `curve` (optional): + + Sets the animation curve. + + The default is `ease-out`. + + The value of this field should be a [AnimationCurve](#types-AnimationCurve). + + ### `BarPosition` @@ -2169,6 +2288,24 @@ The table has the following fields: The value of this field should be a [UiDrag](#types-UiDrag). +- `animations` (optional): + + Configures window animations. + + Animations are disabled by default. + + - Example: + + ```toml + [animations] + enabled = true + duration-ms = 160 + style = "multiphase" + curve = "ease-out" + ``` + + The value of this field should be a [Animations](#types-Animations). + - `xwayland` (optional): Configures the Xwayland settings. @@ -5670,4 +5807,3 @@ The table has the following fields: The value of this field should be a [XScalingMode](#types-XScalingMode). - diff --git a/toml-spec/spec/spec.yaml b/toml-spec/spec/spec.yaml index aa6789da..706c016a 100644 --- a/toml-spec/spec/spec.yaml +++ b/toml-spec/spec/spec.yaml @@ -2942,6 +2942,23 @@ Config: ```toml ui-drag = { enabled = false, threshold = 20 } ``` + animations: + ref: Animations + required: false + description: | + Configures window animations. + + Animations are disabled by default. + + - Example: + + ```toml + [animations] + enabled = true + duration-ms = 160 + style = "multiphase" + curve = "ease-out" + ``` xwayland: ref: Xwayland required: false @@ -3655,6 +3672,97 @@ UiDrag: The default is `10`. +Animations: + kind: table + description: | + Describes window animation settings. + + - Example: + + ```toml + [animations] + enabled = true + duration-ms = 160 + style = "multiphase" + curve = [0.25, 0.1, 0.25, 1.0] + ``` + fields: + enabled: + kind: boolean + required: false + description: | + Enables or disables window animations. + + The default is `false`. + duration-ms: + kind: number + integer_only: true + required: false + description: | + Sets the animation duration in milliseconds. + + The default is `160`. + style: + ref: AnimationStyle + required: false + description: | + Sets the animation style used for tiled window movement animations. + + The default is `multiphase`. + curve: + ref: AnimationCurve + required: false + description: | + Sets the animation curve. + + The default is `ease-out`. + + +AnimationStyle: + kind: string + description: | + Describes a tiled window movement animation style. + values: + - value: plain + description: | + Uses a single interpolated movement from each window's current visual + rectangle to its destination rectangle. + - value: multiphase + description: | + Uses the no-overlap multiphase planner for tiled window movement when a + supported plan exists. + + +AnimationCurve: + kind: variable + description: | + Describes a window animation curve. + variants: + - kind: string + description: | + One of the supported curve presets. + values: + - value: linear + description: No easing. + - value: ease + description: The CSS `ease` curve. + - value: ease-in + description: The CSS `ease-in` curve. + - value: ease-out + description: The CSS `ease-out` curve. + - value: ease-in-out + description: The CSS `ease-in-out` curve. + - kind: array + items: + kind: number + description: | + A custom CSS-style cubic-bezier curve as four numbers: + `x1`, `y1`, `x2`, and `y2`. + + The implicit endpoints are `(0, 0)` and `(1, 1)`. `x1` and `x2` must + be between `0` and `1`. + + Xwayland: kind: table description: | From 5c2f631fdb8bb2f85489f372e276fa9e4ad7e73d Mon Sep 17 00:00:00 2001 From: atagen Date: Sun, 31 May 2026 17:16:44 +1000 Subject: [PATCH 43/47] feat: add alternating autotiling --- book/src/tiling.md | 14 +++++ jay-config/src/_private/client.rs | 6 ++ jay-config/src/_private/ipc.rs | 4 ++ jay-config/src/lib.rs | 11 +++- src/config/handler.rs | 5 ++ src/it/test_config.rs | 4 ++ src/it/tests.rs | 2 + src/it/tests/t0055_autotiling.rs | 58 ++++++++++++++++++ src/state.rs | 34 +++++++++-- src/tree/container.rs | 77 +++++++++++++----------- src/tree/toplevel.rs | 4 +- toml-config/src/config.rs | 8 +++ toml-config/src/config/parsers/config.rs | 4 ++ toml-config/src/lib.rs | 21 +++---- toml-spec/spec/spec.generated.json | 7 +++ toml-spec/spec/spec.generated.md | 25 +++++++- toml-spec/spec/spec.yaml | 19 +++++- 17 files changed, 244 insertions(+), 59 deletions(-) create mode 100644 src/it/tests/t0055_autotiling.rs diff --git a/book/src/tiling.md b/book/src/tiling.md index 650cd73a..2ff61d5e 100644 --- a/book/src/tiling.md +++ b/book/src/tiling.md @@ -77,6 +77,20 @@ You can also right-click any title in a container to toggle mono mode. In mono mode, scroll over the title bar to cycle between windows in the container. +## Autotiling + +Autotiling makes newly tiled windows alternate split direction from the focused +tiled window. The first split uses the containing group direction, then later +windows wrap the focused tile in the opposite direction, producing a horizontal, +vertical, horizontal pattern as the layout grows. + +```toml +[shortcuts] +alt-a = "toggle-autotile" +``` + +Manual grouping and split commands still use the direction you request. + ## Fullscreen Press `alt-u` (`toggle-fullscreen`) to make the focused window fill the entire diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index 71927bbc..7c78abac 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -2079,6 +2079,12 @@ impl ConfigClient { self.send(&ClientMessage::SetAutotile { enabled }); } + pub fn get_autotile(&self) -> bool { + let res = self.send_with_response(&ClientMessage::GetAutotile); + get_response!(res, false, GetAutotile { enabled }); + enabled + } + pub fn set_tab_title_align(&self, align: u32) { self.send(&ClientMessage::SetTabTitleAlign { align }); } diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index e86e79ca..c61c1af6 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -923,6 +923,7 @@ pub enum ClientMessage<'a> { SetAutotile { enabled: bool, }, + GetAutotile, SetTabTitleAlign { align: u32, }, @@ -1189,6 +1190,9 @@ pub enum Response { GetCornerRadius { radius: f32, }, + GetAutotile { + enabled: bool, + }, } #[derive(Serialize, Deserialize, Debug)] diff --git a/jay-config/src/lib.rs b/jay-config/src/lib.rs index c95c6620..fff94506 100644 --- a/jay-config/src/lib.rs +++ b/jay-config/src/lib.rs @@ -453,14 +453,21 @@ pub fn get_corner_radius() -> f32 { /// Enables or disables autotiling. /// -/// When enabled, new windows are automatically placed in a perpendicular -/// sub-container if the predicted body would be narrower than tall (or vice versa). +/// When enabled, newly tiled windows alternate split orientation from the +/// focused tiled window: the first split uses the containing group's direction, +/// then subsequent splits wrap the focused window in the perpendicular +/// direction. /// /// The default is `false`. pub fn set_autotile(enabled: bool) { get!().set_autotile(enabled) } +/// Returns whether autotiling is enabled. +pub fn get_autotile() -> bool { + get!(false).get_autotile() +} + /// Sets the horizontal alignment of title text within tab buttons. /// /// - `"start"` — left-aligned (default) diff --git a/src/config/handler.rs b/src/config/handler.rs index 336da9ff..9a11acab 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -3587,6 +3587,11 @@ impl ConfigProxyHandler { ClientMessage::SetAutotile { enabled } => { self.state.theme.autotile_enabled.set(enabled); } + ClientMessage::GetAutotile => { + self.respond(Response::GetAutotile { + enabled: self.state.theme.autotile_enabled.get(), + }); + } ClientMessage::SeatToggleExpand { .. } => { // Removed feature; kept for binary protocol compatibility. } diff --git a/src/it/test_config.rs b/src/it/test_config.rs index 56ee5272..7691bbcd 100644 --- a/src/it/test_config.rs +++ b/src/it/test_config.rs @@ -331,6 +331,10 @@ impl TestConfig { pub fn set_show_titles(&self, show: bool) -> TestResult { self.send(ClientMessage::SetShowTitles { show }) } + + pub fn set_autotile(&self, enabled: bool) -> TestResult { + self.send(ClientMessage::SetAutotile { enabled }) + } } impl Drop for TestConfig { diff --git a/src/it/tests.rs b/src/it/tests.rs index dc28888c..3e1e502c 100644 --- a/src/it/tests.rs +++ b/src/it/tests.rs @@ -85,6 +85,7 @@ mod t0051_pointer_warp; mod t0052_bar; mod t0053_theme; mod t0054_subsurface_already_attached; +mod t0055_autotiling; pub trait TestCase: Sync { fn name(&self) -> &'static str; @@ -158,5 +159,6 @@ pub fn tests() -> Vec<&'static dyn TestCase> { t0052_bar, t0053_theme, t0054_subsurface_already_attached, + t0055_autotiling, } } diff --git a/src/it/tests/t0055_autotiling.rs b/src/it/tests/t0055_autotiling.rs new file mode 100644 index 00000000..4b3611c4 --- /dev/null +++ b/src/it/tests/t0055_autotiling.rs @@ -0,0 +1,58 @@ +use { + crate::{ + it::{test_error::TestResult, testrun::TestRun}, + tree::{ContainerSplit, Node, ToplevelNodeBase}, + }, + std::rc::Rc, +}; + +testcase!(); + +async fn test(run: Rc) -> TestResult { + run.backend.install_default()?; + run.cfg.set_autotile(true)?; + + let client = run.create_client().await?; + + let win1 = client.create_window().await?; + win1.map().await?; + let root = win1.tl.container_parent()?; + tassert_eq!(root.split.get(), ContainerSplit::Horizontal); + + let win2 = client.create_window().await?; + win2.map().await?; + client.sync().await; + + tassert_eq!(root.split.get(), ContainerSplit::Horizontal); + tassert_eq!(win1.tl.container_parent()?.node_id(), root.node_id()); + tassert_eq!(win2.tl.container_parent()?.node_id(), root.node_id()); + + let win3 = client.create_window().await?; + win3.map().await?; + client.sync().await; + + let v_group = win3.tl.container_parent()?; + tassert_eq!(root.split.get(), ContainerSplit::Horizontal); + tassert_eq!(v_group.split.get(), ContainerSplit::Vertical); + tassert_eq!(win2.tl.container_parent()?.node_id(), v_group.node_id()); + + let win4 = client.create_window().await?; + win4.map().await?; + client.sync().await; + + let h_group = win4.tl.container_parent()?; + tassert_eq!(h_group.split.get(), ContainerSplit::Horizontal); + tassert_eq!(win3.tl.container_parent()?.node_id(), h_group.node_id()); + let h_parent = match h_group + .tl_data() + .parent + .get() + .and_then(|p| p.node_into_container()) + { + Some(parent) => parent, + None => bail!("autotile group does not have a container parent"), + }; + tassert_eq!(h_parent.node_id(), v_group.node_id()); + + Ok(()) +} diff --git a/src/state.rs b/src/state.rs index 42dd909d..a7dad1d5 100644 --- a/src/state.rs +++ b/src/state.rs @@ -925,19 +925,39 @@ impl State { && node.tl_data().kind.is_app_window() && !node.tl_data().visible.get(); if animate_new_app_map { - self.with_layout_animations(|| self.do_map_tiled(seat.as_deref(), node.clone())); + self.with_layout_animations(|| self.do_map_tiled(seat.as_deref(), node.clone(), true)); } else { - self.do_map_tiled(seat.as_deref(), node.clone()); + self.do_map_tiled(seat.as_deref(), node.clone(), true); } self.focus_after_map(node, seat.as_deref()); } - fn do_map_tiled(self: &Rc, seat: Option<&Rc>, node: Rc) { + pub fn map_tiled_without_autotile(self: &Rc, node: Rc) { + let seat = self.seat_queue.last(); + self.do_map_tiled(seat.as_deref(), node.clone(), false); + self.focus_after_map(node, seat.as_deref()); + } + + fn do_map_tiled( + self: &Rc, + seat: Option<&Rc>, + node: Rc, + autotile: bool, + ) { let ws = self.ensure_map_workspace(seat); - self.map_tiled_on(node, &ws); + self.map_tiled_on_(node, &ws, autotile); } pub fn map_tiled_on(self: &Rc, node: Rc, ws: &Rc) { + self.map_tiled_on_(node, ws, false); + } + + fn map_tiled_on_( + self: &Rc, + node: Rc, + ws: &Rc, + autotile: bool, + ) { if let Some(c) = ws.container.get() { let la = c.clone().tl_last_active_child(); let lap = la @@ -946,7 +966,11 @@ impl State { .get() .and_then(|n| n.node_into_container()); if let Some(lap) = lap { - lap.add_child_after(&*la, node); + if autotile { + lap.add_tiled_child_after(&*la, node); + } else { + lap.add_child_after(&*la, node); + } } else { c.append_child(node); } diff --git a/src/tree/container.rs b/src/tree/container.rs index b8de7b25..b81f2e85 100644 --- a/src/tree/container.rs +++ b/src/tree/container.rs @@ -290,6 +290,47 @@ impl ContainerNode { self.add_child_x(prev, new, |prev, new| self.add_child_after_(prev, new)); } + pub fn add_tiled_child_after(self: &Rc, prev: &dyn Node, new: Rc) { + if !self.state.theme.autotile_enabled.get() + || self.mono_child.is_some() + || self.num_children.get() <= 1 + { + self.add_child_after(prev, new); + return; + } + let focused = self + .child_nodes + .borrow() + .get(&prev.node_id()) + .map(|n| n.to_ref()); + let Some(focused) = focused else { + log::error!( + "Tried to autotile a child into a container but the preceding node is not in the container" + ); + return; + }; + let focused_node = focused.node.clone(); + let focused_active = focused_node.tl_data().active(); + let sub = ContainerNode::new( + &self.state, + &self.workspace.get(), + focused_node.clone(), + self.split.get().other(), + ); + // Autotile-created groups are structural and collapse once only one + // child remains. Explicit make-group commands control their own + // grouping through the regular manual paths. + sub.ephemeral.set(Ephemeral::On); + sub.append_child(new); + let sub_id = sub.node_id(); + self.clone().cnode_replace_child(&*focused_node, sub); + if focused_active + && let Some(group) = self.child_nodes.borrow().get(&sub_id).map(|n| n.to_ref()) + { + self.update_child_active(&group, true, 1); + } + } + pub fn add_child_before(self: &Rc, prev: &dyn Node, new: Rc) { self.add_child_x(prev, new, |prev, new| self.add_child_before_(prev, new)); } @@ -1369,42 +1410,6 @@ impl ContainerNode { } pub fn insert_child(self: &Rc, node: Rc, direction: Direction) { - // Autotile: if the container would become too narrow/tall, wrap the - // focused child and new node in a perpendicular sub-container. - if self.state.theme.autotile_enabled.get() && self.mono_child.is_none() { - let (pw, ph) = self.predict_child_body_size(); - let opposite = match self.split.get() { - ContainerSplit::Horizontal if pw > 0 && ph > 0 && pw < ph => { - Some(ContainerSplit::Vertical) - } - ContainerSplit::Vertical if pw > 0 && ph > 0 && ph < pw => { - Some(ContainerSplit::Horizontal) - } - _ => None, - }; - if let Some(opp_split) = opposite { - if let Some(focused) = self.focus_history.last() { - if self.num_children.get() <= 1 { - // Single child, autotile not applicable. - } else { - let focused_node = focused.node.clone(); - let was_ephemeral = self.ephemeral.replace(Ephemeral::Off); - self.clone().cnode_remove_child2(&*focused_node, true); - self.ephemeral.set(was_ephemeral); - let sub = ContainerNode::new( - &self.state, - &self.workspace.get(), - focused_node, - opp_split, - ); - sub.ephemeral.set(Ephemeral::On); - sub.append_child(node); - self.append_child(sub); - return; - } - } - } - } let (split, right) = direction_to_split(direction); if split != self.split.get() || right { self.append_child(node); diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index 312b4ac6..7fff564b 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -979,7 +979,7 @@ impl ToplevelData { } fd.workspace.remove_fullscreen_node(); if fd.placeholder.is_destroyed() { - state.map_tiled(node); + state.map_tiled_without_autotile(node); return; } let parent = fd.placeholder.tl_data().parent.take().unwrap(); @@ -1262,7 +1262,7 @@ pub fn toplevel_set_floating(state: &Rc, tl: Rc, floati }; if !floating { parent.cnode_remove_child2(&*tl, true); - state.map_tiled(tl); + state.map_tiled_without_autotile(tl); } else if let Some(ws) = data.workspace.get() { let node_id = data.node_id; let old_body = diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index 35aca02c..0eed4a21 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -600,6 +600,14 @@ pub struct Config { pub simple_im: Option, pub fallback_output_mode: Option, pub mouse_follows_focus: Option, + pub scratchpads: Vec, + pub autotile: Option, +} + +#[derive(Debug, Clone)] +pub struct Scratchpad { + pub name: String, + pub exec: Option, } #[derive(Debug, Error)] diff --git a/toml-config/src/config/parsers/config.rs b/toml-config/src/config/parsers/config.rs index d82be95b..112f7471 100644 --- a/toml-config/src/config/parsers/config.rs +++ b/toml-config/src/config/parsers/config.rs @@ -156,6 +156,7 @@ impl Parser for ConfigParser<'_> { mouse_follows_focus, animations_val, ), + (scratchpads_val, autotile), ) = ext.extract(( ( opt(val("keymap")), @@ -217,6 +218,7 @@ impl Parser for ConfigParser<'_> { recover(opt(bol("unstable-mouse-follows-focus"))), opt(val("animations")), ), + (opt(val("scratchpads")), recover(opt(bol("autotile")))), ))?; let mut keymap = None; if let Some(value) = keymap_val { @@ -618,6 +620,8 @@ impl Parser for ConfigParser<'_> { simple_im, fallback_output_mode, mouse_follows_focus: mouse_follows_focus.despan(), + scratchpads, + autotile: autotile.despan(), }) } } diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index 4dbf8e74..d8bfea89 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -27,7 +27,7 @@ use { client::Client, config, config_dir, exec::{Command, set_env, unset_env}, - get_workspace, + get_autotile, get_workspace, input::{ FocusFollowsMouseMode, InputDevice, Seat, SwitchEvent, capability::CAP_SWITCH, get_seat, input_devices, on_input_device_removed, on_new_input_device, @@ -40,11 +40,10 @@ use { on_devices_enumerated, on_idle, on_unload, quit, reload, set_animation_cubic_bezier, set_animation_curve, set_animation_duration_ms, set_animation_style, set_animations_enabled, set_autotile, set_color_management_enabled, set_corner_radius, - set_default_workspace_capture, - set_explicit_sync_enabled, set_float_above_fullscreen, set_floating_titles, set_idle, - set_idle_grace_period, set_middle_click_paste_enabled, set_show_bar, - set_show_float_pin_icon, set_show_titles, set_tab_title_align, set_ui_drag_enabled, - set_ui_drag_threshold, + set_default_workspace_capture, set_explicit_sync_enabled, set_float_above_fullscreen, + set_floating_titles, set_idle, set_idle_grace_period, set_middle_click_paste_enabled, + set_show_bar, set_show_float_pin_icon, set_show_titles, set_tab_title_align, + set_ui_drag_enabled, set_ui_drag_threshold, status::{set_i3bar_separator, set_status, set_status_command, unset_status_command}, switch_to_vt, tasks::{self, JoinHandle}, @@ -270,12 +269,7 @@ impl Action { SimpleCommand::MoveTabLeft => b.new(move || s.move_tab(false)), SimpleCommand::MoveTabRight => b.new(move || s.move_tab(true)), SimpleCommand::SetAutotile(enabled) => b.new(move || set_autotile(enabled)), - SimpleCommand::ToggleAutotile => { - b.new(move || { - // Toggle not directly supported; set to true - set_autotile(true) - }) - } + SimpleCommand::ToggleAutotile => b.new(move || set_autotile(!get_autotile())), }, Action::Multi { actions } => { let actions: Vec<_> = actions.into_iter().map(|a| a.into_fn(state)).collect(); @@ -1747,6 +1741,9 @@ fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc Command { diff --git a/toml-spec/spec/spec.generated.json b/toml-spec/spec/spec.generated.json index 50cc8887..4d6cb2bf 100644 --- a/toml-spec/spec/spec.generated.json +++ b/toml-spec/spec/spec.generated.json @@ -1209,6 +1209,10 @@ "type": "boolean", "description": "Configures whether middle-click pasting is enabled.\n\nChanging this has no effect on running applications.\n\nThe default is `true`.\n" }, + "autotile": { + "type": "boolean", + "description": "Configures whether autotiling is enabled by default.\n\nWhen enabled, newly mapped tiled windows alternate their split\norientation automatically. This can also be toggled at runtime via the\n`enable-autotile`, `disable-autotile`, and `toggle-autotile` actions.\n\nThe default is `false`.\n" + }, "modes": { "description": "Configures the input modes.\n\nModes can be used to define shortcuts that are only active when the mode is\nactive.\n\n- Example\n\n ```toml\n [modes.\"navigation\".shortcuts]\n w = \"focus-up\"\n a = \"focus-left\"\n s = \"focus-down\"\n d = \"focus-right\"\n r = \"focus-above\"\n f = \"focus-below\"\n q = \"focus-prev\"\n e = \"focus-next\"\n ```\n\nModes can be activated with the `push-mode` and `latch-mode` actions.\n", "type": "object", @@ -2068,6 +2072,9 @@ "make-group-tab", "change-group-opposite", "toggle-tab", + "enable-autotile", + "disable-autotile", + "toggle-autotile", "toggle-fullscreen", "enter-fullscreen", "exit-fullscreen", diff --git a/toml-spec/spec/spec.generated.md b/toml-spec/spec/spec.generated.md index a31a3767..1a9d82a8 100644 --- a/toml-spec/spec/spec.generated.md +++ b/toml-spec/spec/spec.generated.md @@ -2489,6 +2489,18 @@ The table has the following fields: The value of this field should be a boolean. +- `autotile` (optional): + + Configures whether autotiling is enabled by default. + + When enabled, newly mapped tiled windows alternate their split + orientation automatically. This can also be toggled at runtime via the + `enable-autotile`, `disable-autotile`, and `toggle-autotile` actions. + + The default is `false`. + + The value of this field should be a boolean. + - `modes` (optional): Configures the input modes. @@ -4613,6 +4625,18 @@ The string should have one of the following values: Toggles the current group between tabbed and split mode. +- `enable-autotile`: + + Enables alternating split orientation for newly tiled windows. + +- `disable-autotile`: + + Disables alternating split orientation for newly tiled windows. + +- `toggle-autotile`: + + Toggles alternating split orientation for newly tiled windows. + - `toggle-fullscreen`: Toggle the currently focused window between fullscreen and windowed. @@ -5806,4 +5830,3 @@ The table has the following fields: The scaling mode of X windows. The value of this field should be a [XScalingMode](#types-XScalingMode). - diff --git a/toml-spec/spec/spec.yaml b/toml-spec/spec/spec.yaml index 706c016a..7bc2b970 100644 --- a/toml-spec/spec/spec.yaml +++ b/toml-spec/spec/spec.yaml @@ -1064,6 +1064,12 @@ SimpleActionName: description: Toggles the current group's direction. - value: toggle-tab description: Toggles the current group between tabbed and split mode. + - value: enable-autotile + description: Enables alternating split orientation for newly tiled windows. + - value: disable-autotile + description: Disables alternating split orientation for newly tiled windows. + - value: toggle-autotile + description: Toggles alternating split orientation for newly tiled windows. - value: toggle-fullscreen description: Toggle the currently focused window between fullscreen and windowed. - value: enter-fullscreen @@ -3129,10 +3135,21 @@ Config: required: false description: | Configures whether middle-click pasting is enabled. - + Changing this has no effect on running applications. The default is `true`. + autotile: + kind: boolean + required: false + description: | + Configures whether autotiling is enabled by default. + + When enabled, newly mapped tiled windows alternate their split + orientation automatically. This can also be toggled at runtime via the + `enable-autotile`, `disable-autotile`, and `toggle-autotile` actions. + + The default is `false`. modes: kind: map values: From d756c8a6a26b706dd7e4ff98a6ba6cb7f83c4441 Mon Sep 17 00:00:00 2001 From: atagen Date: Sun, 31 May 2026 17:23:56 +1000 Subject: [PATCH 44/47] feat: implement scratchpad window toggling --- jay-config/src/_private/client.rs | 12 ++ jay-config/src/_private/ipc.rs | 12 ++ jay-config/src/input.rs | 16 +++ jay-config/src/window.rs | 7 ++ src/compositor.rs | 1 + src/config/handler.rs | 35 ++++++ src/it/test_config.rs | 14 +++ src/it/tests.rs | 2 + src/it/tests/t0055_scratchpad.rs | 50 ++++++++ src/state.rs | 147 ++++++++++++++++++++++- src/tree/toplevel.rs | 61 ++++++++++ toml-config/src/config.rs | 8 ++ toml-config/src/config/parsers/action.rs | 22 ++++ toml-config/src/lib.rs | 4 + toml-spec/spec/spec.generated.json | 34 ++++++ toml-spec/spec/spec.generated.md | 53 ++++++++ toml-spec/spec/spec.yaml | 40 ++++++ 17 files changed, 515 insertions(+), 3 deletions(-) create mode 100644 src/it/tests/t0055_scratchpad.rs diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index 7c78abac..57075e68 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -640,6 +640,18 @@ impl ConfigClient { self.send(&ClientMessage::SetWindowWorkspace { window, workspace }); } + pub fn seat_send_to_scratchpad(&self, seat: Seat, name: &str) { + self.send(&ClientMessage::SeatSendToScratchpad { seat, name }); + } + + pub fn seat_toggle_scratchpad(&self, seat: Seat, name: &str) { + self.send(&ClientMessage::SeatToggleScratchpad { seat, name }); + } + + pub fn window_send_to_scratchpad(&self, window: Window, name: &str) { + self.send(&ClientMessage::WindowSendToScratchpad { window, name }); + } + pub fn seat_split(&self, seat: Seat) -> Axis { let res = self.send_with_response(&ClientMessage::GetSeatSplit { seat }); get_response!(res, Axis::Horizontal, GetSplit { axis }); diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index c61c1af6..20ca2269 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -286,6 +286,14 @@ pub enum ClientMessage<'a> { seat: Seat, workspace: Workspace, }, + SeatSendToScratchpad { + seat: Seat, + name: &'a str, + }, + SeatToggleScratchpad { + seat: Seat, + name: &'a str, + }, GetTimer { name: &'a str, }, @@ -687,6 +695,10 @@ pub enum ClientMessage<'a> { window: Window, workspace: Workspace, }, + WindowSendToScratchpad { + window: Window, + name: &'a str, + }, SetWindowFullscreen { window: Window, fullscreen: bool, diff --git a/jay-config/src/input.rs b/jay-config/src/input.rs index dbdef1ba..560197c4 100644 --- a/jay-config/src/input.rs +++ b/jay-config/src/input.rs @@ -466,6 +466,22 @@ impl Seat { get!().set_seat_workspace(self, workspace) } + /// Sends the currently focused window to a scratchpad. + /// + /// Use an empty string for the default scratchpad. + pub fn send_to_scratchpad(self, name: &str) { + get!().seat_send_to_scratchpad(self, name) + } + + /// Toggles a scratchpad. + /// + /// If the scratchpad has a visible window, that window is hidden. Otherwise, the + /// most recently hidden window in the scratchpad is shown on the current workspace. + /// Use an empty string for the default scratchpad. + pub fn toggle_scratchpad(self, name: &str) { + get!().seat_toggle_scratchpad(self, name) + } + /// Toggles whether the currently focused window is fullscreen. pub fn toggle_fullscreen(self) { let c = get!(); diff --git a/jay-config/src/window.rs b/jay-config/src/window.rs index 662cda44..96e4d3b1 100644 --- a/jay-config/src/window.rs +++ b/jay-config/src/window.rs @@ -205,6 +205,13 @@ impl Window { get!().set_window_workspace(self, workspace) } + /// Sends the window to a scratchpad. + /// + /// Use an empty string for the default scratchpad. + pub fn send_to_scratchpad(self, name: &str) { + get!().window_send_to_scratchpad(self, name) + } + /// Toggles whether the currently focused window is fullscreen. pub fn toggle_fullscreen(self) { self.set_fullscreen(!self.fullscreen()) diff --git a/src/compositor.rs b/src/compositor.rs index 11f23808..4dd47342 100644 --- a/src/compositor.rs +++ b/src/compositor.rs @@ -403,6 +403,7 @@ fn start_compositor2( bo_drop_queue: Rc::new(ObjectDropQueue::new(&ring)), virtual_outputs: Default::default(), clean_logs_older_than: Default::default(), + scratchpads: Default::default(), }); state.tracker.register(ClientId::from_raw(0)); create_dummy_output(&state); diff --git a/src/config/handler.rs b/src/config/handler.rs index 9a11acab..f6bc224f 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -1100,6 +1100,24 @@ impl ConfigProxyHandler { Ok(()) } + fn handle_seat_send_to_scratchpad(&self, seat: Seat, name: &str) -> Result<(), CphError> { + self.state.with_linear_layout_animations(|| { + let seat = self.get_seat(seat)?; + if let Some(toplevel) = seat.get_keyboard_node().node_toplevel() { + self.state.send_to_scratchpad(name, toplevel); + } + Ok(()) + }) + } + + fn handle_seat_toggle_scratchpad(&self, seat: Seat, name: &str) -> Result<(), CphError> { + self.state.with_linear_layout_animations(|| { + let seat = self.get_seat(seat)?; + self.state.toggle_scratchpad(&seat, name); + Ok(()) + }) + } + fn handle_set_window_workspace(&self, window: Window, ws: Workspace) -> Result<(), CphError> { let window = self.get_window(window)?; let name = self.get_workspace(ws)?; @@ -1114,6 +1132,14 @@ impl ConfigProxyHandler { Ok(()) } + fn handle_window_send_to_scratchpad(&self, window: Window, name: &str) -> Result<(), CphError> { + self.state.with_linear_layout_animations(|| { + let window = self.get_window(window)?; + self.state.send_to_scratchpad(name, window); + Ok(()) + }) + } + fn handle_get_device_name(&self, device: InputDevice) -> Result<(), CphError> { let dev = self.get_device_handler_data(device)?; let name = dev.device.name(); @@ -2989,6 +3015,12 @@ impl ConfigProxyHandler { ClientMessage::SetSeatWorkspace { seat, workspace } => self .handle_set_seat_workspace(seat, workspace) .wrn("set_seat_workspace")?, + ClientMessage::SeatSendToScratchpad { seat, name } => self + .handle_seat_send_to_scratchpad(seat, name) + .wrn("seat_send_to_scratchpad")?, + ClientMessage::SeatToggleScratchpad { seat, name } => self + .handle_seat_toggle_scratchpad(seat, name) + .wrn("seat_toggle_scratchpad")?, ClientMessage::GetConnector { ty, idx } => { self.handle_get_connector(ty, idx).wrn("get_connector")? } @@ -3373,6 +3405,9 @@ impl ConfigProxyHandler { ClientMessage::SetWindowWorkspace { window, workspace } => self .handle_set_window_workspace(window, workspace) .wrn("set_window_workspace")?, + ClientMessage::WindowSendToScratchpad { window, name } => self + .handle_window_send_to_scratchpad(window, name) + .wrn("window_send_to_scratchpad")?, ClientMessage::SetWindowFullscreen { window, fullscreen } => self .handle_set_window_fullscreen(window, fullscreen) .wrn("set_window_fullscreen")?, diff --git a/src/it/test_config.rs b/src/it/test_config.rs index 7691bbcd..5eba8aca 100644 --- a/src/it/test_config.rs +++ b/src/it/test_config.rs @@ -284,6 +284,20 @@ impl TestConfig { }) } + pub fn send_to_scratchpad(&self, seat: SeatId, name: &str) -> TestResult { + self.send(ClientMessage::SeatSendToScratchpad { + seat: Seat(seat.raw() as _), + name, + }) + } + + pub fn toggle_scratchpad(&self, seat: SeatId, name: &str) -> TestResult { + self.send(ClientMessage::SeatToggleScratchpad { + seat: Seat(seat.raw() as _), + name, + }) + } + fn clear(&self) { unsafe { if let Some(srv) = self.srv.take() { diff --git a/src/it/tests.rs b/src/it/tests.rs index 3e1e502c..35b6be97 100644 --- a/src/it/tests.rs +++ b/src/it/tests.rs @@ -86,6 +86,7 @@ mod t0052_bar; mod t0053_theme; mod t0054_subsurface_already_attached; mod t0055_autotiling; +mod t0055_scratchpad; pub trait TestCase: Sync { fn name(&self) -> &'static str; @@ -160,5 +161,6 @@ pub fn tests() -> Vec<&'static dyn TestCase> { t0053_theme, t0054_subsurface_already_attached, t0055_autotiling, + t0055_scratchpad, } } diff --git a/src/it/tests/t0055_scratchpad.rs b/src/it/tests/t0055_scratchpad.rs new file mode 100644 index 00000000..2519335a --- /dev/null +++ b/src/it/tests/t0055_scratchpad.rs @@ -0,0 +1,50 @@ +use { + crate::{ + it::{test_error::TestResult, testrun::TestRun}, + tree::Node, + }, + std::rc::Rc, +}; + +testcase!(); + +async fn test(run: Rc) -> TestResult { + let ds = run.create_default_setup().await?; + + let client = run.create_client().await?; + let win1 = client.create_window().await?; + win1.map2().await?; + let win2 = client.create_window().await?; + win2.map2().await?; + + run.cfg.send_to_scratchpad(ds.seat.id(), "term")?; + client.sync().await; + tassert!(win1.tl.server.node_visible()); + tassert!(!win2.tl.server.node_visible()); + + run.cfg.show_workspace(ds.seat.id(), "2")?; + run.cfg.toggle_scratchpad(ds.seat.id(), "term")?; + client.sync().await; + tassert!(win2.tl.server.node_visible()); + tassert_eq!(ds.output.workspace.get().unwrap().name.as_str(), "2"); + + run.cfg.toggle_scratchpad(ds.seat.id(), "term")?; + client.sync().await; + tassert!(!win2.tl.server.node_visible()); + + run.cfg.toggle_scratchpad(ds.seat.id(), "term")?; + client.sync().await; + tassert!(win2.tl.server.node_visible()); + tassert_eq!(ds.output.workspace.get().unwrap().name.as_str(), "2"); + + run.cfg.show_workspace(ds.seat.id(), "3")?; + client.sync().await; + tassert!(!win2.tl.server.node_visible()); + + run.cfg.toggle_scratchpad(ds.seat.id(), "term")?; + client.sync().await; + tassert!(win2.tl.server.node_visible()); + tassert_eq!(ds.output.workspace.get().unwrap().name.as_str(), "3"); + + Ok(()) +} diff --git a/src/state.rs b/src/state.rs index a7dad1d5..d10ff054 100644 --- a/src/state.rs +++ b/src/state.rs @@ -114,9 +114,11 @@ use { tree::{ ContainerNode, ContainerSplit, Direction, DisplayNode, FindTreeUsecase, FloatNode, FoundNode, LatchListener, Node, NodeId, NodeIds, NodeVisitorBase, OutputNode, - PlaceholderNode, TearingMode, TileState, ToplevelData, ToplevelIdentifier, - ToplevelNode, ToplevelNodeBase, Transform, VrrMode, WorkspaceDisplayOrder, - WorkspaceNode, WorkspaceNodeId, WsMoveConfig, generic_node_visitor, move_ws_to_output, + PlaceholderNode, ScratchpadToplevelState, TearingMode, TileState, ToplevelData, + ToplevelIdentifier, ToplevelNode, ToplevelNodeBase, Transform, VrrMode, + WorkspaceDisplayOrder, WorkspaceNode, WorkspaceNodeId, WsMoveConfig, + generic_node_visitor, move_ws_to_output, toplevel_hide_for_scratchpad, + toplevel_restore_from_scratchpad, toplevel_set_workspace, }, udmabuf::UdmabufHolder, utils::{ @@ -412,6 +414,7 @@ pub struct State { pub bo_drop_queue: Rc>>, pub virtual_outputs: VirtualOutputs, pub clean_logs_older_than: Cell>, + pub scratchpads: RefCell>>>, } // impl Drop for State { @@ -459,6 +462,28 @@ pub struct IdleState { pub in_grace_period: Cell, } +pub struct ScratchpadEntry { + node: Weak, + identifier: ToplevelIdentifier, + hidden: Cell, + restore: RefCell>, +} + +impl ScratchpadEntry { + fn alive(&self) -> bool { + self.node().is_some() + } + + fn node(&self) -> Option> { + let node = self.node.upgrade()?; + if node.tl_data().identifier.get() == self.identifier { + Some(node) + } else { + None + } + } +} + impl IdleState { pub fn set_timeout(&self, state: &State, timeout: Duration) { self.timeout.set(timeout); @@ -1023,6 +1048,121 @@ impl State { float } + pub fn send_to_scratchpad(self: &Rc, name: &str, node: Rc) { + if node.node_is_placeholder() { + return; + } + let identifier = node.tl_data().identifier.get(); + let entry = Rc::new(ScratchpadEntry { + node: Rc::downgrade(&node), + identifier, + hidden: Cell::new(false), + restore: Default::default(), + }); + let Some(restore) = toplevel_hide_for_scratchpad(node) else { + return; + }; + *entry.restore.borrow_mut() = Some(restore); + entry.hidden.set(true); + { + let mut scratchpads = self.scratchpads.borrow_mut(); + for entries in scratchpads.values_mut() { + entries.retain(|entry| entry.alive() && entry.identifier != identifier); + } + scratchpads + .entry(name.to_string()) + .or_default() + .push(entry.clone()); + } + self.tree_changed(); + } + + pub fn toggle_scratchpad(self: &Rc, seat: &Rc, name: &str) { + let entry = { + let mut scratchpads = self.scratchpads.borrow_mut(); + let Some(entries) = scratchpads.get_mut(name) else { + return; + }; + entries.retain(|entry| entry.alive()); + entries + .iter() + .rev() + .find(|entry| { + !entry.hidden.get() && entry.node().is_some_and(|node| node.node_visible()) + }) + .cloned() + .or_else(|| { + entries + .iter() + .rev() + .find(|entry| { + entry.hidden.get() + || entry.node().is_some_and(|node| !node.node_visible()) + }) + .cloned() + }) + }; + let Some(entry) = entry else { + return; + }; + if entry.hidden.get() { + self.show_scratchpad_entry(seat, &entry); + } else if entry.node().is_some_and(|node| !node.node_visible()) { + self.move_scratchpad_entry_to_current_workspace(seat, &entry); + } else { + self.hide_scratchpad_entry(&entry); + } + } + + fn hide_scratchpad_entry(self: &Rc, entry: &Rc) { + let Some(node) = entry.node() else { + return; + }; + if let Some(restore) = toplevel_hide_for_scratchpad(node) { + *entry.restore.borrow_mut() = Some(restore); + entry.hidden.set(true); + self.tree_changed(); + } + } + + fn show_scratchpad_entry( + self: &Rc, + seat: &Rc, + entry: &Rc, + ) { + if !entry.hidden.get() { + return; + } + let Some(node) = entry.node() else { + return; + }; + let restore = entry.restore.borrow(); + let Some(restore) = restore.as_ref() else { + return; + }; + let ws = seat.get_fallback_output().ensure_workspace(); + toplevel_restore_from_scratchpad(self, node.clone(), &ws, restore); + entry.hidden.set(false); + node.node_do_focus(seat, Direction::Unspecified); + seat.maybe_schedule_warp_mouse_to_focus(); + self.tree_changed(); + } + + fn move_scratchpad_entry_to_current_workspace( + self: &Rc, + seat: &Rc, + entry: &Rc, + ) { + let Some(node) = entry.node() else { + return; + }; + let ws = seat.get_fallback_output().ensure_workspace(); + toplevel_set_workspace(self, node.clone(), &ws); + node.node_do_focus(seat, Direction::Unspecified); + seat.maybe_schedule_warp_mouse_to_focus(); + self.tree_changed(); + } + fn focus_after_map(&self, node: Rc, seat: Option<&Rc>) { if !node.node_visible() { return; @@ -1298,6 +1438,7 @@ impl State { self.node_at_tree.borrow_mut().clear(); self.position_hint_requests.clear(); self.pending_warp_mouse_to_focus.clear(); + self.scratchpads.borrow_mut().clear(); self.head_managers.clear(); self.head_managers_async.clear(); self.const_40hz_latch.clear(); diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index 7fff564b..85661202 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -1323,3 +1323,64 @@ pub fn toplevel_set_workspace(state: &Rc, tl: Rc, ws: & tl.tl_set_fullscreen(true, Some(ws.clone())); } } + +pub struct ScratchpadToplevelState { + pub floating: bool, + pub fullscreen: bool, + pub workspace: Option>, +} + +pub fn toplevel_hide_for_scratchpad(tl: Rc) -> Option { + if tl.node_is_placeholder() { + return None; + } + let data = tl.tl_data(); + let scratchpad_state = ScratchpadToplevelState { + floating: data.parent_is_float.get(), + fullscreen: data.is_fullscreen.get(), + workspace: data.workspace.get(), + }; + if data.is_fullscreen.get() { + tl.clone().tl_set_fullscreen(false, None); + if data.is_fullscreen.get() { + return None; + } + } + let parent = data.parent.get()?; + let kb_foci = collect_kb_foci(tl.clone()); + parent.cnode_remove_child2(&*tl, true); + data.parent.take(); + data.float.take(); + if data.parent_is_float.replace(false) { + data.property_changed(TL_CHANGED_FLOATING); + } + if data.workspace.take().is_some() { + data.property_changed(TL_CHANGED_WORKSPACE); + } + tl.tl_set_visible(false); + if let Some(workspace) = &scratchpad_state.workspace { + for seat in kb_foci { + workspace + .clone() + .node_do_focus(&seat, Direction::Unspecified); + } + } + Some(scratchpad_state) +} + +pub fn toplevel_restore_from_scratchpad( + state: &Rc, + tl: Rc, + ws: &Rc, + scratchpad_state: &ScratchpadToplevelState, +) { + if scratchpad_state.floating { + let (width, height) = tl.tl_data().float_size(ws); + state.map_floating(tl.clone(), width, height, ws, None); + } else { + state.map_tiled_on(tl.clone(), ws); + } + if scratchpad_state.fullscreen && ws.fullscreen.is_none() { + tl.tl_set_fullscreen(true, Some(ws.clone())); + } +} diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index 0eed4a21..894cb072 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -64,6 +64,8 @@ pub enum SimpleCommand { SetFloating(bool), ToggleFullscreen, SetFullscreen(bool), + SendToScratchpad, + ToggleScratchpad, Forward(bool), EnableWindowManagement(bool), SetFloatAboveFullscreen(bool), @@ -130,6 +132,12 @@ pub enum Action { MoveToWorkspace { name: String, }, + SendToScratchpad { + name: String, + }, + ToggleScratchpad { + name: String, + }, Multi { actions: Vec, }, diff --git a/toml-config/src/config/parsers/action.rs b/toml-config/src/config/parsers/action.rs index 7581198d..1afd8740 100644 --- a/toml-config/src/config/parsers/action.rs +++ b/toml-config/src/config/parsers/action.rs @@ -117,6 +117,8 @@ impl ActionParser<'_> { "toggle-fullscreen" => ToggleFullscreen, "enter-fullscreen" => SetFullscreen(true), "exit-fullscreen" => SetFullscreen(false), + "send-to-scratchpad" => SendToScratchpad, + "toggle-scratchpad" => ToggleScratchpad, "focus-parent" => FocusParent, "close" => Close, "disable-pointer-constraint" => DisablePointerConstraint, @@ -222,6 +224,24 @@ impl ActionParser<'_> { Ok(Action::MoveToWorkspace { name }) } + fn parse_send_to_scratchpad(&mut self, ext: &mut Extractor<'_>) -> ParseResult { + let name = ext + .extract(opt(str("name")))? + .map(|name| name.value) + .unwrap_or("") + .to_string(); + Ok(Action::SendToScratchpad { name }) + } + + fn parse_toggle_scratchpad(&mut self, ext: &mut Extractor<'_>) -> ParseResult { + let name = ext + .extract(opt(str("name")))? + .map(|name| name.value) + .unwrap_or("") + .to_string(); + Ok(Action::ToggleScratchpad { name }) + } + fn parse_configure_connector(&mut self, ext: &mut Extractor<'_>) -> ParseResult { let con = ext .extract(val("connector"))? @@ -551,6 +571,8 @@ impl Parser for ActionParser<'_> { "switch-to-vt" => self.parse_switch_to_vt(&mut ext), "show-workspace" => self.parse_show_workspace(&mut ext), "move-to-workspace" => self.parse_move_to_workspace(&mut ext), + "send-to-scratchpad" => self.parse_send_to_scratchpad(&mut ext), + "toggle-scratchpad" => self.parse_toggle_scratchpad(&mut ext), "configure-connector" => self.parse_configure_connector(&mut ext), "configure-input" => self.parse_configure_input(&mut ext), "configure-output" => self.parse_configure_output(&mut ext), diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index d8bfea89..cc09047b 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -173,6 +173,8 @@ impl Action { SimpleCommand::Move(dir) => window_or_seat!(s, s.move_(dir)), SimpleCommand::ToggleFullscreen => window_or_seat!(s, s.toggle_fullscreen()), SimpleCommand::SetFullscreen(b) => window_or_seat!(s, s.set_fullscreen(b)), + SimpleCommand::SendToScratchpad => window_or_seat!(s, s.send_to_scratchpad("")), + SimpleCommand::ToggleScratchpad => b.new(move || s.toggle_scratchpad("")), SimpleCommand::FocusParent => b.new(move || s.focus_parent()), SimpleCommand::Close => window_or_seat!(s, s.close()), SimpleCommand::DisablePointerConstraint => { @@ -306,6 +308,8 @@ impl Action { let workspace = get_workspace(&name); window_or_seat!(s, s.set_workspace(workspace)) } + Action::SendToScratchpad { name } => window_or_seat!(s, s.send_to_scratchpad(&name)), + Action::ToggleScratchpad { name } => b.new(move || s.toggle_scratchpad(&name)), Action::ConfigureConnector { con } => b.new(move || { for c in connectors() { if con.match_.matches(c) { diff --git a/toml-spec/spec/spec.generated.json b/toml-spec/spec/spec.generated.json index 4d6cb2bf..efed4522 100644 --- a/toml-spec/spec/spec.generated.json +++ b/toml-spec/spec/spec.generated.json @@ -162,6 +162,38 @@ "name" ] }, + { + "description": "Sends the currently focused window to a scratchpad and hides it.\n\nIf `name` is omitted, the default scratchpad is used.\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-shift-minus = { type = \"send-to-scratchpad\", name = \"terminal\" }\n ```\n", + "type": "object", + "properties": { + "type": { + "const": "send-to-scratchpad" + }, + "name": { + "type": "string", + "description": "The name of the scratchpad." + } + }, + "required": [ + "type" + ] + }, + { + "description": "Toggles a scratchpad.\n\nIf the scratchpad has a visible window, that window is hidden. Otherwise, the\nmost recently hidden window in the scratchpad is shown on the current workspace.\nIf `name` is omitted, the default scratchpad is used.\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-minus = { type = \"toggle-scratchpad\", name = \"terminal\" }\n ```\n", + "type": "object", + "properties": { + "type": { + "const": "toggle-scratchpad" + }, + "name": { + "type": "string", + "description": "The name of the scratchpad." + } + }, + "required": [ + "type" + ] + }, { "description": "Moves a workspace to a different output.\n\n- Example 1: Move a specific workspace to a named output\n\n ```toml\n [shortcuts]\n alt-F1 = { type = \"move-to-output\", workspace = \"1\", output.name = \"right\" }\n ```\n\n- Example 2: Move the current workspace to a named output\n\n ```toml\n [shortcuts]\n alt-F1 = { type = \"move-to-output\", output.name = \"right\" }\n ```\n\n- Example 3: Move the current workspace to the output on the right (directional)\n\n ```toml\n [shortcuts]\n \"logo+ctrl+shift+Right\" = { type = \"move-to-output\", direction = \"right\" }\n \"logo+ctrl+shift+Left\" = { type = \"move-to-output\", direction = \"left\" }\n \"logo+ctrl+shift+Up\" = { type = \"move-to-output\", direction = \"up\" }\n \"logo+ctrl+shift+Down\" = { type = \"move-to-output\", direction = \"down\" }\n ```\n", "type": "object", @@ -2078,6 +2110,8 @@ "toggle-fullscreen", "enter-fullscreen", "exit-fullscreen", + "send-to-scratchpad", + "toggle-scratchpad", "focus-parent", "close", "disable-pointer-constraint", diff --git a/toml-spec/spec/spec.generated.md b/toml-spec/spec/spec.generated.md index 1a9d82a8..df88e7c4 100644 --- a/toml-spec/spec/spec.generated.md +++ b/toml-spec/spec/spec.generated.md @@ -286,6 +286,50 @@ This table is a tagged union. The variant is determined by the `type` field. It The value of this field should be a string. +- `send-to-scratchpad`: + + Sends the currently focused window to a scratchpad and hides it. + + If `name` is omitted, the default scratchpad is used. + + - Example: + + ```toml + [shortcuts] + alt-shift-minus = { type = "send-to-scratchpad", name = "terminal" } + ``` + + The table has the following fields: + + - `name` (optional): + + The name of the scratchpad. + + The value of this field should be a string. + +- `toggle-scratchpad`: + + Toggles a scratchpad. + + If the scratchpad has a visible window, that window is hidden. Otherwise, the + most recently hidden window in the scratchpad is shown on the current workspace. + If `name` is omitted, the default scratchpad is used. + + - Example: + + ```toml + [shortcuts] + alt-minus = { type = "toggle-scratchpad", name = "terminal" } + ``` + + The table has the following fields: + + - `name` (optional): + + The name of the scratchpad. + + The value of this field should be a string. + - `move-to-output`: Moves a workspace to a different output. @@ -1007,6 +1051,7 @@ The string should have one of the following values: supported plan exists. + ### `Animations` @@ -4649,6 +4694,14 @@ The string should have one of the following values: Makes the currently focused window windowed. +- `send-to-scratchpad`: + + Sends the currently focused window to the default scratchpad. + +- `toggle-scratchpad`: + + Toggles the default scratchpad. + - `focus-parent`: Focus the parent of the currently focused window. diff --git a/toml-spec/spec/spec.yaml b/toml-spec/spec/spec.yaml index 7bc2b970..49731ad8 100644 --- a/toml-spec/spec/spec.yaml +++ b/toml-spec/spec/spec.yaml @@ -345,6 +345,42 @@ Action: description: The name of the workspace. required: true kind: string + send-to-scratchpad: + description: | + Sends the currently focused window to a scratchpad and hides it. + + If `name` is omitted, the default scratchpad is used. + + - Example: + + ```toml + [shortcuts] + alt-shift-minus = { type = "send-to-scratchpad", name = "terminal" } + ``` + fields: + name: + description: The name of the scratchpad. + required: false + kind: string + toggle-scratchpad: + description: | + Toggles a scratchpad. + + If the scratchpad has a visible window, that window is hidden. Otherwise, the + most recently hidden window in the scratchpad is shown on the current workspace. + If `name` is omitted, the default scratchpad is used. + + - Example: + + ```toml + [shortcuts] + alt-minus = { type = "toggle-scratchpad", name = "terminal" } + ``` + fields: + name: + description: The name of the scratchpad. + required: false + kind: string move-to-output: description: | Moves a workspace to a different output. @@ -1076,6 +1112,10 @@ SimpleActionName: description: Makes the currently focused window fullscreen. - value: exit-fullscreen description: Makes the currently focused window windowed. + - value: send-to-scratchpad + description: Sends the currently focused window to the default scratchpad. + - value: toggle-scratchpad + description: Toggles the default scratchpad. - value: focus-parent description: Focus the parent of the currently focused window. - value: close From b6502e1d8a336c0ebd0bda8c11c0981c7f0d9624 Mon Sep 17 00:00:00 2001 From: atagen Date: Wed, 3 Jun 2026 16:51:26 +1000 Subject: [PATCH 45/47] feat: implement declarative scratchpads --- jay-config/src/_private/client.rs | 4 + jay-config/src/_private/ipc.rs | 4 + jay-config/src/input.rs | 11 ++ src/config/handler.rs | 11 ++ src/it/test_config.rs | 7 ++ src/it/tests/t0055_scratchpad.rs | 59 ++++++++- src/state.rs | 84 ++++++++----- src/tree/toplevel.rs | 44 +++---- toml-config/src/config.rs | 4 + toml-config/src/config/parsers.rs | 1 + toml-config/src/config/parsers/action.rs | 11 ++ toml-config/src/config/parsers/config.rs | 8 ++ toml-config/src/config/parsers/scratchpad.rs | 87 +++++++++++++ toml-config/src/lib.rs | 41 ++++++- toml-spec/spec/spec.generated.json | 46 ++++++- toml-spec/spec/spec.generated.md | 122 ++++++++++++++++--- toml-spec/spec/spec.yaml | 83 ++++++++++++- 17 files changed, 549 insertions(+), 78 deletions(-) create mode 100644 toml-config/src/config/parsers/scratchpad.rs diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index 57075e68..151e7591 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -648,6 +648,10 @@ impl ConfigClient { self.send(&ClientMessage::SeatToggleScratchpad { seat, name }); } + pub fn seat_cycle_scratchpad(&self, seat: Seat, name: &str) { + self.send(&ClientMessage::SeatCycleScratchpad { seat, name }); + } + pub fn window_send_to_scratchpad(&self, window: Window, name: &str) { self.send(&ClientMessage::WindowSendToScratchpad { window, name }); } diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index 20ca2269..743acc57 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -294,6 +294,10 @@ pub enum ClientMessage<'a> { seat: Seat, name: &'a str, }, + SeatCycleScratchpad { + seat: Seat, + name: &'a str, + }, GetTimer { name: &'a str, }, diff --git a/jay-config/src/input.rs b/jay-config/src/input.rs index 560197c4..450597e2 100644 --- a/jay-config/src/input.rs +++ b/jay-config/src/input.rs @@ -477,11 +477,22 @@ impl Seat { /// /// If the scratchpad has a visible window, that window is hidden. Otherwise, the /// most recently hidden window in the scratchpad is shown on the current workspace. + /// Scratchpad windows are always shown floating. /// Use an empty string for the default scratchpad. pub fn toggle_scratchpad(self, name: &str) { get!().seat_toggle_scratchpad(self, name) } + /// Cycles through the windows of a scratchpad, one at a time. + /// + /// With nothing shown, the first window is brought up; each further invocation + /// hides the current window and shows the next; after the last window the + /// scratchpad is hidden again. Scratchpad windows are always shown floating. + /// Use an empty string for the default scratchpad. + pub fn cycle_scratchpad(self, name: &str) { + get!().seat_cycle_scratchpad(self, name) + } + /// Toggles whether the currently focused window is fullscreen. pub fn toggle_fullscreen(self) { let c = get!(); diff --git a/src/config/handler.rs b/src/config/handler.rs index f6bc224f..68ea93f5 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -1118,6 +1118,14 @@ impl ConfigProxyHandler { }) } + fn handle_seat_cycle_scratchpad(&self, seat: Seat, name: &str) -> Result<(), CphError> { + self.state.with_linear_layout_animations(|| { + let seat = self.get_seat(seat)?; + self.state.cycle_scratchpad(&seat, name); + Ok(()) + }) + } + fn handle_set_window_workspace(&self, window: Window, ws: Workspace) -> Result<(), CphError> { let window = self.get_window(window)?; let name = self.get_workspace(ws)?; @@ -3021,6 +3029,9 @@ impl ConfigProxyHandler { ClientMessage::SeatToggleScratchpad { seat, name } => self .handle_seat_toggle_scratchpad(seat, name) .wrn("seat_toggle_scratchpad")?, + ClientMessage::SeatCycleScratchpad { seat, name } => self + .handle_seat_cycle_scratchpad(seat, name) + .wrn("seat_cycle_scratchpad")?, ClientMessage::GetConnector { ty, idx } => { self.handle_get_connector(ty, idx).wrn("get_connector")? } diff --git a/src/it/test_config.rs b/src/it/test_config.rs index 5eba8aca..8cb39935 100644 --- a/src/it/test_config.rs +++ b/src/it/test_config.rs @@ -298,6 +298,13 @@ impl TestConfig { }) } + pub fn cycle_scratchpad(&self, seat: SeatId, name: &str) -> TestResult { + self.send(ClientMessage::SeatCycleScratchpad { + seat: Seat(seat.raw() as _), + name, + }) + } + fn clear(&self) { unsafe { if let Some(srv) = self.srv.take() { diff --git a/src/it/tests/t0055_scratchpad.rs b/src/it/tests/t0055_scratchpad.rs index 2519335a..5abf2440 100644 --- a/src/it/tests/t0055_scratchpad.rs +++ b/src/it/tests/t0055_scratchpad.rs @@ -1,7 +1,7 @@ use { crate::{ it::{test_error::TestResult, testrun::TestRun}, - tree::Node, + tree::{Node, ToplevelNodeBase}, }, std::rc::Rc, }; @@ -45,6 +45,63 @@ async fn test(run: Rc) -> TestResult { client.sync().await; tassert!(win2.tl.server.node_visible()); tassert_eq!(ds.output.workspace.get().unwrap().name.as_str(), "3"); + // Scratchpad windows are always shown floating. + tassert!(win2.tl.server.tl_data().parent_is_float.get()); + + // Park win2 again, then build a multi-window scratchpad and cycle it. + run.cfg.toggle_scratchpad(ds.seat.id(), "term")?; + client.sync().await; + tassert!(!win2.tl.server.node_visible()); + + // Build a three-window scratchpad. Each window is focused right after it is + // mapped, so sending the focused window parks them in a known order. + let cyc1 = client.create_window().await?; + cyc1.map2().await?; + run.cfg.send_to_scratchpad(ds.seat.id(), "cyc")?; + let cyc2 = client.create_window().await?; + cyc2.map2().await?; + run.cfg.send_to_scratchpad(ds.seat.id(), "cyc")?; + let cyc3 = client.create_window().await?; + cyc3.map2().await?; + run.cfg.send_to_scratchpad(ds.seat.id(), "cyc")?; + client.sync().await; + tassert!(!cyc1.tl.server.node_visible()); + tassert!(!cyc2.tl.server.node_visible()); + tassert!(!cyc3.tl.server.node_visible()); + + // Nothing shown: cycle brings up the first window (insertion order: cyc1). + run.cfg.cycle_scratchpad(ds.seat.id(), "cyc")?; + client.sync().await; + tassert!(cyc1.tl.server.node_visible()); + tassert!(!cyc2.tl.server.node_visible()); + tassert!(!cyc3.tl.server.node_visible()); + // Scratchpad windows are always shown floating. + tassert!(cyc1.tl.server.tl_data().parent_is_float.get()); + + // Cycle advances one at a time. + run.cfg.cycle_scratchpad(ds.seat.id(), "cyc")?; + client.sync().await; + tassert!(!cyc1.tl.server.node_visible()); + tassert!(cyc2.tl.server.node_visible()); + tassert!(!cyc3.tl.server.node_visible()); + + run.cfg.cycle_scratchpad(ds.seat.id(), "cyc")?; + client.sync().await; + tassert!(!cyc1.tl.server.node_visible()); + tassert!(!cyc2.tl.server.node_visible()); + tassert!(cyc3.tl.server.node_visible()); + + // On the final window, the next cycle hides everything. + run.cfg.cycle_scratchpad(ds.seat.id(), "cyc")?; + client.sync().await; + tassert!(!cyc1.tl.server.node_visible()); + tassert!(!cyc2.tl.server.node_visible()); + tassert!(!cyc3.tl.server.node_visible()); + + // And it wraps back to the first window. + run.cfg.cycle_scratchpad(ds.seat.id(), "cyc")?; + client.sync().await; + tassert!(cyc1.tl.server.node_visible()); Ok(()) } diff --git a/src/state.rs b/src/state.rs index d10ff054..74facaf1 100644 --- a/src/state.rs +++ b/src/state.rs @@ -114,7 +114,7 @@ use { tree::{ ContainerNode, ContainerSplit, Direction, DisplayNode, FindTreeUsecase, FloatNode, FoundNode, LatchListener, Node, NodeId, NodeIds, NodeVisitorBase, OutputNode, - PlaceholderNode, ScratchpadToplevelState, TearingMode, TileState, ToplevelData, + PlaceholderNode, TearingMode, TileState, ToplevelData, ToplevelIdentifier, ToplevelNode, ToplevelNodeBase, Transform, VrrMode, WorkspaceDisplayOrder, WorkspaceNode, WorkspaceNodeId, WsMoveConfig, generic_node_visitor, move_ws_to_output, toplevel_hide_for_scratchpad, @@ -466,7 +466,6 @@ pub struct ScratchpadEntry { node: Weak, identifier: ToplevelIdentifier, hidden: Cell, - restore: RefCell>, } impl ScratchpadEntry { @@ -1053,17 +1052,14 @@ impl State { return; } let identifier = node.tl_data().identifier.get(); + if !toplevel_hide_for_scratchpad(node.clone()) { + return; + } let entry = Rc::new(ScratchpadEntry { node: Rc::downgrade(&node), identifier, - hidden: Cell::new(false), - restore: Default::default(), + hidden: Cell::new(true), }); - let Some(restore) = toplevel_hide_for_scratchpad(node) else { - return; - }; - *entry.restore.borrow_mut() = Some(restore); - entry.hidden.set(true); { let mut scratchpads = self.scratchpads.borrow_mut(); for entries in scratchpads.values_mut() { @@ -1072,7 +1068,7 @@ impl State { scratchpads .entry(name.to_string()) .or_default() - .push(entry.clone()); + .push(entry); } self.tree_changed(); } @@ -1084,29 +1080,19 @@ impl State { return; }; entries.retain(|entry| entry.alive()); + // Prefer the currently-shown window; otherwise act on the most recent. entries .iter() .rev() - .find(|entry| { - !entry.hidden.get() && entry.node().is_some_and(|node| node.node_visible()) - }) + .find(|entry| !entry.hidden.get()) + .or_else(|| entries.last()) .cloned() - .or_else(|| { - entries - .iter() - .rev() - .find(|entry| { - entry.hidden.get() - || entry.node().is_some_and(|node| !node.node_visible()) - }) - .cloned() - }) }; let Some(entry) = entry else { return; }; if entry.hidden.get() { - self.show_scratchpad_entry(seat, &entry); + self.show_scratchpad_entry(seat, name, &entry); } else if entry.node().is_some_and(|node| !node.node_visible()) { self.move_scratchpad_entry_to_current_workspace(seat, &entry); } else { @@ -1114,12 +1100,39 @@ impl State { } } + /// Cycles through the windows of a scratchpad, one at a time: + /// nothing shown -> first window -> ... -> last window -> nothing shown. + pub fn cycle_scratchpad(self: &Rc, seat: &Rc, name: &str) { + let (current, next) = { + let mut scratchpads = self.scratchpads.borrow_mut(); + let Some(entries) = scratchpads.get_mut(name) else { + return; + }; + entries.retain(|entry| entry.alive()); + match entries.iter().position(|entry| !entry.hidden.get()) { + // Nothing shown yet: bring up the first window. + None => (None, entries.first().cloned()), + // Hide the shown window and advance; on the last window, `next` + // is `None`, so the scratchpad toggles off. + Some(i) => (entries.get(i).cloned(), entries.get(i + 1).cloned()), + } + }; + if let Some(current) = ¤t { + self.hide_scratchpad_entry(current); + } + if let Some(next) = &next { + self.show_scratchpad_entry(seat, name, next); + } + } + fn hide_scratchpad_entry(self: &Rc, entry: &Rc) { + if entry.hidden.get() { + return; + } let Some(node) = entry.node() else { return; }; - if let Some(restore) = toplevel_hide_for_scratchpad(node) { - *entry.restore.borrow_mut() = Some(restore); + if toplevel_hide_for_scratchpad(node) { entry.hidden.set(true); self.tree_changed(); } @@ -1128,6 +1141,7 @@ impl State { fn show_scratchpad_entry( self: &Rc, seat: &Rc, + name: &str, entry: &Rc, ) { if !entry.hidden.get() { @@ -1136,12 +1150,22 @@ impl State { let Some(node) = entry.node() else { return; }; - let restore = entry.restore.borrow(); - let Some(restore) = restore.as_ref() else { - return; + // Only one window of a scratchpad is visible at a time. + let siblings: Vec<_> = { + let scratchpads = self.scratchpads.borrow(); + scratchpads + .get(name) + .into_iter() + .flatten() + .filter(|sibling| !Rc::ptr_eq(sibling, entry) && !sibling.hidden.get()) + .cloned() + .collect() }; + for sibling in siblings { + self.hide_scratchpad_entry(&sibling); + } let ws = seat.get_fallback_output().ensure_workspace(); - toplevel_restore_from_scratchpad(self, node.clone(), &ws, restore); + toplevel_restore_from_scratchpad(self, node.clone(), &ws); entry.hidden.set(false); node.node_do_focus(seat, Direction::Unspecified); seat.maybe_schedule_warp_mouse_to_focus(); diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index 85661202..c0a2f013 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -1324,29 +1324,25 @@ pub fn toplevel_set_workspace(state: &Rc, tl: Rc, ws: & } } -pub struct ScratchpadToplevelState { - pub floating: bool, - pub fullscreen: bool, - pub workspace: Option>, -} - -pub fn toplevel_hide_for_scratchpad(tl: Rc) -> Option { +/// Removes a toplevel from the tree so it can be parked in a scratchpad. +/// +/// Returns `true` if the window was hidden. A placeholder, a window without a +/// parent, or a window that refuses to leave fullscreen cannot be parked. +pub fn toplevel_hide_for_scratchpad(tl: Rc) -> bool { if tl.node_is_placeholder() { - return None; + return false; } let data = tl.tl_data(); - let scratchpad_state = ScratchpadToplevelState { - floating: data.parent_is_float.get(), - fullscreen: data.is_fullscreen.get(), - workspace: data.workspace.get(), - }; + let workspace = data.workspace.get(); if data.is_fullscreen.get() { tl.clone().tl_set_fullscreen(false, None); if data.is_fullscreen.get() { - return None; + return false; } } - let parent = data.parent.get()?; + let Some(parent) = data.parent.get() else { + return false; + }; let kb_foci = collect_kb_foci(tl.clone()); parent.cnode_remove_child2(&*tl, true); data.parent.take(); @@ -1358,29 +1354,23 @@ pub fn toplevel_hide_for_scratchpad(tl: Rc) -> Option, tl: Rc, ws: &Rc, - scratchpad_state: &ScratchpadToplevelState, ) { - if scratchpad_state.floating { - let (width, height) = tl.tl_data().float_size(ws); - state.map_floating(tl.clone(), width, height, ws, None); - } else { - state.map_tiled_on(tl.clone(), ws); - } - if scratchpad_state.fullscreen && ws.fullscreen.is_none() { - tl.tl_set_fullscreen(true, Some(ws.clone())); - } + let (width, height) = tl.tl_data().float_size(ws); + state.map_floating(tl.clone(), width, height, ws, None); } diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index 894cb072..b57de5ad 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -66,6 +66,7 @@ pub enum SimpleCommand { SetFullscreen(bool), SendToScratchpad, ToggleScratchpad, + CycleScratchpad, Forward(bool), EnableWindowManagement(bool), SetFloatAboveFullscreen(bool), @@ -138,6 +139,9 @@ pub enum Action { ToggleScratchpad { name: String, }, + CycleScratchpad { + name: String, + }, Multi { actions: Vec, }, diff --git a/toml-config/src/config/parsers.rs b/toml-config/src/config/parsers.rs index e353a2f8..98d3ab73 100644 --- a/toml-config/src/config/parsers.rs +++ b/toml-config/src/config/parsers.rs @@ -41,6 +41,7 @@ pub mod modified_keysym; mod output; mod output_match; mod repeat_rate; +mod scratchpad; pub mod shortcuts; mod simple_im; mod status; diff --git a/toml-config/src/config/parsers/action.rs b/toml-config/src/config/parsers/action.rs index 1afd8740..29fdc3e4 100644 --- a/toml-config/src/config/parsers/action.rs +++ b/toml-config/src/config/parsers/action.rs @@ -119,6 +119,7 @@ impl ActionParser<'_> { "exit-fullscreen" => SetFullscreen(false), "send-to-scratchpad" => SendToScratchpad, "toggle-scratchpad" => ToggleScratchpad, + "cycle-scratchpad" => CycleScratchpad, "focus-parent" => FocusParent, "close" => Close, "disable-pointer-constraint" => DisablePointerConstraint, @@ -242,6 +243,15 @@ impl ActionParser<'_> { Ok(Action::ToggleScratchpad { name }) } + fn parse_cycle_scratchpad(&mut self, ext: &mut Extractor<'_>) -> ParseResult { + let name = ext + .extract(opt(str("name")))? + .map(|name| name.value) + .unwrap_or("") + .to_string(); + Ok(Action::CycleScratchpad { name }) + } + fn parse_configure_connector(&mut self, ext: &mut Extractor<'_>) -> ParseResult { let con = ext .extract(val("connector"))? @@ -573,6 +583,7 @@ impl Parser for ActionParser<'_> { "move-to-workspace" => self.parse_move_to_workspace(&mut ext), "send-to-scratchpad" => self.parse_send_to_scratchpad(&mut ext), "toggle-scratchpad" => self.parse_toggle_scratchpad(&mut ext), + "cycle-scratchpad" => self.parse_cycle_scratchpad(&mut ext), "configure-connector" => self.parse_configure_connector(&mut ext), "configure-input" => self.parse_configure_input(&mut ext), "configure-output" => self.parse_configure_output(&mut ext), diff --git a/toml-config/src/config/parsers/config.rs b/toml-config/src/config/parsers/config.rs index 112f7471..8e776860 100644 --- a/toml-config/src/config/parsers/config.rs +++ b/toml-config/src/config/parsers/config.rs @@ -28,6 +28,7 @@ use { log_level::LogLevelParser, output::OutputsParser, repeat_rate::RepeatRateParser, + scratchpad::ScratchpadsParser, shortcuts::{ ComplexShortcutsParser, ShortcutsParser, ShortcutsParserError, parse_modified_keysym_str, @@ -570,6 +571,13 @@ impl Parser for ConfigParser<'_> { } } } + let mut scratchpads = vec![]; + if let Some(value) = scratchpads_val { + match value.parse(&mut ScratchpadsParser(self.0)) { + Ok(v) => scratchpads = v, + Err(e) => log::warn!("Could not parse the scratchpads: {}", self.0.error(e)), + } + } Ok(Config { keymap, repeat_rate, diff --git a/toml-config/src/config/parsers/scratchpad.rs b/toml-config/src/config/parsers/scratchpad.rs new file mode 100644 index 00000000..17cc5238 --- /dev/null +++ b/toml-config/src/config/parsers/scratchpad.rs @@ -0,0 +1,87 @@ +use { + crate::{ + config::{ + Scratchpad, + context::Context, + extractor::{Extractor, ExtractorError, opt, str, val}, + parser::{DataType, ParseResult, Parser, UnexpectedDataType}, + parsers::exec::{ExecParser, ExecParserError}, + }, + toml::{ + toml_span::{Span, Spanned}, + toml_value::Value, + }, + }, + indexmap::IndexMap, + thiserror::Error, +}; + +#[derive(Debug, Error)] +pub enum ScratchpadParserError { + #[error(transparent)] + Expected(#[from] UnexpectedDataType), + #[error(transparent)] + Extract(#[from] ExtractorError), + #[error(transparent)] + Exec(#[from] ExecParserError), +} + +pub struct ScratchpadParser<'a>(pub &'a Context<'a>); + +impl Parser for ScratchpadParser<'_> { + type Value = Scratchpad; + type Error = ScratchpadParserError; + const EXPECTED: &'static [DataType] = &[DataType::Table]; + + fn parse_table( + &mut self, + span: Span, + table: &IndexMap, Spanned>, + ) -> ParseResult { + let mut ext = Extractor::new(self.0, span, table); + let (name, exec_val) = ext.extract((str("name"), opt(val("exec"))))?; + let exec = match exec_val { + None => None, + Some(e) => Some(e.parse_map(&mut ExecParser(self.0))?), + }; + Ok(Scratchpad { + name: name.value.to_string(), + exec, + }) + } +} + +pub struct ScratchpadsParser<'a>(pub &'a Context<'a>); + +impl Parser for ScratchpadsParser<'_> { + type Value = Vec; + type Error = ScratchpadParserError; + const EXPECTED: &'static [DataType] = &[DataType::Table, DataType::Array]; + + fn parse_array(&mut self, _span: Span, array: &[Spanned]) -> ParseResult { + let mut res = vec![]; + for el in array { + match el.parse(&mut ScratchpadParser(self.0)) { + Ok(o) => res.push(o), + Err(e) => { + log::warn!("Could not parse scratchpad: {}", self.0.error(e)); + } + } + } + Ok(res) + } + + fn parse_table( + &mut self, + span: Span, + table: &IndexMap, Spanned>, + ) -> ParseResult { + log::warn!( + "`scratchpads` value should be an array: {}", + self.0.error3(span) + ); + ScratchpadParser(self.0) + .parse_table(span, table) + .map(|v| vec![v]) + } +} diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index cc09047b..6e3430f8 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -15,7 +15,7 @@ use { config::{ Action, AnimationCurveConfig, ClientRule, Config, ConfigConnector, ConfigDrmDevice, ConfigKeymap, ConnectorMatch, DrmDeviceMatch, Exec, Input, InputMatch, Output, - OutputMatch, SimpleCommand, Status, Theme, WindowRule, parse_config, + OutputMatch, SimpleCommand, Status, Theme, WindowMatch, WindowRule, parse_config, }, rules::{MatcherTemp, RuleMapper}, shortcuts::ModeState, @@ -175,6 +175,7 @@ impl Action { SimpleCommand::SetFullscreen(b) => window_or_seat!(s, s.set_fullscreen(b)), SimpleCommand::SendToScratchpad => window_or_seat!(s, s.send_to_scratchpad("")), SimpleCommand::ToggleScratchpad => b.new(move || s.toggle_scratchpad("")), + SimpleCommand::CycleScratchpad => b.new(move || s.cycle_scratchpad("")), SimpleCommand::FocusParent => b.new(move || s.focus_parent()), SimpleCommand::Close => window_or_seat!(s, s.close()), SimpleCommand::DisablePointerConstraint => { @@ -310,6 +311,7 @@ impl Action { } Action::SendToScratchpad { name } => window_or_seat!(s, s.send_to_scratchpad(&name)), Action::ToggleScratchpad { name } => b.new(move || s.toggle_scratchpad(&name)), + Action::CycleScratchpad { name } => b.new(move || s.cycle_scratchpad(&name)), Action::ConfigureConnector { con } => b.new(move || { for c in connectors() { if con.match_.matches(c) { @@ -1461,6 +1463,43 @@ fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc