diff --git a/crates/bitty-runtime/src/runtime.rs b/crates/bitty-runtime/src/runtime.rs index c2e66ccda..7e2bd6746 100644 --- a/crates/bitty-runtime/src/runtime.rs +++ b/crates/bitty-runtime/src/runtime.rs @@ -164,7 +164,7 @@ pub mod workspaces; pub use self::animations::{ AnimationCurve, AnimationKind, AnimationPolicy, ClosingFrame, MAX_CONCURRENT_ANIMATIONS, - PanelAnimator, ReducedMotionMode, + PanelAnimator, PanelBounds, ReducedMotionMode, }; pub use self::fx::{ FX_PULSE_DURATION_MS, FX_PULSE_QUEUE_CAPACITY, FxPulseQueue, FxPulseTracker, QueuedFxPulse, diff --git a/crates/bitty-runtime/src/runtime/animations.rs b/crates/bitty-runtime/src/runtime/animations.rs index 166d19f24..8d9c0e1d8 100644 --- a/crates/bitty-runtime/src/runtime/animations.rs +++ b/crates/bitty-runtime/src/runtime/animations.rs @@ -12,18 +12,28 @@ //! //! Split open/close, single-pane zoom, and workspace switches (CTX-1078, //! issue #1755) ride this same contract: the layout commits its final state -//! immediately (geometry and terminal content are never interpolated) and -//! only the Core-owned decoration ring fades over the configured duration -//! with a smooth cubic easing (`ease_in_out` smoothstep is degree-3, the -//! `spring` name resolves to it with zero overshoot). They are detected from -//! the `View`-set delta and the active-workspace index on the next present -//! with a virtual-clock seam, so tests advance time deterministically. +//! immediately (terminal truth, PTY size, and hit-testing are final on the +//! first frame) and the Core-owned decoration ring fades over the configured +//! duration with a smooth cubic easing (`ease_in_out` smoothstep is degree-3, +//! the `spring` name resolves to it with zero overshoot). They are detected +//! from the `View`-set delta and the active-workspace index on the next +//! present with a virtual-clock seam, so tests advance time deterministically. +//! +//! CTX-1335 (issue #2113) adds paint-only panel-bounds interpolation on the +//! same seam: when a live `View`'s frame/content changes, the committed start +//! and end [`PanelBounds`] are captured and the painting path interpolates +//! between them over the existing duration/easing. The logical layout, +//! terminal grid, cursor, selection, scrollback, input hit-testing, and plugin +//! execution phase never observe the interpolated values. Instant, reduced +//! motion, safe mode, and capacity overflow all paint the committed end state +//! immediately. //! //! Move/resize/drag (CTX-0967, issue #1696) are Hyprland-style geometry gestures: //! the layout commits its final state immediately (terminal content is never -//! interpolated) and only the Core-owned decoration ring fades over the -//! configured duration. They are triggered directly from the gesture paths -//! (Alt-drag float move, border-drag ratio change, keyboard resize, panel +//! interpolated) and the Core-owned decoration ring fades over the configured +//! duration; CTX-1335 also interpolates their painted bounds when the tracked +//! start/end rects are available. They are triggered directly from the gesture +//! paths (Alt-drag float move, border-drag ratio change, keyboard resize, panel //! reparent) rather than detected from the `View`-set delta, because a move or //! resize keeps the same `View` set. //! @@ -245,6 +255,85 @@ impl AnimationPolicy { } } +/// Panel bounds that may be interpolated across a transition (CTX-1335). +/// +/// Both rectangles are presentation-only: the layout, PTY size, hit-testing, +/// and terminal truth commit once at the transition start. Only these painted +/// bounds move between the committed start and end snapshots. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PanelBounds { + /// Decoration-inclusive frame rectangle, physical px. + pub frame: bitty_render::geometry::RectPx, + /// Painted content rectangle inside the border, physical px. + pub content: bitty_render::geometry::RectPx, +} + +impl PanelBounds { + /// Interpolates `from` toward `to` at eased progress `p` (clamped). + /// + /// Rounds each edge independently with deterministic integer math, so + /// tests on a virtual clock observe the exact same rectangles on every + /// platform. Sizes saturate at zero rather than wrapping when a shrinking + /// rectangle crosses its end. + #[must_use] + pub fn lerp(from: Self, to: Self, p: f32) -> Self { + let p = if p.is_nan() { 0.0 } else { p.clamp(0.0, 1.0) }; + Self { + frame: lerp_rect(from.frame, to.frame, p), + content: lerp_rect(from.content, to.content, p), + } + } + + /// Translates both rectangles by `(dx, dy)` pixels. + #[must_use] + pub fn translated(self, dx: i32, dy: i32) -> Self { + Self { + frame: translate_rect(self.frame, dx, dy), + content: translate_rect(self.content, dx, dy), + } + } +} + +/// Deterministic integer interpolation of one axis value. +fn lerp_axis(from: i64, to: i64, p: f32) -> i64 { + let delta = (to as f64) - (from as f64); + let value = (from as f64) + delta * f64::from(p); + // `as i64` saturates, so hostile values cannot wrap. + value.round() as i64 +} + +/// Interpolates two rectangles without overflow. +fn lerp_rect( + from: bitty_render::geometry::RectPx, + to: bitty_render::geometry::RectPx, + p: f32, +) -> bitty_render::geometry::RectPx { + let x = lerp_axis(i64::from(from.x), i64::from(to.x), p); + let y = lerp_axis(i64::from(from.y), i64::from(to.y), p); + let width = lerp_axis(i64::from(from.width), i64::from(to.width), p).max(0); + let height = lerp_axis(i64::from(from.height), i64::from(to.height), p).max(0); + bitty_render::geometry::RectPx::new( + i32::try_from(x).unwrap_or(if x < 0 { i32::MIN } else { i32::MAX }), + i32::try_from(y).unwrap_or(if y < 0 { i32::MIN } else { i32::MAX }), + u32::try_from(width).unwrap_or(u32::MAX), + u32::try_from(height).unwrap_or(u32::MAX), + ) +} + +/// Translates one rectangle with saturating arithmetic. +fn translate_rect( + rect: bitty_render::geometry::RectPx, + dx: i32, + dy: i32, +) -> bitty_render::geometry::RectPx { + bitty_render::geometry::RectPx::new( + rect.x.saturating_add(dx), + rect.y.saturating_add(dy), + rect.width, + rect.height, + ) +} + /// One active animation on a surface. #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ActiveAnimation { @@ -253,6 +342,15 @@ struct ActiveAnimation { surface: Option, started: Instant, duration: Duration, + /// CTX-1335: painted bounds captured when the transition armed. `None` + /// for ring-only transitions (open/close/focus), so geometry stays at the + /// committed final state exactly as before. + bounds: Option<(PanelBounds, PanelBounds)>, + /// Whether the entry owns an independent ring fade armed through + /// [`PanelAnimator::trigger`]. A bounds-only entry (`false`) is removed + /// outright when its geometry is cancelled; a ring entry keeps fading + /// with its bounds cleared. + ring: bool, } impl ActiveAnimation { @@ -322,9 +420,22 @@ impl PanelAnimator { self.clear_surface(surface); return false; } - // Restart (bounded) any existing animation on the same surface. - self.active - .retain(|a| !(a.kind == kind && a.surface == surface)); + // Restart (bounded) any existing animation of the same kind on the + // surface. Close and the gesture kinds also drop an in-flight bounds + // tween on that surface (the surface is going away, or the gesture + // re-arms its own geometry on the next present). Focus/Open/Workspace + // leave bounds interpolation untouched: they share the same surface + // without conflicting with its painted geometry (CTX-1335). + let replaces_bounds = matches!( + kind, + AnimationKind::Close + | AnimationKind::Move + | AnimationKind::Resize + | AnimationKind::Drag + ); + self.active.retain(|a| { + !(a.surface == surface && (a.kind == kind || (replaces_bounds && a.bounds.is_some()))) + }); if self.active.len() >= MAX_CONCURRENT_ANIMATIONS { // Capacity exceeded: commit the end state immediately. Drop an // existing finished animation first so a stale entry cannot @@ -339,10 +450,231 @@ impl PanelAnimator { surface, started: now, duration, + bounds: None, + ring: true, }); true } + /// Arms a geometry transition for `surface` from `from` to `to` at `now`. + /// + /// Returns `true` when the painted bounds are now interpolating, or + /// `false` when the policy resolves to an instant cut (zero duration) or + /// the bounded capacity is exhausted; in both cases the caller paints the + /// committed end state immediately. The transition uses `kind`'s accepted + /// duration and easing, except when it rides an in-flight drag gesture + /// (which keeps that gesture's accepted clock), and replaces any other + /// in-flight geometry transition on the same surface so two bounds + /// animations never blend. + pub fn trigger_bounds( + &mut self, + kind: AnimationKind, + surface: Option, + from: PanelBounds, + to: PanelBounds, + now: Instant, + ) -> bool { + self.trigger_bounds_at(kind, surface, from, to, now, now) + } + + /// Arms a geometry transition whose clock started at `started`. + /// + /// Gesture paths arm their ring transition before the next present; the + /// present-time detector reuses that start so the bounds do not restart + /// half-way through the gesture's accepted duration. + pub fn trigger_bounds_at( + &mut self, + kind: AnimationKind, + surface: Option, + from: PanelBounds, + to: PanelBounds, + started: Instant, + now: Instant, + ) -> bool { + let duration = self.policy.duration(kind); + if duration.is_zero() { + self.cancel_bounds(surface); + return false; + } + // One geometry transition per surface: reuse the same-kind slot, or + // attach to an in-flight Alt+drag gesture entry (CTX-1335 F3). The + // drag ring already owns the surface's accepted duration, so its + // bounds ride that entry instead of consuming a second budget slot + // and multiplying the ring alpha. + if let Some(index) = self.bounds_host(kind, surface, now) { + let mut entry = self.active.remove(index); + self.active + .retain(|a| !(a.surface == surface && a.bounds.is_some())); + if entry.kind == kind { + entry.started = started; + entry.duration = duration; + } + entry.bounds = Some((from, to)); + self.active.push(entry); + return true; + } + // No reusable slot: replace any stale bounds entry deterministically + // (a different kind must never keep painting its old end rectangle). + self.active + .retain(|a| !(a.surface == surface && a.bounds.is_some())); + if self.active.len() >= MAX_CONCURRENT_ANIMATIONS { + self.active.retain(|a| !a.done(now)); + if self.active.len() >= MAX_CONCURRENT_ANIMATIONS { + return false; + } + } + self.active.push(ActiveAnimation { + kind, + surface, + started, + duration, + bounds: Some((from, to)), + ring: false, + }); + true + } + + /// Index of the entry that can host a bounds tween for `kind` on + /// `surface`: a same-kind slot, or an in-flight Alt+drag gesture entry + /// (CTX-1335 F3). + fn bounds_host( + &self, + kind: AnimationKind, + surface: Option, + now: Instant, + ) -> Option { + let mut drag_host = None; + for (index, animation) in self.active.iter().enumerate() { + if animation.surface != surface || animation.done(now) { + continue; + } + if animation.kind == kind { + return Some(index); + } + if animation.kind == AnimationKind::Drag + && matches!(kind, AnimationKind::Move | AnimationKind::Resize) + { + drag_host = Some(index); + } + } + drag_host + } + + /// Whether [`Self::trigger_bounds_at`] would reuse an existing entry + /// instead of consuming a new budget slot. + #[must_use] + pub(crate) fn has_bounds_host( + &self, + kind: AnimationKind, + surface: Option, + now: Instant, + ) -> bool { + self.bounds_host(kind, surface, now).is_some() + } + + /// Cancels the bounds tween on `surface` without touching an independent + /// ring fade. Returns whether anything was cancelled. + /// + /// A bounds-only entry is removed (nothing else would paint); a ring + /// entry keeps its accepted fade with `bounds: None`, so the next paint + /// uses the committed end rectangle immediately (CTX-1335 F1/F2). + pub(crate) fn cancel_bounds(&mut self, surface: Option) -> bool { + let mut changed = false; + let mut index = 0; + while index < self.active.len() { + let animation = self.active[index]; + if animation.surface == surface && animation.bounds.is_some() { + changed = true; + if animation.ring { + self.active[index].bounds = None; + index += 1; + } else { + self.active.remove(index); + } + } else { + index += 1; + } + } + changed + } + + /// Start instant of an active animation of `kind` on `surface`, if any. + #[must_use] + pub fn active_started( + &self, + kind: AnimationKind, + surface: Option, + now: Instant, + ) -> Option { + self.active + .iter() + .find(|a| a.kind == kind && a.surface == surface && !a.done(now)) + .map(|a| a.started) + } + + /// Earliest start instant among active gesture geometry transitions + /// (move/resize/drag, any surface), if any. + /// + /// A gesture that reparents one panel also changes its siblings' bounds; + /// those siblings join the gesture's clock instead of starting a fresh + /// transition on the next present. + #[must_use] + pub fn earliest_geometry_started(&self, now: Instant) -> Option { + self.active + .iter() + .filter(|a| { + matches!( + a.kind, + AnimationKind::Move | AnimationKind::Resize | AnimationKind::Drag + ) && !a.done(now) + }) + .map(|a| a.started) + .min() + } + + /// The eased, interpolated bounds of the active geometry transition on + /// `surface`, or `None` when it is not animating (caller paints the + /// committed end state). + #[must_use] + pub fn interpolated_bounds( + &self, + surface: Option, + now: Instant, + ) -> Option { + self.active + .iter() + .find(|a| a.surface == surface && a.bounds.is_some() && !a.done(now)) + .and_then(|a| { + let (from, to) = a.bounds?; + let progress = self.policy.curve(a.kind).eval(a.progress(now)); + Some(PanelBounds::lerp(from, to, progress)) + }) + } + + /// Whether at least one geometry transition is currently interpolating. + #[must_use] + pub fn bounds_active(&self, now: Instant) -> bool { + self.active + .iter() + .any(|a| a.bounds.is_some() && !a.done(now)) + } + + /// Drops one in-flight bounds transition to make room for a ring + /// transition at the shared capacity bound. + /// + /// Geometry interpolation is additive presentation (CTX-1335); the + /// accepted open/close/focus/move ring transitions keep priority when a + /// hostile trigger storm reaches [`MAX_CONCURRENT_ANIMATIONS`]. The + /// dropped surface paints its committed end state immediately. + pub fn drop_one_bounds(&mut self, now: Instant) -> bool { + self.active.retain(|a| !a.done(now)); + if let Some(index) = self.active.iter().position(|a| a.bounds.is_some()) { + self.active.remove(index); + return true; + } + false + } + /// Drops any animation for `surface` of any kind (e.g. the surface was /// removed for good). fn clear_surface(&mut self, surface: Option) { @@ -591,6 +923,257 @@ mod tests { assert!(anim.tick(end)); } + fn bounds(x: i32, y: i32, w: u32, h: u32) -> PanelBounds { + let frame = bitty_render::geometry::RectPx::new(x, y, w, h); + // Saturating so degenerate fixtures (`0x0`) stay valid under the CI + // profile's overflow checks instead of panicking in the helper. + let content = bitty_render::geometry::RectPx::new( + x.saturating_add(2), + y.saturating_add(2), + w.saturating_sub(4), + h.saturating_sub(4), + ); + PanelBounds { frame, content } + } + + #[test] + fn panel_bounds_interpolation_is_exact_at_endpoints_and_monotone() { + let from = bounds(10, 20, 100, 60); + let to = bounds(50, 40, 200, 120); + assert_eq!(PanelBounds::lerp(from, to, 0.0), from); + assert_eq!(PanelBounds::lerp(from, to, 1.0), to); + // Deterministic midpoint: every edge is the rounded average. + let mid = PanelBounds::lerp(from, to, 0.5); + assert_eq!(mid.frame.x, 30); + assert_eq!(mid.frame.y, 30); + assert_eq!(mid.frame.width, 150); + assert_eq!(mid.frame.height, 90); + assert_eq!(mid.content.x, 32); + assert_eq!(mid.content.width, 146); + // Out-of-range and non-finite progress clamp instead of extrapolating. + assert_eq!(PanelBounds::lerp(from, to, -1.0), from); + assert_eq!(PanelBounds::lerp(from, to, 2.0), to); + assert_eq!(PanelBounds::lerp(from, to, f32::NAN), from); + // A shrinking rectangle never reports a wrapped span. + let empty = PanelBounds::lerp(bounds(0, 0, 4, 4), bounds(0, 0, 0, 0), 0.5); + assert_eq!(empty.frame.width, 2); + assert_eq!(empty.frame.height, 2); + } + + #[test] + fn bounds_transition_interpolates_and_closes_the_wakeup_gate() { + let now = t0(); + let mut anim = PanelAnimator::new(AnimationPolicy::default()); + let view = ViewId::new(21); + let from = bounds(0, 0, 80, 40); + let to = bounds(80, 40, 160, 80); + assert!(anim.trigger_bounds(AnimationKind::Move, Some(view), from, to, now)); + assert!(anim.bounds_active(now)); + assert_eq!(anim.interpolated_bounds(Some(view), now), Some(from)); + let mid = now + Duration::from_millis(75); + let mid_bounds = anim + .interpolated_bounds(Some(view), mid) + .expect("mid bounds"); + assert_ne!(mid_bounds, from); + assert_ne!(mid_bounds, to); + // The same transition also drives the existing ring fade slot and the + // frame-on-demand deadline. + assert!( + anim.progress(AnimationKind::Move, Some(view), mid) + .is_some() + ); + assert!(anim.next_deadline(mid).is_some()); + let end = now + Duration::from_millis(150); + assert!(!anim.bounds_active(end)); + assert_eq!(anim.interpolated_bounds(Some(view), end), None); + assert_eq!(anim.next_deadline(end), None); + assert!(anim.tick(end)); + } + + #[test] + fn instant_bounds_trigger_cuts_immediately_and_never_accumulates() { + let now = t0(); + for policy in [ + AnimationPolicy { + enabled: false, + ..AnimationPolicy::default() + }, + AnimationPolicy { + reduced_motion: ReducedMotionMode::Always, + ..AnimationPolicy::default() + }, + AnimationPolicy { + safe_mode: true, + ..AnimationPolicy::default() + }, + AnimationPolicy { + duration_ms: [0; AnimationKind::COUNT], + ..AnimationPolicy::default() + }, + ] { + let mut anim = PanelAnimator::new(policy); + let view = ViewId::new(22); + assert!( + !anim.trigger_bounds( + AnimationKind::Move, + Some(view), + bounds(0, 0, 10, 10), + bounds(20, 20, 30, 30), + now, + ), + "{policy:?} must cut geometry instantly" + ); + assert!(!anim.bounds_active(now), "{policy:?}"); + assert_eq!(anim.interpolated_bounds(Some(view), now), None); + assert_eq!(anim.next_deadline(now), None); + } + } + + #[test] + fn bounds_transitions_share_the_concurrent_surface_cap() { + let now = t0(); + let mut anim = PanelAnimator::new(AnimationPolicy::default()); + for i in 0..MAX_CONCURRENT_ANIMATIONS { + let view = ViewId::new(i as u64 + 1); + assert!(anim.trigger_bounds( + AnimationKind::Move, + Some(view), + bounds(0, 0, 10, 10), + bounds(10, 10, 20, 20), + now, + )); + } + // One past the cap commits the end state immediately (no queue). + assert!(!anim.trigger_bounds( + AnimationKind::Move, + Some(ViewId::new(999)), + bounds(0, 0, 10, 10), + bounds(10, 10, 20, 20), + now, + )); + assert_eq!(anim.active_count(now), MAX_CONCURRENT_ANIMATIONS); + } + + #[test] + fn bounds_reuse_same_kind_and_drag_slots_without_double_counting() { + let now = t0(); + let mut anim = PanelAnimator::new(AnimationPolicy::default()); + let moved = ViewId::new(30); + let dragged = ViewId::new(31); + + // A same-kind ring slot is reused instead of consuming another + // budget entry (CTX-1335 F4). + assert!(anim.trigger(AnimationKind::Move, Some(moved), now)); + assert!(anim.trigger_bounds( + AnimationKind::Move, + Some(moved), + bounds(0, 0, 10, 10), + bounds(10, 10, 20, 20), + now, + )); + assert_eq!(anim.active_count(now), 1); + assert!(anim.has_bounds_host(AnimationKind::Move, Some(moved), now)); + + // An in-flight Alt+drag gesture hosts the per-view move bounds: one + // surface, one budget entry, one ring alpha factor (CTX-1335 F3). + assert!(anim.trigger(AnimationKind::Drag, Some(dragged), now)); + assert!(anim.has_bounds_host(AnimationKind::Move, Some(dragged), now)); + assert!(anim.trigger_bounds( + AnimationKind::Move, + Some(dragged), + bounds(0, 0, 10, 10), + bounds(10, 10, 20, 20), + now, + )); + assert_eq!(anim.active_count(now), 2); + assert!( + anim.progress(AnimationKind::Drag, Some(dragged), now) + .is_some() + ); + assert!( + anim.progress(AnimationKind::Move, Some(dragged), now) + .is_none(), + "bounds must ride the drag entry, not add a second Move entry" + ); + + // Cancelling geometry keeps the independent drag ring fade. + assert!(anim.cancel_bounds(Some(dragged))); + assert_eq!(anim.interpolated_bounds(Some(dragged), now), None); + assert!( + anim.progress(AnimationKind::Drag, Some(dragged), now) + .is_some() + ); + assert!(!anim.cancel_bounds(Some(dragged))); + } + + #[test] + fn different_kind_bounds_are_replaced_not_blended_and_cancel_cleans_up() { + let now = t0(); + let mut anim = PanelAnimator::new(AnimationPolicy::default()); + let view = ViewId::new(32); + assert!(anim.trigger_bounds( + AnimationKind::Move, + Some(view), + bounds(0, 0, 10, 10), + bounds(40, 20, 50, 40), + now, + )); + let mid = now + Duration::from_millis(60); + let moved = anim + .interpolated_bounds(Some(view), mid) + .expect("move tween"); + // A different kind replaces the in-flight tween deterministically. + assert!(anim.trigger_bounds( + AnimationKind::Resize, + Some(view), + bounds(0, 0, 10, 10), + bounds(60, 30, 70, 50), + now, + )); + assert_eq!(anim.active_count(now), 1, "one bounds entry per surface"); + assert!( + anim.progress(AnimationKind::Move, Some(view), mid) + .is_none() + ); + assert!( + anim.progress(AnimationKind::Resize, Some(view), mid) + .is_some() + ); + assert_ne!(anim.interpolated_bounds(Some(view), mid), Some(moved)); + // A bounds-only entry disappears on cancel: nothing else paints. + assert!(anim.cancel_bounds(Some(view))); + assert_eq!(anim.active_count(now), 0); + assert!(!anim.bounds_active(now)); + assert!(!anim.cancel_bounds(Some(view))); + } + + #[test] + fn overflow_drops_bounds_before_ring_transitions() { + let now = t0(); + let mut anim = PanelAnimator::new(AnimationPolicy::default()); + let view = ViewId::new(33); + let focused = ViewId::new(34); + assert!(anim.trigger_bounds( + AnimationKind::Move, + Some(view), + bounds(0, 0, 10, 10), + bounds(10, 10, 20, 20), + now, + )); + assert!(anim.trigger(AnimationKind::Focus, Some(focused), now)); + assert!(anim.drop_one_bounds(now)); + assert_eq!(anim.active_count(now), 1); + assert!(anim.interpolated_bounds(Some(view), now).is_none()); + assert!( + anim.progress(AnimationKind::Focus, Some(focused), now) + .is_some() + ); + assert!( + !anim.drop_one_bounds(now), + "nothing left to drop: no bounds entry" + ); + } + #[test] fn instant_policy_never_arms_and_reports_inactive() { let now = t0(); @@ -761,8 +1344,9 @@ mod tests { // config layer resolves `spring` to `ease_in_out`. The resolved curve // never overshoots: every sample stays in `0..=1`, so the overshoot // bound is zero and a settled value always lands exactly on target. - // Geometry and content are never interpolated; only the chrome ring - // fades, so there is no spring displacement to bound beyond this. + // CTX-1335 interpolates painted bounds with this same resolved curve, + // so a spring-named transition still has zero overshoot: every edge + // stays between its committed start and end rectangles. let resolved = AnimationCurve::EaseInOut; let mut max = 0.0_f32; let mut min = 1.0_f32; diff --git a/crates/bitty-runtime/src/runtime/layout_focus.rs b/crates/bitty-runtime/src/runtime/layout_focus.rs index a6e348f41..2f63c945a 100644 --- a/crates/bitty-runtime/src/runtime/layout_focus.rs +++ b/crates/bitty-runtime/src/runtime/layout_focus.rs @@ -490,9 +490,20 @@ impl Runtime { surface: Option, now: std::time::Instant, ) -> bool { + // CTX-1335 F7: a zero-duration kind never admits an entry, so evict a + // bounds tween only after the duration gate. Otherwise a refused ring + // trigger would still discard another surface's geometry for nothing. + if self.animator.policy().duration(kind).is_zero() { + return self.animator.trigger(kind, surface, now); + } if self.animator.active_count(now) + self.fx.active_count(now) >= MAX_CONCURRENT_ANIMATIONS { - return false; + // CTX-1335: bounds interpolation yields to the accepted ring + // transitions at the shared cap. Dropping one paints that + // surface's committed end state immediately; no queue grows. + if !self.animator.drop_one_bounds(now) { + return false; + } } let armed = self.animator.trigger(kind, surface, now); if armed { @@ -502,6 +513,40 @@ impl Runtime { armed } + /// CTX-1335: arms a paint-only bounds transition with the same shared + /// capacity rule as [`Self::trigger_animation`]. Refusal commits the end + /// state immediately; nothing is queued. + fn trigger_bounds_animation( + &mut self, + kind: AnimationKind, + surface: ViewId, + from: PanelBounds, + to: PanelBounds, + started: std::time::Instant, + now: std::time::Instant, + ) -> bool { + // A same-kind slot or an in-flight drag entry is reused by + // `trigger_bounds_at`, so it does not consume another budget entry. + let reuses_slot = self.animator.has_bounds_host(kind, Some(surface), now); + if !reuses_slot + && self.animator.active_count(now) + self.fx.active_count(now) + >= MAX_CONCURRENT_ANIMATIONS + { + // CTX-1335 F2: a refused tween must not leave the surface's old + // geometry painting. Drop it so this frame is the committed end + // bounds. + self.animator.cancel_bounds(Some(surface)); + return false; + } + let armed = self + .animator + .trigger_bounds_at(kind, Some(surface), from, to, started, now); + if armed { + self.pending_full_redraw = true; + } + armed + } + /// Arms a plugin-requested ring pulse for `surface` at `now` (CTX-1139, /// DEC-0110). /// @@ -564,6 +609,61 @@ impl Runtime { self.animator.progress(kind, surface, now) } + /// CTX-1335: painted frames at `now`, with active panel-bounds + /// interpolation applied. + /// + /// Presentation-only. `crate::PresentFrame` consumers that route input, + /// hit-test, resize a PTY, or commit layout must keep using + /// [`Self::present_frames`]: those values are final on the first frame of + /// a transition. The returned frames differ only in their painted + /// `frame`/`content` rectangles (and the grid dims derived from them), and + /// they are exactly the committed logical frames whenever no geometry + /// transition is active (instant, reduced-motion, safe, or complete). + #[must_use] + pub fn paint_frames_at(&self, now: std::time::Instant) -> Vec { + let frames = self.present_frames(); + self.paint_frames_from(&frames, now).unwrap_or(frames) + } + + /// Applies active geometry interpolation to already-computed logical + /// frames (CTX-1335). Pure: reads the animator only. `None` when no + /// geometry transition is active, so the present path reuses the logical + /// allocations without copying them. + #[must_use] + pub(super) fn paint_frames_from( + &self, + frames: &[PresentFrame], + now: std::time::Instant, + ) -> Option> { + if !self.animator.bounds_active(now) { + return None; + } + let live = self.live_cell_metrics(); + let max_dim = u32::try_from(bitty_term_state::MAX_GRID_DIM).unwrap_or(u32::MAX); + Some( + frames + .iter() + .map(|frame| { + let Some(bounds) = self.animator.interpolated_bounds(Some(frame.view), now) + else { + return *frame; + }; + let mut painted = *frame; + painted.frame = bounds.frame; + painted.content = bounds.content; + // The renderer paints the committed grid into the animated + // viewport: dims follow the painted content, never the other + // way around, so terminal truth and PTY size stay final. + painted.cols = + (bounds.content.width / live.width.max(1)).clamp(1, max_dim) as u16; + painted.rows = + (bounds.content.height / live.height.max(1)).clamp(1, max_dim) as u16; + painted + }) + .collect(), + ) + } + /// Detects RFC-0002 transitions between the last presented frame and the /// current one and arms the matching animations (CTX-0341). /// @@ -592,10 +692,15 @@ impl Runtime { return; } let policy = self.config.animations; + // CTX-1335 F8: geometry interpolation rides a *causal* accepted + // transition (split/zoom open or close, workspace switch, or an + // in-flight gesture), never an unrelated focus fade. + let mut accepted_transition = false; if policy.animates(AnimationKind::Workspace) && active_workspace != self.last_presented_workspace { + accepted_transition = true; self.trigger_animation(AnimationKind::Workspace, None, now); } @@ -610,6 +715,7 @@ impl Runtime { if policy.animates(AnimationKind::Open) { for frame in allocations { if !old_ids.contains(&frame.view) { + accepted_transition = true; self.trigger_animation(AnimationKind::Open, Some(frame.view), now); } } @@ -664,9 +770,141 @@ impl Runtime { radius: old.radius, color, }); + accepted_transition = true; self.trigger_animation(AnimationKind::Close, Some(old.view), now); } } + // CTX-1335 (issue #2113): a live View whose painted bounds changed + // between presents interpolates from the committed previous bounds to + // the committed current bounds. The logical layout already holds the + // end state, so only the paint path reads these captured rectangles. + // A repeat change re-arms from the currently painted bounds, which + // avoids a jump when a gesture updates geometry mid-transition. + let old_by_view: std::collections::HashMap = self + .last_presented_allocations + .iter() + .map(|frame| (frame.view, *frame)) + .collect(); + let gesture_active = self.animator.earliest_geometry_started(now).is_some(); + for frame in allocations { + let Some(old) = old_by_view.get(&frame.view) else { + // A brand-new View uses the open transition, not a bounds + // tween; there is no committed start rectangle to move from. + // Drop a stale entry defensively when a retired id is reused. + self.animator.cancel_bounds(Some(frame.view)); + continue; + }; + if old.frame == frame.frame && old.content == frame.content { + continue; + } + // Geometry interpolation rides an accepted panel transition: + // open/close (split/zoom), workspace, or a gesture kind already + // armed before this present. A bare appearance-driven reflow + // (decoration, DPI, direct layout edit) keeps its established + // instant behavior. A tier change (float detach/restore) also + // fails closed to the committed end bounds: the float's opaque + // cover must own its final frame on the first present, never an + // interpolated one that could bleed the base beneath it. + if old.tier != frame.tier { + // CTX-1335 F1: only arming no new tween is not enough — the + // surface's old tween would keep painting an obsolete rect + // under the float cover/occluders. Cut it before painting. + self.animator.cancel_bounds(Some(frame.view)); + continue; + } + if !accepted_transition + && !gesture_active + && self + .animator + .interpolated_bounds(Some(frame.view), now) + .is_none() + { + continue; + } + let size_changed = + old.frame.width != frame.frame.width || old.frame.height != frame.frame.height; + let kind = if size_changed { + AnimationKind::Resize + } else { + AnimationKind::Move + }; + if !policy.animates(kind) { + // CTX-1335 F2: a non-animating kind must not leave a + // different-kind tween painting its old end rectangle. + self.animator.cancel_bounds(Some(frame.view)); + continue; + } + // Re-targeting an in-flight tween starts from the currently + // painted bounds at this present, so a later gesture update never + // jumps ahead. Otherwise reuse the gesture's accepted start + // (Alt-drag/resize/reparent arm the ring before the next present) + // or start at this present. + let in_flight = self.animator.interpolated_bounds(Some(frame.view), now); + let from = in_flight.unwrap_or(PanelBounds { + frame: old.frame, + content: old.content, + }); + let to = PanelBounds { + frame: frame.frame, + content: frame.content, + }; + let started = if in_flight.is_some() { + now + } else { + self.animator + .active_started(kind, Some(frame.view), now) + .or_else(|| self.animator.earliest_geometry_started(now)) + .unwrap_or(now) + }; + self.trigger_bounds_animation(kind, frame.view, from, to, started, now); + } + + // CTX-1335: a workspace switch slides the newly visible workspace in + // from the direction of travel. The outgoing workspace is stashed (no + // retained ghost, #2134); only the committed new bounds translate, so + // the animation never depends on stale content. The accepted + // cross-fade (`AnimationKind::Workspace` on the global surface) still + // runs alongside this geometry. + if active_workspace != self.last_presented_workspace + && policy.animates(AnimationKind::Workspace) + { + let live = self.live_cell_metrics(); + let distance = + i32::try_from(u32::from(self.container.width).saturating_mul(live.width)) + .unwrap_or(i32::MAX); + if distance > 0 { + let direction: i32 = if active_workspace > self.last_presented_workspace { + 1 + } else { + -1 + }; + let dx = direction.saturating_mul(distance); + for frame in allocations { + // CTX-1335 F5: pins are window-global chrome, not part of + // the incoming workspace; they stay anchored while the + // workspace slides beneath them. + if self.pinned.contains(frame.view) { + continue; + } + let to = PanelBounds { + frame: frame.frame, + content: frame.content, + }; + let from = to.translated(dx, 0); + if !self.trigger_bounds_animation( + AnimationKind::Workspace, + frame.view, + from, + to, + now, + now, + ) { + break; + } + } + } + } + if focused != self.last_presented_focus { if let Some(fid) = focused { if policy.animates(AnimationKind::Focus) { diff --git a/crates/bitty-runtime/src/runtime/present.rs b/crates/bitty-runtime/src/runtime/present.rs index 98b8aa93e..ffcaa1ae6 100644 --- a/crates/bitty-runtime/src/runtime/present.rs +++ b/crates/bitty-runtime/src/runtime/present.rs @@ -2128,6 +2128,10 @@ impl Runtime { // change: force the full per-leaf path for this frame. pending_full = true; } + // CTX-1335: apply paint-only bounds interpolation. The logical + // `allocations` below stay final (hit-testing, PTY size, and terminal + // truth never observe the animated rectangles). + let paint_allocations = self.paint_frames_from(&allocations, now); // Frame-on-demand: no origin advanced and no forced redraw -> idle. // `last == u64::MAX` marks the first frame (always present). @@ -2236,6 +2240,7 @@ impl Runtime { Some(TickBasis { snapshot, allocations, + paint_allocations, focused, full_frame, current_gen, @@ -2256,7 +2261,10 @@ impl Runtime { /// [`FrameLayers`] with the overlay and image layers still empty. fn build_leaf_primitives(&mut self, basis: &TickBasis, now: std::time::Instant) -> FrameLayers { let snapshot = &basis.snapshot; - let allocations = &basis.allocations; + // CTX-1335: leaf painting reads the interpolated bounds. Every other + // consumer of `basis.allocations` (hit-testing, PTY, damage identity) + // keeps the committed logical geometry. + let allocations = basis.painted_allocations(); let view_map = &basis.view_map; let pad_px = basis.pad_px; let current_gen = basis.current_gen; @@ -3058,21 +3066,20 @@ impl Runtime { layers: &mut FrameLayers, ) { self.paint_closing_rings(basis.pad_px, now, layers); - self.paint_selection_highlight(&basis.allocations, &basis.view_map, basis.pad_px, layers); - self.paint_plaintext_hover(&basis.allocations, basis.pad_px, layers); + // CTX-1335: overlays anchored to a leaf follow its interpolated paint + // bounds so content, cursor, and selection never tear apart during a + // geometry transition. Their logical state and hit-testing remain + // final. + let paint = basis.painted_allocations(); + self.paint_selection_highlight(paint, &basis.view_map, basis.pad_px, layers); + self.paint_plaintext_hover(paint, basis.pad_px, layers); self.paint_ime_preedit(layers); - self.paint_pending_banners( - &basis.allocations, - &basis.view_map, - basis.pad_px, - now, - layers, - ); - self.paint_hyperlink_hover(&basis.allocations, basis.pad_px, layers); - self.paint_help_overlay(&basis.allocations, &basis.view_map, basis.pad_px, layers); - self.paint_bell_and_notification(&basis.allocations, basis.pad_px, now, layers); + self.paint_pending_banners(paint, &basis.view_map, basis.pad_px, now, layers); + self.paint_hyperlink_hover(paint, basis.pad_px, layers); + self.paint_help_overlay(paint, &basis.view_map, basis.pad_px, layers); + self.paint_bell_and_notification(paint, basis.pad_px, now, layers); self.paint_scrollbar_overlay(layers); - self.paint_hint_overlay(&basis.allocations, basis.pad_px, layers); + self.paint_hint_overlay(paint, basis.pad_px, layers); } /// CTX-0577 (M1-16): the bounded visual bell flash and the single @@ -4190,7 +4197,8 @@ impl Runtime { fn paint_kitty_images(&mut self, basis: &TickBasis, layers: &mut FrameLayers) { let snapshot = &basis.snapshot; - let allocations = &basis.allocations; + // CTX-1335: image blits follow the interpolated leaf bounds. + let allocations = basis.painted_allocations(); let view_map = &basis.view_map; let live = self.live_cell_metrics(); @@ -4662,8 +4670,13 @@ impl Runtime { struct TickBasis { /// Grid snapshot backing the primary origin and the overlays. snapshot: Snapshot, - /// Decorated leaf allocations for this frame. + /// Decorated leaf allocations for this frame (logical, final geometry). allocations: Vec, + /// Paint-only allocations with active bounds interpolation applied + /// (CTX-1335). `None` whenever no geometry transition is active, in which + /// case painting reuses `allocations`; never consumed by hit-testing or + /// terminal-truth paths. + paint_allocations: Option>, /// Focused leaf at collection time. focused: Option, /// Whether any full-invalidation source armed a full frame. @@ -4684,6 +4697,16 @@ struct TickBasis { band_versions: Vec<(String, bitty_lua::ui::UiSlot, u32)>, } +impl TickBasis { + /// Leaf allocations used for painting: interpolated when a geometry + /// transition is active, otherwise the committed logical frames. + fn painted_allocations(&self) -> &[layout_focus::PresentFrame] { + self.paint_allocations + .as_deref() + .unwrap_or(&self.allocations) + } +} + /// Combined draw layers assembled by the present phases (CTX-0474). /// /// The leaf pass fills the grid layers and the cursor; the overlay phases diff --git a/crates/bitty-runtime/tests/panel_animations.rs b/crates/bitty-runtime/tests/panel_animations.rs index 3a54161d5..26d119b34 100644 --- a/crates/bitty-runtime/tests/panel_animations.rs +++ b/crates/bitty-runtime/tests/panel_animations.rs @@ -1,21 +1,29 @@ #![forbid(unsafe_code)] //! Renderer-side panel animations (RFC-0002, CTX-0341; CTX-0967 move/resize/drag; -//! CTX-1078 splits/zoom/workspaces). +//! CTX-1078 splits/zoom/workspaces; CTX-1335 paint geometry). //! //! Pins the runtime wiring of the accepted contract: //! - a new split `View` arms a bounded open transition and presents extra //! frames while active; when it completes the present path idles again //! (frame-on-demand, PB-7: zero wakeups after completion); //! - single-pane zoom engage/disengage arms bounded close/open transitions -//! through the same chrome-ring fade (geometry and content commit -//! immediately, never interpolated); +//! through the same chrome-ring fade; the logical layout, PTY size, +//! content, and hit-test frames commit immediately (CTX-1335 adds +//! paint-only bounds interpolation); //! - workspace switches (new/switch/prev/next/last) arm the bounded -//! workspace transition with the same virtual-clock seam; +//! workspace cross-fade and slide the committed incoming bounds in with +//! the same virtual-clock seam; //! - panel move (same-workspace reposition, cross-workspace reparent), //! panel resize (border-drag divider, keyboard step), and panel drag //! (Alt+drag float move) arm their own bounded transitions from the //! gesture paths; the layout commits immediately and only Core-owned -//! chrome fades (terminal content is never interpolated); +//! chrome fades plus the painted bounds interpolate (terminal content is +//! never interpolated); +//! - CTX-1335 geometry interpolation is paint-only: start/mid/end painted +//! bounds run on the virtual clock while `present_frames()` stays final; +//! instant/reduced/safe policies paint the end bounds on the first frame, +//! and float detach/restore (tier changes) fail closed to instant bounds +//! so the opaque float cover never bleeds the base beneath it; //! - the `now`-parameterized `tick_at` seam lets tests advance virtual time //! deterministically, so no wall-clock sleeps are needed; //! - `enabled = false`, `reduced_motion = "always"`, `safe_mode`, and `0` ms @@ -30,8 +38,8 @@ use std::time::{Duration, Instant}; use bitty_platform::{CursorPosition, MouseButton, NamedKey, PressState}; use bitty_runtime::{ - AnimationCurve, AnimationKind, AnimationPolicy, LayoutNode, ReducedMotionMode, Runtime, - RuntimeConfig, SplitAxis, UiRect, View, ViewId, + AnimationCurve, AnimationKind, AnimationPolicy, LayoutNode, MAX_CONCURRENT_ANIMATIONS, + OverlayTier, ReducedMotionMode, Runtime, RuntimeConfig, SplitAxis, UiRect, View, ViewId, }; fn runtime_with(policy: AnimationPolicy) -> Runtime { @@ -551,6 +559,592 @@ fn terminal_grid_is_never_interpolated() { assert_eq!(row, "GRID-TRUTH", "grid content is never animated"); } +// ── CTX-1335: paint-only panel-bounds interpolation ────────────────────── + +fn frame_for(frames: &[bitty_runtime::PresentFrame], view: ViewId) -> bitty_runtime::PresentFrame { + *frames + .iter() + .find(|frame| frame.view == view) + .expect("view must be present") +} + +#[test] +fn split_resizes_interpolate_pane_bounds_on_the_virtual_clock() { + // A split commits the final layout, PTY size, and hit-test geometry on + // the first frame; only the painted bounds move from the committed start + // to the committed end over the accepted Resize duration. + let mut rt = runtime_with(AnimationPolicy::default()); + let start = Instant::now(); + rt.set_container(UiRect::new(0, 0, 80, 24)); + let _ = rt.tick_at(start).expect("baseline presents"); + assert!(rt.tick_at(start).is_none()); + let before = frame_for(&rt.present_frames(), ViewId::new(1)); + + rt.set_layout(two_pane_split()); + let _ = rt.tick_at(start).expect("split presents"); + let logical = rt.present_frames(); + let target = frame_for(&logical, ViewId::new(1)); + assert_ne!(before.frame, target.frame, "split resizes the kept pane"); + + // Frame zero paints the committed start bounds; hit-testing already sees + // the committed end bounds. + let painted_start = frame_for(&rt.paint_frames_at(start), ViewId::new(1)); + assert_eq!(painted_start.frame, before.frame); + assert_eq!(painted_start.content, before.content); + assert_eq!( + rt.present_frames(), + logical, + "logical/hit-test frames never animate" + ); + + let mid = start + Duration::from_millis(60); + rt.tick_at(mid).expect("mid frame presents"); + let painted_mid = frame_for(&rt.paint_frames_at(mid), ViewId::new(1)); + assert_ne!(painted_mid.frame, before.frame, "mid must have moved"); + assert_ne!(painted_mid.frame, target.frame, "mid must not be final"); + // The kept pane shrinks: its width interpolates monotonically between the + // committed start and end spans. + let lo = before.frame.width.min(target.frame.width); + let hi = before.frame.width.max(target.frame.width); + assert!( + (lo..=hi).contains(&painted_mid.frame.width), + "mid width {} must lie between {lo} and {hi}", + painted_mid.frame.width + ); + assert_eq!( + rt.present_frames(), + logical, + "hit-testing stays final during the animation" + ); + + // Open is 150 ms, so the whole split settles by then; the final frame is + // the exact committed end state and the present path idles. + let end = start + Duration::from_millis(150); + rt.tick_at(end).expect("final frame commits"); + let painted_end = frame_for(&rt.paint_frames_at(end), ViewId::new(1)); + assert_eq!(painted_end.frame, target.frame); + assert_eq!(painted_end.content, target.content); + assert!(!rt.animations_active()); + assert!(rt.tick_at(end).is_none()); + assert_eq!(rt.animation_deadline(), None); +} + +#[test] +fn instant_reduced_and_safe_paths_skip_bounds_interpolation() { + // First-class instant paths: disabled, reduced motion, safe mode, and + // zero durations all paint the committed end bounds on the first frame + // and produce no extra animation wakeups. + for policy in [ + AnimationPolicy { + enabled: false, + ..AnimationPolicy::default() + }, + AnimationPolicy { + reduced_motion: ReducedMotionMode::Always, + ..AnimationPolicy::default() + }, + AnimationPolicy { + safe_mode: true, + ..AnimationPolicy::default() + }, + AnimationPolicy { + duration_ms: [0; AnimationKind::COUNT], + ..AnimationPolicy::default() + }, + ] { + let mut rt = runtime_with(policy); + let start = Instant::now(); + rt.set_container(UiRect::new(0, 0, 80, 24)); + let _ = rt.tick_at(start).expect("baseline presents"); + rt.set_layout(two_pane_split()); + let _ = rt.tick_at(start).expect("split presents"); + let logical = rt.present_frames(); + let paint = rt.paint_frames_at(start); + assert_eq!( + paint, logical, + "instant path must use final bounds: {policy:?}" + ); + assert!(!rt.animations_active(), "{policy:?}"); + assert!(rt.tick_at(start).is_none(), "{policy:?}"); + assert_eq!(rt.animation_deadline(), None, "{policy:?}"); + } +} + +#[test] +fn workspace_switch_slides_committed_bounds_without_touching_hits() { + // A workspace switch stashes the outgoing workspace and slides the + // committed incoming bounds in from the direction of travel alongside + // the accepted cross-fade. + let mut rt = runtime_with(AnimationPolicy::default()); + let start = Instant::now(); + rt.set_container(UiRect::new(0, 0, 80, 24)); + let _ = rt.tick_at(start).expect("baseline presents"); + assert!(rt.tick_at(start).is_none()); + + rt.workspace_new().expect("new workspace"); + let _ = rt.tick_at(start).expect("switch presents"); + let logical = rt.present_frames(); + let target = logical + .first() + .expect("incoming workspace frame") + .to_owned(); + + let painted_start = frame_for(&rt.paint_frames_at(start), target.view); + assert!( + painted_start.frame.x > target.frame.x, + "next workspace slides in from the right: {} must start right of {}", + painted_start.frame.x, + target.frame.x + ); + assert_eq!(painted_start.frame.width, target.frame.width); + assert_eq!( + rt.present_frames(), + logical, + "workspace switch hit-test geometry is final" + ); + + let mid = start + Duration::from_millis(100); + rt.tick_at(mid).expect("mid frame presents"); + let painted_mid = frame_for(&rt.paint_frames_at(mid), target.view); + assert!( + painted_mid.frame.x < painted_start.frame.x && painted_mid.frame.x > target.frame.x, + "mid slide {} must lie between {} and {}", + painted_mid.frame.x, + painted_start.frame.x, + target.frame.x + ); + assert_eq!(rt.present_frames(), logical); + + let end = start + Duration::from_millis(200); + rt.tick_at(end).expect("final frame commits"); + let painted_end = frame_for(&rt.paint_frames_at(end), target.view); + assert_eq!(painted_end.frame, target.frame); + assert_eq!(painted_end.content, target.content); + assert!(!rt.animations_active()); + assert!(rt.tick_at(end).is_none()); +} + +#[test] +fn workspace_slide_is_instant_under_reduced_motion() { + for policy in [ + AnimationPolicy { + reduced_motion: ReducedMotionMode::Always, + ..AnimationPolicy::default() + }, + AnimationPolicy { + safe_mode: true, + ..AnimationPolicy::default() + }, + AnimationPolicy { + enabled: false, + ..AnimationPolicy::default() + }, + ] { + let mut rt = runtime_with(policy); + let start = Instant::now(); + rt.set_container(UiRect::new(0, 0, 80, 24)); + let _ = rt.tick_at(start).expect("baseline presents"); + rt.workspace_new().expect("new workspace"); + let _ = rt.tick_at(start).expect("switch presents"); + assert_eq!( + rt.paint_frames_at(start), + rt.present_frames(), + "reduced/instant workspace must paint final bounds: {policy:?}" + ); + assert!(!rt.animations_active(), "{policy:?}"); + assert!(rt.tick_at(start).is_none(), "{policy:?}"); + } +} + +/// Physical cursor position at fractional (`fx`, `fy`) offsets inside +/// `view`'s committed present frame, derived from public geometry only. +fn point_in_frame(rt: &Runtime, view: ViewId, fx: f64, fy: f64) -> CursorPosition { + let frame = frame_for(&rt.present_frames(), view); + let pad = f64::from(rt.window_padding_physical()); + CursorPosition { + x: pad + f64::from(frame.frame.x) + f64::from(frame.frame.width) * fx, + y: pad + f64::from(frame.frame.y) + f64::from(frame.frame.height) * fy, + } +} + +#[test] +fn float_tier_changes_cut_a_mid_tween_bounds_tween() { + // CTX-1335 F1: a detach or restore during an in-flight geometry tween + // must cancel it, not merely decline to re-arm. The painted frame is the + // committed rect on the first present after the tier flips, so the float + // cover/occluders can never own an obsolete rectangle. + let mut rt = runtime_with(AnimationPolicy::default()); + let start = Instant::now(); + rt.set_container(UiRect::new(0, 0, 80, 24)); + let _ = rt.tick_at(start).expect("baseline presents"); + rt.set_layout(two_pane_split()); + let _ = rt.tick_at(start).expect("split presents"); + let mid = start + Duration::from_millis(30); + rt.tick_at(mid).expect("mid tween frame presents"); + assert!( + rt.paint_frames_at(mid) != rt.present_frames(), + "a split resize tween is actually in flight" + ); + + rt.toggle_floating(Some(ViewId::new(1))) + .expect("detach view 1"); + let after = start + Duration::from_millis(40); + let _ = rt.tick_at(after); + let target = frame_for(&rt.present_frames(), ViewId::new(1)); + assert_eq!(target.tier, Some(OverlayTier::Float)); + let painted = frame_for(&rt.paint_frames_at(after), ViewId::new(1)); + assert_eq!(painted.tier, target.tier, "float tier committed"); + assert_eq!( + painted.frame, target.frame, + "detach must cut the stale tween and paint the committed float frame" + ); + assert_eq!(painted.content, target.content); + assert_eq!(painted.cols, target.cols); + assert_eq!(painted.rows, target.rows); + + // Restore direction: move the detached float (arming a live drag/move + // bounds tween), then restore mid-tween. + let settled = after + Duration::from_millis(200); + rt.tick_at(settled); + let grab = point_in_frame(&rt, ViewId::new(1), 0.5, 0.5); + rt.handle_cursor_moved(grab); + rt.handle_key_event(named_key(NamedKey::Alt, PressState::Pressed)); + rt.handle_mouse_input(press(MouseButton::Left)); + assert!( + rt.alt_drag_active(), + "Alt+press on the detached float grabs" + ); + let t = Instant::now(); + let moved = CursorPosition { + x: grab.x + 18.0, + y: grab.y + 38.0, + }; + assert!(rt.update_alt_drag_at(moved, t), "drag motion must apply"); + let _ = rt.tick_at(t).expect("drag present attaches bounds"); + let drag_mid = t + Duration::from_millis(30); + rt.tick_at(drag_mid).expect("drag mid frame presents"); + assert!( + rt.paint_frames_at(drag_mid) != rt.present_frames(), + "float move tween is in flight before restore" + ); + + rt.toggle_floating(Some(ViewId::new(1))) + .expect("restore view 1"); + let restored_at = drag_mid + Duration::from_millis(5); + let _ = rt.tick_at(restored_at); + let target = frame_for(&rt.present_frames(), ViewId::new(1)); + assert_eq!( + target.tier, None, + "restore returns the leaf to the tiled base" + ); + let painted = frame_for(&rt.paint_frames_at(restored_at), ViewId::new(1)); + assert_eq!(painted.tier, target.tier, "tiled tier committed"); + assert_eq!( + painted.frame, target.frame, + "restore must cut the stale float tween and paint the committed tiled frame" + ); + assert_eq!(painted.content, target.content); + assert_eq!(painted.cols, target.cols); + assert_eq!(painted.rows, target.rows); + rt.handle_mouse_input(release(MouseButton::Left)); + rt.handle_key_event(named_key(NamedKey::Alt, PressState::Released)); +} + +#[test] +fn non_animating_kind_cancels_a_different_kind_bounds_tween() { + // CTX-1335 F2: with move animated and resize instant, a move tween in + // flight followed by a size-only delta must cut the stale move tween and + // paint the committed end bounds. + let mut rt = runtime_with(AnimationPolicy { + duration_ms: [150, 120, 100, 200, 150, 0, 150], + ..AnimationPolicy::default() + }); + let start = Instant::now(); + rt.set_container(UiRect::new(0, 0, 80, 24)); + let _ = rt.tick_at(start).expect("baseline presents"); + rt.set_layout(two_pane_split()); + let _ = rt.tick_at(start).expect("split presents"); + let settled = start + Duration::from_millis(200); + rt.tick_at(settled); + assert!(rt.tick_at(settled).is_none(), "settled baseline"); + + let t = Instant::now(); + rt.workspace_move_focused_to_position_at(1, t) + .expect("reposition must succeed"); + let _ = rt.tick_at(t).expect("move present attaches bounds"); + let mid = t + Duration::from_millis(30); + rt.tick_at(mid).expect("move mid frame presents"); + assert_ne!( + frame_for(&rt.paint_frames_at(mid), ViewId::new(1)).frame, + frame_for(&rt.present_frames(), ViewId::new(1)).frame, + "move tween is painting" + ); + + rt.set_layout(LayoutNode::split( + SplitAxis::Horizontal, + 0.6, + LayoutNode::leaf(View::new(ViewId::new(1), 40, 24)), + LayoutNode::leaf(View::new(ViewId::new(2), 40, 24)), + )); + let after = t + Duration::from_millis(40); + let _ = rt.tick_at(after); + let target = frame_for(&rt.present_frames(), ViewId::new(1)); + let painted = frame_for(&rt.paint_frames_at(after), ViewId::new(1)); + assert_eq!( + painted.frame, target.frame, + "non-animating resize must cut the stale move tween" + ); + assert_eq!(painted.content, target.content); +} + +#[test] +fn capacity_refused_bounds_paint_the_committed_end() { + // CTX-1335 F2/F4: with the shared budget full, a surface with no + // reusable slot refuses its bounds tween and paints the committed end + // bounds on that frame. + let mut rt = runtime_with(AnimationPolicy::default()); + let start = Instant::now(); + rt.set_container(UiRect::new(0, 0, 80, 24)); + let _ = rt.tick_at(start).expect("baseline presents"); + rt.set_layout(two_pane_split()); + let _ = rt.tick_at(start).expect("split presents"); + let settled = start + Duration::from_millis(200); + rt.tick_at(settled); + assert!(rt.tick_at(settled).is_none(), "settled baseline"); + + let t = Instant::now(); + rt.handle_cursor_moved(border_cell_pixels(39, 12)); + rt.handle_mouse_input(press(MouseButton::Left)); + assert!(rt.border_drag_active(), "border press must grab"); + assert!(rt.update_border_drag_at(border_cell_pixels(45, 12), t)); + for i in 0..(MAX_CONCURRENT_ANIMATIONS - 1) { + assert!(rt.trigger_animation(AnimationKind::Open, Some(ViewId::new(100 + i as u64)), t)); + } + rt.set_layout(LayoutNode::split( + SplitAxis::Horizontal, + 0.7, + LayoutNode::leaf(View::new(ViewId::new(1), 40, 24)), + LayoutNode::leaf(View::new(ViewId::new(2), 40, 24)), + )); + let after = t + Duration::from_millis(20); + let _ = rt.tick_at(after); + assert_eq!( + rt.paint_frames_at(after), + rt.present_frames(), + "capacity-refused bounds must paint the committed geometry" + ); + rt.handle_mouse_input(release(MouseButton::Left)); +} + +#[test] +fn retargeting_starts_from_the_currently_painted_bounds() { + // CTX-1335 F4: a live gesture update retargets from the painted frame at + // that instant, so the next present is continuous (no jump back to the + // previous committed start). + let mut rt = runtime_with(AnimationPolicy::default()); + let start = Instant::now(); + rt.set_container(UiRect::new(0, 0, 80, 24)); + let _ = rt.tick_at(start).expect("baseline presents"); + rt.set_layout(two_pane_split()); + let _ = rt.tick_at(start).expect("split presents"); + let settled = start + Duration::from_millis(200); + rt.tick_at(settled); + assert!(rt.tick_at(settled).is_none(), "settled baseline"); + + let t = Instant::now(); + rt.handle_cursor_moved(border_cell_pixels(39, 12)); + rt.handle_mouse_input(press(MouseButton::Left)); + assert!(rt.update_border_drag_at(border_cell_pixels(45, 12), t)); + let _ = rt.tick_at(t).expect("first resize present"); + let mid = t + Duration::from_millis(40); + rt.tick_at(mid).expect("resize mid frame presents"); + + let t2 = mid + Duration::from_millis(1); + assert!(rt.update_border_drag_at(border_cell_pixels(51, 12), t2)); + let continuity = frame_for(&rt.paint_frames_at(t2), ViewId::new(1)).frame; + let _ = rt.tick_at(t2).expect("retarget present"); + let retargeted = frame_for(&rt.paint_frames_at(t2), ViewId::new(1)).frame; + assert_eq!( + retargeted, continuity, + "retarget must restart from the currently painted bounds" + ); + let target = frame_for(&rt.present_frames(), ViewId::new(1)).frame; + assert_ne!( + retargeted, target, + "retargeted tween has not already settled" + ); + rt.handle_mouse_input(release(MouseButton::Left)); +} + +#[test] +fn alt_drag_float_keeps_one_animation_entry_under_a_full_budget() { + // CTX-1335 F3: an Alt-drag float holds the Drag ring; the per-view move + // bounds ride that entry instead of consuming a second budget slot. With + // the shared budget otherwise full, interpolation still works and the + // ring alpha is multiplied once. + let mut rt = runtime_with(AnimationPolicy::default()); + let start = Instant::now(); + rt.set_container(UiRect::new(0, 0, 80, 24)); + let _ = rt.tick_at(start).expect("baseline presents"); + rt.set_layout(two_pane_split()); + let _ = rt.tick_at(start).expect("split presents"); + let settled = start + Duration::from_millis(200); + rt.tick_at(settled); + rt.toggle_floating(Some(ViewId::new(1))) + .expect("detach view 1"); + let detach_settled = settled + Duration::from_millis(200); + rt.tick_at(detach_settled); + let detach_idle = detach_settled + Duration::from_millis(200); + rt.tick_at(detach_idle); + assert!(rt.tick_at(detach_idle).is_none(), "detach settled"); + + let grab = point_in_frame(&rt, ViewId::new(1), 0.5, 0.5); + rt.handle_cursor_moved(grab); + rt.handle_key_event(named_key(NamedKey::Alt, PressState::Pressed)); + rt.handle_mouse_input(press(MouseButton::Left)); + assert!( + rt.alt_drag_active(), + "Alt+press on the detached float grabs" + ); + // Commit the pointer focus change before filling the budget so this + // present does not also arm a focus fade that would legitimately evict a + // bounds entry at the shared cap. + let focus_settled = Instant::now(); + rt.tick_at(focus_settled); + let focus_idle = focus_settled + Duration::from_millis(200); + rt.tick_at(focus_idle); + assert!(rt.tick_at(focus_idle).is_none(), "focus settled"); + let old_frame = frame_for(&rt.present_frames(), ViewId::new(1)).frame; + let t = Instant::now(); + let moved = CursorPosition { + x: grab.x + 18.0, + y: grab.y + 38.0, + }; + assert!(rt.update_alt_drag_at(moved, t)); + let target = frame_for(&rt.present_frames(), ViewId::new(1)).frame; + assert_ne!(old_frame, target, "drag moved the committed float frame"); + for i in 0..(MAX_CONCURRENT_ANIMATIONS - 1) { + assert!(rt.trigger_animation(AnimationKind::Open, Some(ViewId::new(200 + i as u64)), t)); + } + let _ = rt.tick_at(t).expect("drag present attaches bounds"); + assert_eq!( + frame_for(&rt.paint_frames_at(t), ViewId::new(1)).frame, + old_frame, + "attached tween starts at the committed previous bounds" + ); + let mid = t + Duration::from_millis(75); + rt.tick_at(mid).expect("drag mid frame presents"); + let painted = frame_for(&rt.paint_frames_at(mid), ViewId::new(1)).frame; + assert_ne!(painted, old_frame, "mid drag must have moved"); + assert_ne!( + painted, target, + "a full budget must not refuse the drag's own bounds slot" + ); + assert!( + rt.animation_progress(AnimationKind::Move, Some(ViewId::new(1)), mid) + .is_none(), + "bounds ride the drag entry, not a second move entry" + ); + assert!( + rt.animation_progress(AnimationKind::Drag, Some(ViewId::new(1)), mid) + .is_some() + ); + rt.handle_mouse_input(release(MouseButton::Left)); + rt.handle_key_event(named_key(NamedKey::Alt, PressState::Released)); +} + +#[test] +fn pinned_overlay_does_not_slide_with_the_workspace() { + // CTX-1335 F5: pins are window-global chrome; the incoming workspace + // slides beneath them instead of dragging them in from off-screen. + let mut rt = runtime_with(AnimationPolicy::default()); + let start = Instant::now(); + rt.set_container(UiRect::new(0, 0, 80, 24)); + let _ = rt.tick_at(start).expect("baseline presents"); + rt.set_layout(two_pane_split()); + let _ = rt.tick_at(start).expect("split presents"); + let settled = start + Duration::from_millis(200); + rt.tick_at(settled); + rt.pin_floating(ViewId::new(1)).expect("pin view 1"); + let pinned_settled = settled + Duration::from_millis(200); + rt.tick_at(pinned_settled); + + let t = Instant::now(); + rt.workspace_new().expect("new workspace"); + let _ = rt.tick_at(t).expect("workspace switch presents"); + let target = frame_for(&rt.present_frames(), ViewId::new(1)); + let painted = frame_for(&rt.paint_frames_at(t), ViewId::new(1)); + assert_eq!( + painted.frame, target.frame, + "pinned chrome must stay anchored during the workspace slide" + ); + assert_eq!(painted.content, target.content); +} + +#[test] +fn unrelated_focus_fade_does_not_tween_an_appearance_reflow() { + // CTX-1335 F8: geometry interpolation needs a causal accepted transition; + // an unrelated focus fade must not turn a decoration/container reflow + // into a bounds tween. + let mut rt = runtime_with(AnimationPolicy::default()); + let start = Instant::now(); + rt.set_container(UiRect::new(0, 0, 80, 24)); + let _ = rt.tick_at(start).expect("baseline presents"); + rt.set_layout(two_pane_split()); + let _ = rt.tick_at(start).expect("split presents"); + let settled = start + Duration::from_millis(200); + rt.tick_at(settled); + assert!(rt.tick_at(settled).is_none(), "settled baseline"); + + let t = Instant::now(); + assert!(rt.trigger_animation(AnimationKind::Focus, Some(ViewId::new(1)), t)); + rt.set_container(UiRect::new(0, 0, 90, 24)); + let after = t + Duration::from_millis(20); + let _ = rt.tick_at(after); + assert_eq!( + rt.paint_frames_at(after), + rt.present_frames(), + "appearance reflow stays instant while an unrelated focus fades" + ); +} + +#[test] +fn zero_duration_trigger_does_not_evict_a_live_bounds_tween() { + // CTX-1335 F7: a refused ring trigger must not drop another surface's + // geometry before discovering its own duration is zero. + let mut rt = runtime_with(AnimationPolicy { + duration_ms: [150, 120, 0, 200, 150, 120, 150], + ..AnimationPolicy::default() + }); + let start = Instant::now(); + rt.set_container(UiRect::new(0, 0, 80, 24)); + let _ = rt.tick_at(start).expect("baseline presents"); + rt.set_layout(two_pane_split()); + let _ = rt.tick_at(start).expect("split presents"); + assert!( + rt.animation_progress(AnimationKind::Resize, Some(ViewId::new(1)), start) + .is_some(), + "split armed a resize bounds slot" + ); + for i in 0..(MAX_CONCURRENT_ANIMATIONS - 2) { + assert!(rt.trigger_animation( + AnimationKind::Open, + Some(ViewId::new(300 + i as u64)), + start + )); + } + let now = Instant::now(); + assert!( + !rt.trigger_animation(AnimationKind::Focus, Some(ViewId::new(999)), now), + "zero-duration focus never arms" + ); + assert!( + rt.animation_progress(AnimationKind::Resize, Some(ViewId::new(1)), now) + .is_some(), + "the live bounds tween survives a refused zero-duration trigger" + ); +} + // ── CTX-0967: move/resize/drag transitions ─────────────────────────────── /// Cursor pixels landing on container cell (col, row) under the default @@ -894,10 +1488,11 @@ fn zoom_engage_arms_close_and_disengage_arms_open() { } #[test] -fn zoom_never_interpolates_grid_or_geometry() { - // CTX-1078: zoom commits geometry and content at once. The leaf count - // changes at the mutation (not at animation end), and a grid byte - // written mid-zoom reads back verbatim. +fn zoom_commits_grid_and_hit_geometry_immediately() { + // CTX-1078 / CTX-1335: zoom commits the logical layout, PTY size, and + // hit-test geometry at the mutation (not at animation end). Only the + // paint bounds interpolate, and a grid byte written mid-zoom reads back + // verbatim. let mut rt = runtime_with(AnimationPolicy::default()); let start = Instant::now(); let _ = rt.tick_at(start);