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/docs/window-animations-plan.md b/docs/window-animations-plan.md deleted file mode 100644 index 1c99c60e..00000000 --- a/docs/window-animations-plan.md +++ /dev/null @@ -1,200 +0,0 @@ -# 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 721b5097..151e7591 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -640,6 +640,22 @@ 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 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 }); + } + pub fn seat_split(&self, seat: Seat) -> Axis { let res = self.send_with_response(&ClientMessage::GetSeatSplit { seat }); get_response!(res, Axis::Horizontal, GetSplit { axis }); @@ -1035,6 +1051,14 @@ 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 }); + } + pub fn set_color_management_enabled(&self, enabled: bool) { self.send(&ClientMessage::SetColorManagementEnabled { enabled }); } @@ -2071,6 +2095,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 d090ba0c..743acc57 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -286,6 +286,18 @@ pub enum ClientMessage<'a> { seat: Seat, workspace: Workspace, }, + SeatSendToScratchpad { + seat: Seat, + name: &'a str, + }, + SeatToggleScratchpad { + seat: Seat, + name: &'a str, + }, + SeatCycleScratchpad { + seat: Seat, + name: &'a str, + }, GetTimer { name: &'a str, }, @@ -554,6 +566,15 @@ pub enum ClientMessage<'a> { SetAnimationCurve { curve: u32, }, + SetAnimationStyle { + style: u32, + }, + SetAnimationCubicBezier { + x1: f32, + y1: f32, + x2: f32, + y2: f32, + }, SetXScalingMode { mode: XScalingMode, }, @@ -678,6 +699,10 @@ pub enum ClientMessage<'a> { window: Window, workspace: Workspace, }, + WindowSendToScratchpad { + window: Window, + name: &'a str, + }, SetWindowFullscreen { window: Window, fullscreen: bool, @@ -914,6 +939,7 @@ pub enum ClientMessage<'a> { SetAutotile { enabled: bool, }, + GetAutotile, SetTabTitleAlign { align: u32, }, @@ -1180,6 +1206,9 @@ pub enum Response { GetCornerRadius { radius: f32, }, + GetAutotile { + enabled: bool, + }, } #[derive(Serialize, Deserialize, Debug)] diff --git a/jay-config/src/input.rs b/jay-config/src/input.rs index dbdef1ba..450597e2 100644 --- a/jay-config/src/input.rs +++ b/jay-config/src/input.rs @@ -466,6 +466,33 @@ 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. + /// 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/jay-config/src/lib.rs b/jay-config/src/lib.rs index 44546ce0..fff94506 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,21 @@ 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`. @@ -429,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/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/animation.rs b/src/animation.rs index b0091933..e76e030b 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -1,66 +1,293 @@ 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}, }, }; -const DEFAULT_DURATION_MS: u32 = 160; +pub mod multiphase; -#[derive(Copy, Clone, Debug, Eq, PartialEq)] +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, - Ease, - EaseIn, - EaseOut, - EaseInOut, + 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::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, 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::EaseOut), + curve: Cell::new(AnimationCurve::from_config(3)), + style: Cell::new(AnimationStyle::Multiphase), windows: Default::default(), + phased: Default::default(), + exits: Default::default(), tick: Default::default(), } } @@ -69,6 +296,8 @@ 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(); } @@ -79,26 +308,43 @@ impl AnimationState { node_id: NodeId, old: Rect, new: Rect, + _retained: Option>, now_nsec: u64, 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; } 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, - }; + 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 { - 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, @@ -107,12 +353,129 @@ impl AnimationState { 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), @@ -120,6 +483,55 @@ impl AnimationState { } } + 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; @@ -137,6 +549,30 @@ 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); + 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); @@ -164,6 +600,7 @@ struct WindowAnimation { duration_nsec: u64, curve: AnimationCurve, last_damage: Rect, + retained: Option>, } impl WindowAnimation { @@ -182,6 +619,196 @@ impl WindowAnimation { } } +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, @@ -225,6 +852,18 @@ impl LatchListener for AnimationTick { } } +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 @@ -246,33 +885,70 @@ 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)] 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); @@ -280,14 +956,233 @@ 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 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, 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 +1196,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) @@ -312,4 +1207,27 @@ mod tests { 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 d8fb4027..4dd47342 100644 --- a/src/compositor.rs +++ b/src/compositor.rs @@ -363,6 +363,9 @@ fn start_compositor2( 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(), @@ -400,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 0e9436c5..68ea93f5 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(()) @@ -1003,6 +1005,18 @@ 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"); + } + } + fn handle_set_direct_scanout_enabled( &self, device: Option, @@ -1086,6 +1100,32 @@ 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_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)?; @@ -1100,6 +1140,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(); @@ -1990,9 +2038,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 +2054,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> { @@ -2971,6 +3023,15 @@ 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::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")? } @@ -3237,6 +3298,10 @@ 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) + } ClientMessage::SetXScalingMode { mode } => self .handle_set_x_scaling_mode(mode) .wrn("set_x_scaling_mode")?, @@ -3351,6 +3416,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")?, @@ -3565,6 +3633,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/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.rs b/src/ifs/wl_surface.rs index 547b7e2a..4224e727 100644 --- a/src/ifs/wl_surface.rs +++ b/src/ifs/wl_surface.rs @@ -1520,25 +1520,25 @@ impl WlSurface { let bounds = self.toplevel.get().and_then(|tl| tl.tl_render_bounds()); let pos = self.buffer_abs_pos.get(); let apply_damage = |pos: Rect| { - if pending.damage_full { - let mut damage = pos; + let clip_damage = |mut damage: Rect| { + damage = damage.intersect(pos); if let Some(bounds) = bounds { damage = damage.intersect(bounds); } - self.client.state.damage(damage); + damage + }; + if pending.damage_full { + self.client.state.damage(clip_damage(pos)); } else { let matrix = self.damage_matrix.get(); if let Some(buffer) = self.buffer.get() { for damage in &pending.buffer_damage { - let mut damage = matrix.apply( + let damage = matrix.apply( pos.x1(), pos.y1(), damage.intersect(buffer.buffer.buf.rect), ); - if let Some(bounds) = bounds { - damage = damage.intersect(bounds); - } - self.client.state.damage(damage); + self.client.state.damage(clip_damage(damage)); } } for damage in &pending.surface_damage { @@ -1550,8 +1550,7 @@ impl WlSurface { let y2 = (damage.y2() + scale - 1) / scale; damage = Rect::new_saturating(x1, y1, x2, y2); } - damage = damage.intersect(bounds.unwrap_or(pos)); - self.client.state.damage(damage); + self.client.state.damage(clip_damage(damage)); } } }; 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/it/test_config.rs b/src/it/test_config.rs index 56ee5272..8cb39935 100644 --- a/src/it/test_config.rs +++ b/src/it/test_config.rs @@ -284,6 +284,27 @@ 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, + }) + } + + 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() { @@ -331,6 +352,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/test_ifs/test_viewport.rs b/src/it/test_ifs/test_viewport.rs index b25105c8..e08266de 100644 --- a/src/it/test_ifs/test_viewport.rs +++ b/src/it/test_ifs/test_viewport.rs @@ -29,6 +29,17 @@ impl TestViewport { Ok(()) } + pub fn unset_source(&self) -> Result<(), TestError> { + self.tran.send(SetSource { + self_id: self.id, + x: Fixed::from_int(-1), + y: Fixed::from_int(-1), + width: Fixed::from_int(-1), + height: Fixed::from_int(-1), + })?; + Ok(()) + } + pub fn set_destination(&self, width: i32, height: i32) -> Result<(), TestError> { self.tran.send(SetDestination { self_id: self.id, @@ -37,6 +48,15 @@ impl TestViewport { })?; Ok(()) } + + pub fn unset_destination(&self) -> Result<(), TestError> { + self.tran.send(SetDestination { + self_id: self.id, + width: -1, + height: -1, + })?; + Ok(()) + } } impl Drop for TestViewport { diff --git a/src/it/tests.rs b/src/it/tests.rs index dc28888c..35b6be97 100644 --- a/src/it/tests.rs +++ b/src/it/tests.rs @@ -85,6 +85,8 @@ mod t0051_pointer_warp; 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; @@ -158,5 +160,7 @@ pub fn tests() -> Vec<&'static dyn TestCase> { t0052_bar, t0053_theme, t0054_subsurface_already_attached, + t0055_autotiling, + t0055_scratchpad, } } diff --git a/src/it/tests/t0002_window.rs b/src/it/tests/t0002_window.rs index 84571c57..28ee359f 100644 --- a/src/it/tests/t0002_window.rs +++ b/src/it/tests/t0002_window.rs @@ -1,7 +1,6 @@ use { crate::{ it::{test_error::TestError, testrun::TestRun}, - rect::Rect, tree::Node, }, std::rc::Rc, @@ -11,29 +10,19 @@ testcase!(); /// Create and map a single surface async fn test(run: Rc) -> Result<(), TestError> { - run.backend.install_default()?; + let ds = run.create_default_setup().await?; let client = run.create_client().await?; let window = client.create_window().await?; window.map().await?; - tassert_eq!(window.tl.core.width.get(), 800); - tassert_eq!( - window.tl.core.height.get(), - 600 - 2 * run.state.theme.title_plus_underline_height() - ); + let workspace_rect = ds.output.workspace_rect.get(); - tassert_eq!( - window.tl.server.node_absolute_position(), - Rect::new_sized( - 0, - 2 * run.state.theme.title_plus_underline_height(), - window.tl.core.width.get(), - window.tl.core.height.get(), - ) - .unwrap() - ); + tassert_eq!(window.tl.core.width.get(), workspace_rect.width()); + tassert_eq!(window.tl.core.height.get(), workspace_rect.height()); + + tassert_eq!(window.tl.server.node_absolute_position(), workspace_rect); Ok(()) } diff --git a/src/it/tests/t0003_multi_window.rs b/src/it/tests/t0003_multi_window.rs index 3fbf599c..db726f90 100644 --- a/src/it/tests/t0003_multi_window.rs +++ b/src/it/tests/t0003_multi_window.rs @@ -11,7 +11,7 @@ testcase!(); /// Create and map two surfaces async fn test(run: Rc) -> Result<(), TestError> { - run.backend.install_default()?; + let ds = run.create_default_setup().await?; let client = run.create_client().await?; @@ -21,17 +21,30 @@ async fn test(run: Rc) -> Result<(), TestError> { let window2 = client.create_window().await?; window2.map().await?; - let otop = 2 * run.state.theme.title_plus_underline_height(); + let workspace_rect = ds.output.workspace_rect.get(); let bw = run.state.theme.sizes.border_width.get(); + let child_width = (workspace_rect.width() - bw) / 2; tassert_eq!( window.tl.server.node_absolute_position(), - Rect::new_sized(0, otop, (800 - bw) / 2, 600 - otop).unwrap() + Rect::new_sized( + workspace_rect.x1(), + workspace_rect.y1(), + child_width, + workspace_rect.height(), + ) + .unwrap() ); tassert_eq!( window2.tl.server.node_absolute_position(), - Rect::new_sized((800 - bw) / 2 + bw, otop, (800 - bw) / 2, 600 - otop).unwrap() + Rect::new_sized( + workspace_rect.x1() + child_width + bw, + workspace_rect.y1(), + child_width, + workspace_rect.height(), + ) + .unwrap() ); Ok(()) diff --git a/src/it/tests/t0007_subsurface/screenshot_1.qoi b/src/it/tests/t0007_subsurface/screenshot_1.qoi index 230c0408..b5954651 100644 Binary files a/src/it/tests/t0007_subsurface/screenshot_1.qoi and b/src/it/tests/t0007_subsurface/screenshot_1.qoi differ diff --git a/src/it/tests/t0007_subsurface/screenshot_2.qoi b/src/it/tests/t0007_subsurface/screenshot_2.qoi index 722271f6..718d5c29 100644 Binary files a/src/it/tests/t0007_subsurface/screenshot_2.qoi and b/src/it/tests/t0007_subsurface/screenshot_2.qoi differ diff --git a/src/it/tests/t0014_container_scroll_focus.rs b/src/it/tests/t0014_container_scroll_focus.rs index 0186cbaf..dccd1096 100644 --- a/src/it/tests/t0014_container_scroll_focus.rs +++ b/src/it/tests/t0014_container_scroll_focus.rs @@ -48,13 +48,18 @@ async fn test(run: Rc) -> TestResult { let mono_container = w_mono2.tl.container_parent()?; let container_pos = mono_container.tl_data().pos.get(); - let w_mono1_title = mono_container.render_data.borrow_mut().title_rects[0] - .move_(container_pos.x1(), container_pos.y1()); - ds.mouse.abs( - &ds.connector, - w_mono1_title.x1() as _, - w_mono1_title.y1() as _, - ); + let (tab_x, tab_y) = { + let tab_bar = mono_container.tab_bar.borrow(); + let Some(tab_bar) = tab_bar.as_ref() else { + bail!("no tab bar"); + }; + let w_mono1_title = &tab_bar.entries[0]; + ( + container_pos.x1() + w_mono1_title.x.get() + w_mono1_title.width.get() / 2, + container_pos.y1() + tab_bar.height / 2, + ) + }; + ds.mouse.abs(&ds.connector, tab_x as _, tab_y as _); client.sync().await; tassert!(enters.next().is_err()); diff --git a/src/it/tests/t0015_scroll_partial.rs b/src/it/tests/t0015_scroll_partial.rs index c6cf49b7..f5cb6e3c 100644 --- a/src/it/tests/t0015_scroll_partial.rs +++ b/src/it/tests/t0015_scroll_partial.rs @@ -26,12 +26,18 @@ async fn test(run: Rc) -> TestResult { let container = w_mono2.tl.container_parent()?; let pos = container.tl_data().pos.get(); - let w_mono1_title = container.render_data.borrow_mut().title_rects[0].move_(pos.x1(), pos.y1()); - ds.mouse.abs( - &ds.connector, - w_mono1_title.x1() as f64, - w_mono1_title.y1() as f64, - ); + let (tab_x, tab_y) = { + let tab_bar = container.tab_bar.borrow(); + let Some(tab_bar) = tab_bar.as_ref() else { + bail!("no tab bar"); + }; + let w_mono1_title = &tab_bar.entries[0]; + ( + pos.x1() + w_mono1_title.x.get() + w_mono1_title.width.get() / 2, + pos.y1() + tab_bar.height / 2, + ) + }; + ds.mouse.abs(&ds.connector, tab_x as f64, tab_y as f64); client.sync().await; let enters = dss.kb.enter.expect()?; diff --git a/src/it/tests/t0020_surface_offset/screenshot_1.qoi b/src/it/tests/t0020_surface_offset/screenshot_1.qoi index eef5f37a..4c826f86 100644 Binary files a/src/it/tests/t0020_surface_offset/screenshot_1.qoi and b/src/it/tests/t0020_surface_offset/screenshot_1.qoi differ diff --git a/src/it/tests/t0020_surface_offset/screenshot_2.qoi b/src/it/tests/t0020_surface_offset/screenshot_2.qoi index 7e8cf143..0fb763e2 100644 Binary files a/src/it/tests/t0020_surface_offset/screenshot_2.qoi and b/src/it/tests/t0020_surface_offset/screenshot_2.qoi differ diff --git a/src/it/tests/t0022_toplevel_suspended.rs b/src/it/tests/t0022_toplevel_suspended.rs index 1fdacb1a..524856e3 100644 --- a/src/it/tests/t0022_toplevel_suspended.rs +++ b/src/it/tests/t0022_toplevel_suspended.rs @@ -2,7 +2,7 @@ use { crate::{ ifs::wl_surface::xdg_surface::xdg_toplevel::STATE_SUSPENDED, it::{ - test_error::TestResult, + test_error::{TestErrorExt, TestResult}, test_utils::{ test_ouput_node_ext::TestOutputNodeExt, test_toplevel_node_ext::TestToplevelNodeExt, }, @@ -10,7 +10,7 @@ use { }, }, isnt::std_1::collections::IsntHashSetExt, - std::rc::Rc, + std::{rc::Rc, time::Duration}, }; testcase!(); @@ -19,6 +19,7 @@ async fn test(run: Rc) -> TestResult { let ds = run.create_default_setup().await?; let client = run.create_client().await?; + let default_seat = client.get_default_seat().await?; let win1 = client.create_window().await?; win1.set_color(255, 0, 0, 255); @@ -44,5 +45,23 @@ async fn test(run: Rc) -> TestResult { client.sync().await; tassert!(win2.tl.core.states.borrow().not_contains(&STATE_SUSPENDED)); + let leaves = default_seat.kb.leave.expect()?; + let enters = default_seat.kb.enter.expect()?; + + run.cfg.set_idle(Duration::from_micros(100))?; + run.cfg.set_idle_grace_period(Duration::from_secs(0))?; + run.state.wheel.timeout(3).await?; + + client.sync().await; + tassert!(win2.tl.core.states.borrow().contains(&STATE_SUSPENDED)); + let leave = leaves.next().with_context(|| "no leave on suspend")?; + tassert_eq!(leave.surface, win2.surface.id); + + ds.mouse.rel(1.0, 1.0); + client.sync().await; + tassert!(win2.tl.core.states.borrow().not_contains(&STATE_SUSPENDED)); + let enter = enters.next().with_context(|| "no enter on restore")?; + tassert_eq!(enter.surface, win2.surface.id); + Ok(()) } diff --git a/src/it/tests/t0023_xdg_activation/screenshot_1.qoi b/src/it/tests/t0023_xdg_activation/screenshot_1.qoi index 1fa8d204..960da20a 100644 Binary files a/src/it/tests/t0023_xdg_activation/screenshot_1.qoi and b/src/it/tests/t0023_xdg_activation/screenshot_1.qoi differ diff --git a/src/it/tests/t0026_output_transform/screenshot_1.qoi b/src/it/tests/t0026_output_transform/screenshot_1.qoi index 2206fc85..f11111bb 100644 Binary files a/src/it/tests/t0026_output_transform/screenshot_1.qoi and b/src/it/tests/t0026_output_transform/screenshot_1.qoi differ diff --git a/src/it/tests/t0028_top_level_restacking/screenshot_1.qoi b/src/it/tests/t0028_top_level_restacking/screenshot_1.qoi index f7bf53bf..9f5fca3c 100644 Binary files a/src/it/tests/t0028_top_level_restacking/screenshot_1.qoi and b/src/it/tests/t0028_top_level_restacking/screenshot_1.qoi differ diff --git a/src/it/tests/t0028_top_level_restacking/screenshot_2.qoi b/src/it/tests/t0028_top_level_restacking/screenshot_2.qoi index b454acd3..aaf1b108 100644 Binary files a/src/it/tests/t0028_top_level_restacking/screenshot_2.qoi and b/src/it/tests/t0028_top_level_restacking/screenshot_2.qoi differ diff --git a/src/it/tests/t0029_double_click_float/screenshot_1.qoi b/src/it/tests/t0029_double_click_float/screenshot_1.qoi index dd974ccf..e08dc525 100644 Binary files a/src/it/tests/t0029_double_click_float/screenshot_1.qoi and b/src/it/tests/t0029_double_click_float/screenshot_1.qoi differ diff --git a/src/it/tests/t0029_double_click_float/screenshot_2.qoi b/src/it/tests/t0029_double_click_float/screenshot_2.qoi index f49edd4d..e08dc525 100644 Binary files a/src/it/tests/t0029_double_click_float/screenshot_2.qoi and b/src/it/tests/t0029_double_click_float/screenshot_2.qoi differ diff --git a/src/it/tests/t0037_toplevel_drag/screenshot_2.qoi b/src/it/tests/t0037_toplevel_drag/screenshot_2.qoi index b9826001..36c68e4e 100644 Binary files a/src/it/tests/t0037_toplevel_drag/screenshot_2.qoi and b/src/it/tests/t0037_toplevel_drag/screenshot_2.qoi differ diff --git a/src/it/tests/t0038_subsurface_parent_state/screenshot_1.qoi b/src/it/tests/t0038_subsurface_parent_state/screenshot_1.qoi index 988bc767..e6f6db74 100644 Binary files a/src/it/tests/t0038_subsurface_parent_state/screenshot_1.qoi and b/src/it/tests/t0038_subsurface_parent_state/screenshot_1.qoi differ diff --git a/src/it/tests/t0038_subsurface_parent_state/screenshot_2.qoi b/src/it/tests/t0038_subsurface_parent_state/screenshot_2.qoi index a7509404..9abc8de3 100644 Binary files a/src/it/tests/t0038_subsurface_parent_state/screenshot_2.qoi and b/src/it/tests/t0038_subsurface_parent_state/screenshot_2.qoi differ diff --git a/src/it/tests/t0039_alpha_modifier/screenshot_1.qoi b/src/it/tests/t0039_alpha_modifier/screenshot_1.qoi index 8fe5d0b2..80a29c84 100644 Binary files a/src/it/tests/t0039_alpha_modifier/screenshot_1.qoi and b/src/it/tests/t0039_alpha_modifier/screenshot_1.qoi differ diff --git a/src/it/tests/t0039_alpha_modifier/screenshot_2.qoi b/src/it/tests/t0039_alpha_modifier/screenshot_2.qoi index 9874e2f5..735af290 100644 Binary files a/src/it/tests/t0039_alpha_modifier/screenshot_2.qoi and b/src/it/tests/t0039_alpha_modifier/screenshot_2.qoi differ diff --git a/src/it/tests/t0041_input_method/screenshot_1.qoi b/src/it/tests/t0041_input_method/screenshot_1.qoi index d25fcf64..cd07ecd4 100644 Binary files a/src/it/tests/t0041_input_method/screenshot_1.qoi and b/src/it/tests/t0041_input_method/screenshot_1.qoi differ diff --git a/src/it/tests/t0041_input_method/screenshot_2.qoi b/src/it/tests/t0041_input_method/screenshot_2.qoi index 7f93231a..d76ea9a0 100644 Binary files a/src/it/tests/t0041_input_method/screenshot_2.qoi and b/src/it/tests/t0041_input_method/screenshot_2.qoi differ diff --git a/src/it/tests/t0041_input_method/screenshot_3.qoi b/src/it/tests/t0041_input_method/screenshot_3.qoi index d25fcf64..cd07ecd4 100644 Binary files a/src/it/tests/t0041_input_method/screenshot_3.qoi and b/src/it/tests/t0041_input_method/screenshot_3.qoi differ diff --git a/src/it/tests/t0042_toplevel_select/screenshot_1.qoi b/src/it/tests/t0042_toplevel_select/screenshot_1.qoi index 6423ef6d..6d57d140 100644 Binary files a/src/it/tests/t0042_toplevel_select/screenshot_1.qoi and b/src/it/tests/t0042_toplevel_select/screenshot_1.qoi differ diff --git a/src/it/tests/t0042_toplevel_select/screenshot_2.qoi b/src/it/tests/t0042_toplevel_select/screenshot_2.qoi index 823fd750..478b3c43 100644 Binary files a/src/it/tests/t0042_toplevel_select/screenshot_2.qoi and b/src/it/tests/t0042_toplevel_select/screenshot_2.qoi differ diff --git a/src/it/tests/t0042_toplevel_select/screenshot_3.qoi b/src/it/tests/t0042_toplevel_select/screenshot_3.qoi index 823fd750..478b3c43 100644 Binary files a/src/it/tests/t0042_toplevel_select/screenshot_3.qoi and b/src/it/tests/t0042_toplevel_select/screenshot_3.qoi differ diff --git a/src/it/tests/t0042_toplevel_select/screenshot_4.qoi b/src/it/tests/t0042_toplevel_select/screenshot_4.qoi index 714222f1..07dd87fb 100644 Binary files a/src/it/tests/t0042_toplevel_select/screenshot_4.qoi and b/src/it/tests/t0042_toplevel_select/screenshot_4.qoi differ diff --git a/src/it/tests/t0047_surface_damage.rs b/src/it/tests/t0047_surface_damage.rs index d9760bc8..c2d0d6dd 100644 --- a/src/it/tests/t0047_surface_damage.rs +++ b/src/it/tests/t0047_surface_damage.rs @@ -308,9 +308,8 @@ async fn test(run: Rc) -> TestResult { let output_damage = connector_data.damage.borrow(); tassert!(!output_damage.is_empty()); - // Buffer damage is transformed by the damage matrix which includes the surface position - // The buffer damage (0,0,1,1) should be transformed to surface coordinates - let expected_buffer_damage = buffer_damage.move_(surface_pos.x1(), surface_pos.y1()); + // The test window maps its 1x1 buffer through a viewport to the full window size. + let expected_buffer_damage = surface_pos; // Find the exact output damage that matches our expected buffer damage let mut found_exact_buffer_damage = false; @@ -331,10 +330,12 @@ async fn test(run: Rc) -> TestResult { // Test 7: Check output damage from existing window's viewport (which already has scaling) connector_data.damage.borrow_mut().clear(); - // The existing window was created with create_surface_ext() which automatically creates a viewport - // Let's verify that the viewport's existing scaling affects buffer damage correctly - // First, let's modify the viewport scaling that already exists on the window - window.surface.viewport.set_destination(150, 100)?; // Change scaling to 150x100 + // The existing window was created with create_surface_ext() which automatically creates a viewport. + // Commit the viewport size change separately; that commit intentionally damages the old/new extents. + window.surface.viewport.set_destination(150, 100)?; + window.surface.commit()?; + client.sync().await; + connector_data.damage.borrow_mut().clear(); // Add buffer damage to test viewport scaling coordinate transformation window.surface.damage_buffer(0, 0, 1, 1)?; // Damage entire 1x1 buffer @@ -346,8 +347,8 @@ async fn test(run: Rc) -> TestResult { let output_damage = connector_data.damage.borrow(); tassert!(!output_damage.is_empty()); - // With viewporter scaling, the 1x1 buffer damage should scale to 150x100 - // and be moved by surface position (0, 36) to get output coordinates (0, 36, 150, 136) + // With viewporter scaling, the 1x1 buffer damage should scale to the viewport destination. + let surface_pos = window.surface.server.buffer_abs_pos.get(); let expected_scaled_damage = Rect::new_sized(0, 0, 150, 100).unwrap(); let expected_output_damage = expected_scaled_damage.move_(surface_pos.x1(), surface_pos.y1()); @@ -402,8 +403,9 @@ async fn test(run: Rc) -> TestResult { rotation_window.map().await?; client.sync().await; - // Disable viewporter by setting destination to 0x0 to rely purely on buffer dimensions - rotation_window.surface.viewport.set_destination(0, 0)?; // Disable viewporter + // Disable viewporter to rely purely on buffer dimensions. + rotation_window.surface.viewport.unset_source()?; + rotation_window.surface.viewport.unset_destination()?; // Use a rectangular buffer (4x2) so rotation has a visible geometric effect // Attach AFTER mapping to avoid being overwritten by map()'s single-pixel buffer 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/it/tests/t0055_scratchpad.rs b/src/it/tests/t0055_scratchpad.rs new file mode 100644 index 00000000..5abf2440 --- /dev/null +++ b/src/it/tests/t0055_scratchpad.rs @@ -0,0 +1,107 @@ +use { + crate::{ + it::{test_error::TestResult, testrun::TestRun}, + tree::{Node, ToplevelNodeBase}, + }, + 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"); + // 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/renderer.rs b/src/renderer.rs index f8174883..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,7 +18,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, }, }, @@ -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(); @@ -467,6 +480,251 @@ 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_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); @@ -507,7 +765,6 @@ impl Renderer<'_> { } } let body = visual_mb.move_(x, y); - let body = self.base.scale_rect(body); let content = container .mono_content .get() @@ -526,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 { @@ -578,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; } @@ -815,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() { @@ -823,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 f12a1745..74facaf1 100644 --- a/src/state.rs +++ b/src/state.rs @@ -2,7 +2,17 @@ use { crate::{ acceptor::Acceptor, allocator::BufferObject, - animation::{AnimationCurve, AnimationState, AnimationTick, expand_damage_rect}, + 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, @@ -104,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, 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::{ @@ -154,6 +166,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, @@ -267,6 +371,9 @@ pub struct State { 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, @@ -307,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 { @@ -354,6 +462,27 @@ pub struct IdleState { pub in_grace_period: Cell, } +pub struct ScratchpadEntry { + node: Weak, + identifier: ToplevelIdentifier, + hidden: Cell, +} + +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); @@ -816,16 +945,43 @@ 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(), true)); + } else { + 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 @@ -834,7 +990,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); } @@ -851,7 +1011,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(); @@ -882,8 +1042,149 @@ 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 + } + + pub fn send_to_scratchpad(self: &Rc, name: &str, node: Rc) { + if node.node_is_placeholder() { + 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(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); + } + 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()); + // Prefer the currently-shown window; otherwise act on the most recent. + entries + .iter() + .rev() + .find(|entry| !entry.hidden.get()) + .or_else(|| entries.last()) + .cloned() + }; + let Some(entry) = entry else { + return; + }; + if entry.hidden.get() { + 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 { + self.hide_scratchpad_entry(&entry); + } + } + + /// 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 toplevel_hide_for_scratchpad(node) { + entry.hidden.set(true); + self.tree_changed(); + } + } + + fn show_scratchpad_entry( + self: &Rc, + seat: &Rc, + name: &str, + entry: &Rc, + ) { + if !entry.hidden.get() { + return; + } + let Some(node) = entry.node() 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); + 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>) { @@ -1122,6 +1423,8 @@ 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.layout_animation_style_override.set(None); self.suppress_animations_for_next_layout.set(false); self.render_ctx_watchers.clear(); self.workspace_watchers.clear(); @@ -1159,6 +1462,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(); @@ -1469,7 +1773,62 @@ 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, + ) { + 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() @@ -1489,18 +1848,374 @@ 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, + 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( - old.union(new), + 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(); @@ -1524,6 +2239,22 @@ 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; + }; + 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); @@ -1533,6 +2264,23 @@ 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 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; @@ -2079,6 +2827,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 3a6db3a2..44a6a778 100644 --- a/src/tree/container.rs +++ b/src/tree/container.rs @@ -32,6 +32,7 @@ use { numcell::NumCell, on_drop_event::OnDropEvent, rc_eq::rc_eq, + scroller::Scroller, threshold_counter::ThresholdCounter, }, }, @@ -132,6 +133,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, @@ -149,6 +151,7 @@ pub struct ContainerNode { pub child_removed: Rc, pub all_children_resized: Rc, pub tab_bar: RefCell>, + scroll: Scroller, pub update_tab_textures_scheduled: Cell, pub ephemeral: Cell, } @@ -240,6 +243,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, @@ -264,6 +268,7 @@ impl ContainerNode { child_removed: state.lazy_event_sources.create_source(), all_children_resized: state.post_layout_event_sources.create_source(), tab_bar: RefCell::new(None), + scroll: Default::default(), update_tab_textures_scheduled: Cell::new(false), ephemeral: Cell::new(Ephemeral::Off), }); @@ -288,6 +293,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)); } @@ -473,6 +519,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 +537,7 @@ impl ContainerNode { self.damage(); } } + self.mono_transition_animation_pending.set(false); } fn perform_mono_layout(self: &Rc, child: &ContainerChild) { @@ -748,6 +796,18 @@ impl ContainerNode { self.activate_child2(child, false); } + fn activate_child_from_input( + self: &Rc, + child: &NodeRef, + seat: &Rc, + ) { + self.activate_child(child); + child + .node + .clone() + .node_do_focus(seat, Direction::Unspecified); + } + fn activate_child2(self: &Rc, child: &NodeRef, preserve_focus: bool) { if let Some(mc) = self.mono_child.get() { if mc.node.node_id() == child.node.node_id() { @@ -823,6 +883,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 { @@ -1364,42 +1425,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); @@ -1509,7 +1534,7 @@ impl ContainerNode { fn button( self: Rc, id: CursorType, - _seat: &Rc, + seat: &Rc, _time_usec: u64, pressed: bool, button: u32, @@ -1539,7 +1564,7 @@ impl ContainerNode { if let Some(child) = children.get(&child_id) { let child_ref = child.to_ref(); drop(children); - self.activate_child(&child_ref); + self.activate_child_from_input(&child_ref, seat); } return; } @@ -1766,12 +1791,39 @@ 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); - container.perform_layout(); + 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); @@ -2029,31 +2081,33 @@ impl Node for ContainerNode { self.button(id, seat, time_usec, state == ButtonState::Pressed, button); } - fn node_on_axis_event(self: Rc, _seat: &Rc, event: &PendingScroll) { + fn node_on_axis_event(self: Rc, seat: &Rc, event: &PendingScroll) { if self.mono_child.is_none() { return; } - // Use vertical scroll (index 1) to switch tabs. - let v = match event.v120[1].get() { - Some(v) if v != 0 => v, + let steps = match self.scroll.handle(event) { + Some(steps) => steps, _ => return, }; - let mono = match self.mono_child.get() { + let mut target = match self.mono_child.get() { Some(m) => m, None => return, }; - let next = if v > 0 { - // Scroll down → next tab. - mono.next().or_else(|| self.children.first()) - } else { - // Scroll up → previous tab. - mono.prev().or_else(|| self.children.last()) - }; - if let Some(next) = next { - if next.node.node_id() != mono.node.node_id() { - self.activate_child(&next); + let current_id = target.node.node_id(); + for _ in 0..steps.abs() { + let next = if steps > 0 { + target.next().or_else(|| self.children.first()) + } else { + target.prev().or_else(|| self.children.last()) + }; + match next { + Some(next) => target = next, + None => break, } } + if target.node.node_id() != current_id { + self.activate_child_from_input(&target, seat); + } } fn node_on_leave(&self, seat: &WlSeatGlobal) { @@ -2271,6 +2325,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/display.rs b/src/tree/display.rs index 440916bf..26b31a88 100644 --- a/src/tree/display.rs +++ b/src/tree/display.rs @@ -8,18 +8,25 @@ use { renderer::Renderer, state::State, tree::{ - FindTreeResult, FindTreeUsecase, FoundNode, Node, NodeId, NodeLayerLink, NodeLocation, - OutputNode, StackedNode, TileDragDestination, WorkspaceDragDestination, + Direction, FindTreeResult, FindTreeUsecase, FoundNode, Node, NodeId, NodeLayerLink, + NodeLocation, OutputNode, StackedNode, TileDragDestination, WorkspaceDragDestination, WorkspaceNodeId, walker::NodeVisitor, }, utils::{copyhashmap::CopyHashMap, linkedlist::LinkedList}, }, - std::{cell::Cell, ops::Deref, rc::Rc}, + std::{ + cell::{Cell, RefCell}, + mem, + ops::Deref, + rc::{Rc, Weak}, + }, }; pub struct DisplayNode { pub id: NodeId, pub extents: Cell, + visible: Cell, + suspend_restore_kb_foci: RefCell, Weak)>>, pub outputs: CopyHashMap>, pub stacked: Rc>>, pub stacked_above_layers: Rc>>, @@ -31,6 +38,8 @@ impl DisplayNode { let slf = Self { id, extents: Default::default(), + visible: Default::default(), + suspend_restore_kb_foci: Default::default(), outputs: Default::default(), stacked: Default::default(), stacked_above_layers: Default::default(), @@ -71,6 +80,17 @@ impl DisplayNode { pub fn update_visible(&self, state: &State) { let visible = state.root_visible(); + let was_visible = self.visible.replace(visible); + if !visible && was_visible { + let mut foci = self.suspend_restore_kb_foci.borrow_mut(); + foci.clear(); + for seat in state.globals.seats.lock().values() { + let node = seat.get_keyboard_node(); + if node.node_id() != self.id { + foci.push((seat.clone(), Rc::downgrade(&node))); + } + } + } for output in self.outputs.lock().values() { output.update_visible(); } @@ -82,6 +102,20 @@ impl DisplayNode { for seat in state.globals.seats.lock().values() { seat.set_visible(visible); } + if visible && !was_visible { + for (seat, node) in mem::take(&mut *self.suspend_restore_kb_foci.borrow_mut()) { + if seat.get_keyboard_node().node_id() == self.id { + if let Some(node) = node.upgrade() + && node.node_visible() + { + seat.focus_node(node); + } else { + seat.get_fallback_output() + .take_keyboard_navigation_focus(&seat, Direction::Unspecified); + } + } + } + } if visible { state.damage(self.extents.get()); } 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 34d637dd..c0a2f013 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,22 +192,56 @@ 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 + 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()) + .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 + && (!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_tiled_animation(data.node_id, prev, *rect); + .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() { @@ -292,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; } @@ -316,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 } @@ -356,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, @@ -394,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 { @@ -416,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, @@ -479,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), @@ -867,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(); @@ -952,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); @@ -1060,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() { @@ -1074,11 +1262,21 @@ 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 = + 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); } } @@ -1125,3 +1323,54 @@ pub fn toplevel_set_workspace(state: &Rc, tl: Rc, ws: & tl.tl_set_fullscreen(true, Some(ws.clone())); } } + +/// 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 false; + } + let data = tl.tl_data(); + let workspace = data.workspace.get(); + if data.is_fullscreen.get() { + tl.clone().tl_set_fullscreen(false, None); + if data.is_fullscreen.get() { + return false; + } + } + 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(); + 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) = &workspace { + for seat in kb_foci { + workspace + .clone() + .node_do_focus(&seat, Direction::Unspecified); + } + } + true +} + +/// Maps a parked scratchpad window back onto `ws`. Scratchpad windows always +/// return floating, regardless of how they were laid out before parking. +pub fn toplevel_restore_from_scratchpad( + state: &Rc, + tl: Rc, + ws: &Rc, +) { + let (width, height) = tl.tl_data().float_size(ws); + state.map_floating(tl.clone(), width, height, ws, None); +} 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 d860d656..b57de5ad 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -64,6 +64,9 @@ pub enum SimpleCommand { SetFloating(bool), ToggleFullscreen, SetFullscreen(bool), + SendToScratchpad, + ToggleScratchpad, + CycleScratchpad, Forward(bool), EnableWindowManagement(bool), SetFloatAboveFullscreen(bool), @@ -130,6 +133,15 @@ pub enum Action { MoveToWorkspace { name: String, }, + SendToScratchpad { + name: String, + }, + ToggleScratchpad { + name: String, + }, + CycleScratchpad { + name: String, + }, Multi { actions: Vec, }, @@ -270,7 +282,14 @@ pub struct UiDrag { pub struct Animations { pub enabled: Option, pub duration_ms: Option, - pub curve: Option, + pub style: Option, + pub curve: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum AnimationCurveConfig { + Preset(String), + CubicBezier([f32; 4]), } #[derive(Debug, Clone)] @@ -593,6 +612,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)] @@ -659,3 +686,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 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 7581198d..29fdc3e4 100644 --- a/toml-config/src/config/parsers/action.rs +++ b/toml-config/src/config/parsers/action.rs @@ -117,6 +117,9 @@ impl ActionParser<'_> { "toggle-fullscreen" => ToggleFullscreen, "enter-fullscreen" => SetFullscreen(true), "exit-fullscreen" => SetFullscreen(false), + "send-to-scratchpad" => SendToScratchpad, + "toggle-scratchpad" => ToggleScratchpad, + "cycle-scratchpad" => CycleScratchpad, "focus-parent" => FocusParent, "close" => Close, "disable-pointer-constraint" => DisablePointerConstraint, @@ -222,6 +225,33 @@ 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_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"))? @@ -551,6 +581,9 @@ 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), + "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/animations.rs b/toml-config/src/config/parsers/animations.rs index a8abdf89..cc5cb439 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, str, 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>); @@ -36,15 +44,56 @@ 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("curve"))), + 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(), - curve: curve.despan().map(|s| s.to_string()), + 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 d82be95b..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, @@ -156,6 +157,7 @@ impl Parser for ConfigParser<'_> { mouse_follows_focus, animations_val, ), + (scratchpads_val, autotile), ) = ext.extract(( ( opt(val("keymap")), @@ -217,6 +219,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 { @@ -568,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, @@ -618,6 +628,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/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 1b057985..6e3430f8 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, WindowMatch, WindowRule, parse_config, }, rules::{MatcherTemp, RuleMapper}, shortcuts::ModeState, @@ -23,11 +23,11 @@ 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}, - 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, @@ -37,13 +37,13 @@ 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, - 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, + 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, status::{set_i3bar_separator, set_status, set_status_command, unset_status_command}, switch_to_vt, tasks::{self, JoinHandle}, @@ -173,6 +173,9 @@ 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::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 => { @@ -269,12 +272,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(); @@ -311,6 +309,9 @@ 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::CycleScratchpad { name } => b.new(move || s.cycle_scratchpad(&name)), Action::ConfigureConnector { con } => b.new(move || { for c in connectors() { if con.match_.matches(c) { @@ -1462,6 +1463,43 @@ 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.style.as_deref().unwrap_or("multiphase") { + "plain" => 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(curve) = curve { - set_animation_curve(curve); } if let Some(xwayland) = config.xwayland { if let Some(enabled) = xwayland.enabled { @@ -1731,6 +1784,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 930ad697..4469c157 100644 --- a/toml-spec/spec/spec.generated.json +++ b/toml-spec/spec/spec.generated.json @@ -162,6 +162,54 @@ "name" ] }, + { + "description": "Sends the currently focused window to a scratchpad and hides it.\n\nA scratchpad can hold any number of windows. If `name` is omitted, the\ndefault 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.\nOnly one window of a scratchpad is shown at a time, and scratchpad windows are\nalways shown floating. If `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": "Cycles through the windows of a scratchpad, one at a time.\n\nWith no window shown, the first window is brought up. Each further invocation\nhides the current window and shows the next; after the last window the\nscratchpad is hidden again. Scratchpad windows are always shown floating.\nIf `name` is omitted, the default scratchpad is used.\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-minus = { type = \"cycle-scratchpad\", name = \"terminal\" }\n ```\n", + "type": "object", + "properties": { + "type": { + "const": "cycle-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", @@ -641,6 +689,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 +1188,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" @@ -1150,6 +1257,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", @@ -1177,6 +1288,14 @@ "egui": { "description": "Sets the egui settings of the compositor.\n", "$ref": "#/$defs/Egui" + }, + "scratchpads": { + "type": "array", + "description": "An array of pre-configured scratchpads.\n\nEach entry launches a program when the graphics are first initialized and\nimmediately parks its window in the named scratchpad. The window is captured\nvia a unique tag attached to the spawned process, so other windows of the\nsame application are never affected.\n\nUse a `toggle-scratchpad` or `cycle-scratchpad` action to bring the windows\nup; they are always shown floating.\n\n- Example:\n\n ```toml\n [[scratchpads]]\n name = \"term\"\n exec = \"foot\"\n\n [[scratchpads]]\n name = \"notes\"\n exec = [\"obsidian\"]\n ```\n", + "items": { + "description": "", + "$ref": "#/$defs/Scratchpad" + } } }, "required": [] @@ -1991,6 +2110,23 @@ }, "required": [] }, + "Scratchpad": { + "description": "A pre-configured scratchpad whose program is launched at startup and parked\nin the scratchpad.\n\n- Example:\n\n ```toml\n [[scratchpads]]\n name = \"term\"\n exec = \"foot\"\n ```\n", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the scratchpad that the spawned window is parked in." + }, + "exec": { + "description": "The program to launch when the graphics are first initialized.\n\nIf omitted, no program is launched and the scratchpad is only created on\ndemand by `send-to-scratchpad`.\n", + "$ref": "#/$defs/Exec" + } + }, + "required": [ + "name" + ] + }, "SimpleActionName": { "type": "string", "description": "The name of a `simple` Action.\n\nWhen used inside a window rule, the following actions apply to the matched window\ninstead fo the focused window:\n\n- `move-left`\n- `move-down`\n- `move-up`\n- `move-right`\n- `toggle-fullscreen`\n- `enter-fullscreen`\n- `exit-fullscreen`\n- `close`\n- `toggle-floating`\n- `float`\n- `tile`\n- `toggle-float-pinned`\n- `pin-float`\n- `unpin-float`\n\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-q = \"quit\"\n ```\n", @@ -2009,9 +2145,15 @@ "make-group-tab", "change-group-opposite", "toggle-tab", + "enable-autotile", + "disable-autotile", + "toggle-autotile", "toggle-fullscreen", "enter-fullscreen", "exit-fullscreen", + "send-to-scratchpad", + "toggle-scratchpad", + "cycle-scratchpad", "focus-parent", "close", "disable-pointer-constraint", diff --git a/toml-spec/spec/spec.generated.md b/toml-spec/spec/spec.generated.md index 43e9f20d..21682ada 100644 --- a/toml-spec/spec/spec.generated.md +++ b/toml-spec/spec/spec.generated.md @@ -286,6 +286,76 @@ 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. + + A scratchpad can hold any number of windows. 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. + Only one window of a scratchpad is shown at a time, and scratchpad windows are + always shown floating. 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. + +- `cycle-scratchpad`: + + Cycles through the windows of a scratchpad, one at a time. + + With no window 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. + If `name` is omitted, the default scratchpad is used. + + - Example: + + ```toml + [shortcuts] + alt-minus = { type = "cycle-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. @@ -942,6 +1012,126 @@ 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 +2359,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. @@ -2352,6 +2560,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. @@ -2452,6 +2672,32 @@ The table has the following fields: The value of this field should be a [Egui](#types-Egui). +- `scratchpads` (optional): + + An array of pre-configured scratchpads. + + Each entry launches a program when the graphics are first initialized and + immediately parks its window in the named scratchpad. The window is captured + via a unique tag attached to the spawned process, so other windows of the + same application are never affected. + + Use a `toggle-scratchpad` or `cycle-scratchpad` action to bring the windows + up; they are always shown floating. + + - Example: + + ```toml + [[scratchpads]] + name = "term" + exec = "foot" + + [[scratchpads]] + name = "notes" + exec = ["obsidian"] + ``` + + The value of this field should be an array of [Scratchpads](#types-Scratchpad). + ### `Connector` @@ -4385,6 +4631,40 @@ The table has the following fields: The value of this field should be a string. + +### `Scratchpad` + +A pre-configured scratchpad whose program is launched at startup and parked +in the scratchpad. + +- Example: + + ```toml + [[scratchpads]] + name = "term" + exec = "foot" + ``` + +Values of this type should be tables. + +The table has the following fields: + +- `name` (required): + + The name of the scratchpad that the spawned window is parked in. + + The value of this field should be a string. + +- `exec` (optional): + + The program to launch when the graphics are first initialized. + + If omitted, no program is launched and the scratchpad is only created on + demand by `send-to-scratchpad`. + + The value of this field should be a [Exec](#types-Exec). + + ### `SimpleActionName` @@ -4476,6 +4756,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. @@ -4488,6 +4780,18 @@ 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. + +- `cycle-scratchpad`: + + Cycles through the windows of 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 aa6789da..315c74b9 100644 --- a/toml-spec/spec/spec.yaml +++ b/toml-spec/spec/spec.yaml @@ -345,6 +345,64 @@ 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. + + A scratchpad can hold any number of windows. 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. + Only one window of a scratchpad is shown at a time, and scratchpad windows are + always shown floating. 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 + cycle-scratchpad: + description: | + Cycles through the windows of a scratchpad, one at a time. + + With no window 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. + If `name` is omitted, the default scratchpad is used. + + - Example: + + ```toml + [shortcuts] + alt-minus = { type = "cycle-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. @@ -1064,12 +1122,24 @@ 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 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: cycle-scratchpad + description: Cycles through the windows of the default scratchpad. - value: focus-parent description: Focus the parent of the currently focused window. - value: close @@ -2942,6 +3012,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 @@ -3112,10 +3199,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: @@ -3212,6 +3310,61 @@ Config: required: false description: | Sets the egui settings of the compositor. + scratchpads: + kind: array + items: + ref: Scratchpad + required: false + description: | + An array of pre-configured scratchpads. + + Each entry launches a program when the graphics are first initialized and + immediately parks its window in the named scratchpad. The window is captured + via a unique tag attached to the spawned process, so other windows of the + same application are never affected. + + Use a `toggle-scratchpad` or `cycle-scratchpad` action to bring the windows + up; they are always shown floating. + + - Example: + + ```toml + [[scratchpads]] + name = "term" + exec = "foot" + + [[scratchpads]] + name = "notes" + exec = ["obsidian"] + ``` + + +Scratchpad: + kind: table + description: | + A pre-configured scratchpad whose program is launched at startup and parked + in the scratchpad. + + - Example: + + ```toml + [[scratchpads]] + name = "term" + exec = "foot" + ``` + fields: + name: + kind: string + required: true + description: The name of the scratchpad that the spawned window is parked in. + exec: + ref: Exec + required: false + description: | + The program to launch when the graphics are first initialized. + + If omitted, no program is launched and the scratchpad is only created on + demand by `send-to-scratchpad`. Idle: @@ -3655,6 +3808,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: |