Skip to main content

herogpui_components/
anim.rs

1//! Motion primitives shared by every animated component.
2//!
3//! HeroUI v3 drives animation from data attributes: overlays fade in on
4//! `[data-entering]`, buttons scale on `[data-pressed]`, and everything is
5//! suppressed when the user asks for reduced motion — with no opt-in required
6//! from the caller.
7//!
8//! ## Hover slots never request their own frame
9//!
10//! [`hover_fade`] and `field_chrome_ramp` keep a copy of the pointer state in
11//! keyed state, because a colour ramp needs to know which endpoint it is easing
12//! towards and gpui hands that out only through a listener. Those listeners
13//! record the new value and stop; they must never call `cx.notify()`.
14//!
15//! gpui reconciles an `on_hover` listener during *paint* whenever the element's
16//! interactive state is new, deferring a call so the listener catches up with a
17//! pointer that was already inside (`Interactivity::paint` in pinned gpui-pre
18//! 0.3.3). That reconciliation is not a user event, so a listener that notifies
19//! turns every such frame into a request for another one. It settles only if
20//! the element keeps the same id across frames -- and a control whose id comes
21//! from a caller-owned state entity does not, if the caller rebuilds that
22//! entity in `render`. The result is an unbounded re-render that spins a real
23//! app at full CPU and hangs a headless test, where `App::flush_effects` draws
24//! every dirty window until none is left.
25//!
26//! What repaints on a genuine crossing instead is gpui's own hover machinery:
27//! when an element carries a hover style, `Interactivity::paint` installs a
28//! capture-phase `MouseMoveEvent` handler that notifies the view. That handler
29//! is the *only* repaint on offer -- `Window::dispatch_mouse_event` refreshes
30//! for an active drag and nothing else -- and it is gated on `hover_style`
31//! specifically: a `mouse_cursor`-only element installs the handler but leaves
32//! its `hover_state` `None` and so notifies nothing.
33//!
34//! So the rule is **whoever owns the element's `on_hover` owns its gpui hover
35//! style**, and these two helpers own both rather than trusting a caller to
36//! supply the second. [`hover_fade`] takes the immediate `hover_border`
37//! endpoint that its callers used to apply themselves; `field_chrome_ramp`
38//! sets an empty refinement, because every endpoint it has is interpolated and
39//! a style swap would snap the colour. When an `Interaction` slot is passed,
40//! `util::track_interaction` owns the listener and its notify, and that
41//! caller keeps its own hover style. A caller that sets a second hover style on
42//! the same element trips gpui's own `debug_assert!("hover style already set")`,
43//! so the ownership rule cannot be broken silently.
44//!
45//! This module is the gpui equivalent. Components call [`entering`] instead of
46//! reaching for `with_animation` directly, so the reduced-motion check and the
47//! duration/easing live in exactly one place.
48
49use std::cell::{Cell, RefCell};
50use std::collections::HashMap;
51use std::rc::Rc;
52use std::time::Duration;
53
54use gpui::{
55    px, AnimationExt, AnyElement, App, ElementId, InteractiveElement, IntoElement, ParentElement,
56    StatefulInteractiveElement, StyleRefinement, Styled, Window,
57};
58use herogpui_core::element_id;
59use herogpui_theme::ActiveTheme;
60
61/// `[data-entering]` duration for the common case — most overlays are
62/// `duration-150`. Panels and `Autocomplete` are 250; see [`Motion`].
63pub const ENTERING_MS: u64 = 150;
64
65/// How long `.button`'s fill takes to change, read from its own declaration:
66/// `background-color 100ms var(--ease-out)`.
67///
68/// 150ms is the commonest duration across v3's sheets (88 declarations to
69/// 100ms's 33), but the button states its own, and this is the button's.
70pub const TRANSITION_MS: u64 = 100;
71
72/// Toast cards translate for 350ms but fade on their own 150ms opacity track.
73/// The separate constant keeps that stylesheet timing visible to the shared
74/// motion readers without conflating it with the placement motion itself.
75pub const TOAST_OPACITY_MS: u64 = 150;
76
77/// `.accordion__trigger`'s opacity/box-shadow transition duration. Accordion
78/// has its own 150ms declaration; it must not inherit the button's 100ms hover
79/// token when it uses the shared colour-fill machinery.
80pub const ACCORDION_TRIGGER_HOVER_MS: u64 = 150;
81
82/// How long a press takes: `transform 250ms var(--ease-smooth)`.
83///
84/// The instant [`pressed`] still arrives in one frame — gpui's `active` is a
85/// style swap with no timeline — but `pressed_with_background_ramp` rides
86/// this pinned duration on the components whose stylesheets declare it.
87pub const PRESS_MS: u64 = 250;
88
89/// `progress-bar-indeterminate`: one sweep every 1.5 seconds.
90pub const PROGRESS_BAR_INDETERMINATE_MS: u64 = 1500;
91/// `.progress-bar__fill` width transition duration.
92pub const PROGRESS_BAR_FILL_MS: u64 = 300;
93
94/// v3's indeterminate ProgressBar curve.
95pub fn progress_bar_indeterminate_ease() -> impl Fn(f32) -> f32 {
96    |t| cubic_bezier(0.65, 0.0, 0.35, 1.0, t)
97}
98
99/// `@keyframes progress-circle-spin`: one linear turn per second.
100pub const PROGRESS_CIRCLE_SPIN_MS: u64 = 1000;
101/// `.progress-circle__fill-circle` value changes use the pinned 300ms
102/// ease-out stroke transition. The canvas-backed port applies the same
103/// timeline to its retained arc fraction.
104pub const PROGRESS_CIRCLE_FILL_MS: u64 = 300;
105
106/// Rotation for one `progress-circle-spin` iteration, in radians.
107pub fn progress_circle_spin_turn(delta: f32) -> f32 {
108    delta.clamp(0.0, 1.0) * std::f32::consts::TAU
109}
110
111/// Evaluates a CSS `cubic-bezier(x1, y1, x2, y2)` at `t`.
112///
113/// v3 names its curves in `--ease-*` tokens and gpui takes an arbitrary easing
114/// function, so the real curves can be used rather than approximated by
115/// whichever of gpui's two built-ins looks closest.
116fn cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32, t: f32) -> f32 {
117    // A cubic Bezier from (0,0) to (1,1); `t` is the x we want a y for, so the
118    // curve parameter has to be solved for first.
119    let bez = |a: f32, b: f32, u: f32| {
120        let v = 1.0 - u;
121        3.0 * v * v * u * a + 3.0 * v * u * u * b + u * u * u
122    };
123    let mut lo = 0.0f32;
124    let mut hi = 1.0f32;
125    let mut u = t;
126    // Bisection: monotonic in x, and 24 halvings is well under a pixel.
127    for _ in 0..24 {
128        let x = bez(x1, x2, u);
129        if x < t {
130            lo = u;
131        } else {
132            hi = u;
133        }
134        u = (lo + hi) * 0.5;
135    }
136    bez(y1, y2, u)
137}
138
139/// v3's `--ease-out` — Tailwind's `ease-out`, `cubic-bezier(0, 0, 0.2, 1)`.
140pub fn ease_out() -> impl Fn(f32) -> f32 {
141    |t| cubic_bezier(0.0, 0.0, 0.2, 1.0, t)
142}
143
144/// One of v3's `--ease-*` curves.
145///
146/// Named rather than passed as a closure so a [`Motion`] stays `Copy` and can be
147/// a `const`.
148#[derive(Clone, Copy, Debug, PartialEq, Eq)]
149pub enum Curve {
150    /// `--ease-out`, Tailwind's `ease-out`: `cubic-bezier(0, 0, 0.2, 1)`.
151    Out,
152    /// `--ease-smooth`, CSS `ease`: `cubic-bezier(0.25, 0.1, 0.25, 1)`.
153    Smooth,
154    /// `--ease-out-quad`: `cubic-bezier(0.25, 0.46, 0.45, 0.94)`.
155    OutQuad,
156    /// `--ease-out-fluid`: `cubic-bezier(0.32, 0.72, 0, 1)`.
157    OutFluid,
158    /// `--ease-out-quart`: `cubic-bezier(0.165, 0.84, 0.44, 1)` — the
159    /// close button's transform curve.
160    OutQuart,
161    /// Constant speed, no easing.
162    Linear,
163}
164
165impl Curve {
166    /// Evaluates the curve at progress `t` (0 to 1), returning the eased progress.
167    pub fn at(self, t: f32) -> f32 {
168        match self {
169            Curve::Out => cubic_bezier(0.0, 0.0, 0.2, 1.0, t),
170            Curve::Smooth => cubic_bezier(0.25, 0.1, 0.25, 1.0, t),
171            Curve::OutQuad => cubic_bezier(0.25, 0.46, 0.45, 0.94, t),
172            Curve::OutFluid => cubic_bezier(0.32, 0.72, 0.0, 1.0, t),
173            Curve::OutQuart => cubic_bezier(0.165, 0.84, 0.44, 1.0, t),
174            Curve::Linear => t,
175        }
176    }
177}
178
179/// The duration, scale and curve v3 declares for one overlay's transition.
180///
181/// v3 does **not** animate every overlay the same way, which is what reading the
182/// guide rather than the stylesheets had suggested. Each surface names its own
183/// `duration-*`, `ease-*` and `zoom-*`, and a modal panel even *shrinks* in from
184/// 105% rather than growing from 90%. The constants below are transcribed one
185/// per group, and `anim_audit.py` checks them against the CSS.
186#[derive(Clone, Copy, Debug)]
187pub struct Motion {
188    /// Duration in milliseconds.
189    pub ms: u64,
190    /// The scale the animation starts at (entering) or ends at (exiting).
191    /// `1.0` means no scaling — a fade alone.
192    pub scale: f32,
193    /// Easing curve.
194    pub curve: Curve,
195}
196
197impl Motion {
198    /// `duration-250 ease-out-quad zoom-in-105` — `Modal` and `AlertDialog`
199    /// panels, which settle *down* onto the page.
200    pub const PANEL_IN: Motion = Motion {
201        ms: 250,
202        scale: 1.05,
203        curve: Curve::OutQuad,
204    };
205    /// `duration-100 ease-out-quad zoom-out-95`.
206    pub const PANEL_OUT: Motion = Motion {
207        ms: 100,
208        scale: 0.95,
209        curve: Curve::OutQuad,
210    };
211
212    /// `duration-150 ease-out fade-in-0` — the backdrop behind a panel, which
213    /// only fades.
214    pub const BACKDROP_IN: Motion = Motion {
215        ms: 150,
216        scale: 1.0,
217        curve: Curve::Out,
218    };
219    /// `duration-100 ease-out fade-out-0`.
220    pub const BACKDROP_OUT: Motion = Motion {
221        ms: 100,
222        scale: 1.0,
223        curve: Curve::Out,
224    };
225
226    /// `duration-150 ease-smooth zoom-in-90` — `Popover`, `Dropdown`, `Tooltip`.
227    pub const POPOVER_IN: Motion = Motion {
228        ms: 150,
229        scale: 0.90,
230        curve: Curve::Smooth,
231    };
232    /// `duration-150 ease-smooth zoom-in-95` — `Select`, `ComboBox`, the date
233    /// and colour pickers, which start closer to full size.
234    pub const LIST_IN: Motion = Motion {
235        ms: 150,
236        scale: 0.95,
237        curve: Curve::Smooth,
238    };
239    /// `duration-100 ease-smooth zoom-out-95` — the exit both share.
240    pub const LIST_OUT: Motion = Motion {
241        ms: 100,
242        scale: 0.95,
243        curve: Curve::Smooth,
244    };
245
246    /// `.disclosure__content` is a *transition*, not an `animate-in`: `height
247    /// 200ms ease-out-quad, opacity 200ms ease-out`. The collapsible panel
248    /// helper measures its natural child extent and drives both properties at
249    /// these curves.
250    pub const DISCLOSURE: Motion = Motion {
251        ms: 200,
252        scale: 1.0,
253        curve: Curve::OutQuad,
254    };
255
256    /// `field-error.css` expands the error row over 350ms with the smooth
257    /// curve, independently of its shorter 150ms opacity transition.
258    pub const FIELD_ERROR_HEIGHT: Motion = Motion {
259        ms: 350,
260        scale: 1.0,
261        curve: Curve::Smooth,
262    };
263    /// Opacity transition of the field error row: 150ms with the `Out` curve.
264    pub const FIELD_ERROR_OPACITY: Motion = Motion {
265        ms: 150,
266        scale: 1.0,
267        curve: Curve::Out,
268    };
269
270    /// `translate 250ms cubic-bezier(0.32, 0.72, 0, 1)` — the drawer's slide,
271    /// which `drawer.css` gives its own `--drawer-enter-*` tokens.
272    pub const DRAWER_IN: Motion = Motion {
273        ms: 250,
274        scale: 1.0,
275        curve: Curve::OutFluid,
276    };
277    /// `--drawer-exit-duration: 200ms`, same curve.
278    pub const DRAWER_OUT: Motion = Motion {
279        ms: 200,
280        scale: 1.0,
281        curve: Curve::OutFluid,
282    };
283
284    /// Drawer-specific backdrop fade from `drawer.css`: it follows the
285    /// panel's fluid enter curve instead of the shared modal backdrop token.
286    pub const DRAWER_BACKDROP_IN: Motion = Motion {
287        ms: 250,
288        scale: 1.0,
289        curve: Curve::OutFluid,
290    };
291    /// Drawer-specific backdrop exit from `drawer.css`.
292    pub const DRAWER_BACKDROP_OUT: Motion = Motion {
293        ms: 200,
294        scale: 1.0,
295        curve: Curve::OutFluid,
296    };
297
298    /// `duration-250 ease-out-fluid zoom-in-95` — `Autocomplete` alone.
299    pub const FLUID_IN: Motion = Motion {
300        ms: 250,
301        scale: 0.95,
302        curve: Curve::OutFluid,
303    };
304    /// `duration-100 ease-out-quad zoom-out-95`.
305    pub const FLUID_OUT: Motion = Motion {
306        ms: 100,
307        scale: 0.95,
308        curve: Curve::OutQuad,
309    };
310
311    /// `350ms ease-out-fluid` — Toast's placement-aware card translation and
312    /// fade when a new notification enters the queue.
313    pub const TOAST_IN: Motion = Motion {
314        ms: 350,
315        scale: 1.0,
316        curve: Curve::OutFluid,
317    };
318    /// `350ms ease-out-fluid` — the frontmost Toast card leaves toward the
319    /// placement edge while its opacity settles to zero.
320    pub const TOAST_OUT: Motion = Motion {
321        ms: 350,
322        scale: 1.0,
323        curve: Curve::OutFluid,
324    };
325    /// `200ms ease-out-fluid` and `scale(.96)` — a non-frontmost Toast card
326    /// retires in an expanded/collapsed stack without leaving its slot.
327    pub const TOAST_STACK_OUT: Motion = Motion {
328        ms: 200,
329        scale: 0.96,
330        curve: Curve::OutFluid,
331    };
332}
333
334/// v3's `--ease-smooth`, which is CSS `ease`: `cubic-bezier(0.25, 0.1, 0.25, 1)`.
335pub fn ease_smooth() -> impl Fn(f32) -> f32 {
336    |t| cubic_bezier(0.25, 0.1, 0.25, 1.0, t)
337}
338
339/// Tailwind's default transition timing — `cubic-bezier(0.4, 0, 0.2, 1)` — the
340/// curve a `transition-all duration-*` utility runs when the rule names no
341/// `--ease-*` token. v3's checkmark undraw rides it: selected, its rule is
342/// `stroke-dashoffset 150ms linear 15ms`; unselecting falls back to the base
343/// `transition-all duration-200`.
344pub(crate) fn tailwind_default_ease() -> impl Fn(f32) -> f32 {
345    |t| cubic_bezier(0.4, 0.0, 0.2, 1.0, t)
346}
347
348/// The transition on v3's accordion and disclosure indicators. Both stylesheets
349/// keep one down-chevron in the DOM and rotate it 180 degrees when expanded;
350/// the two components share this keyed implementation so a quick reversal
351/// resumes from the frame that was actually painted. The bare Tailwind
352/// `transition` utility uses the pinned default curve, rather than the named
353/// `ease-smooth` token used by several other HeroUI transitions.
354#[allow(dead_code)]
355pub(crate) const INDICATOR_ROTATION_MS: u64 = 250;
356
357/// HeroUI's calendar year-picker indicator uses the same transition timing
358/// as its trigger heading color and rotates the down chevron through one
359/// quarter turn when the picker opens.
360pub(crate) const YEAR_PICKER_INDICATOR_MS: u64 = 150;
361pub(crate) const YEAR_PICKER_INDICATOR_ANGLE: f32 = std::f32::consts::FRAC_PI_2;
362
363/// Render a 16px indicator SVG with the same state-driven rotation used by
364/// HeroUI's `.accordion__indicator` and `.disclosure__indicator` rules.
365///
366/// `svg` must already carry its path and visual styling. The wrapper animates
367/// only the SVG transform, so its layout box stays fixed while the angle moves
368/// from the live frame to the new expanded endpoint. Reduced motion still
369/// paints the endpoint immediately and does not schedule a frame.
370pub(crate) fn rotating_indicator(
371    id: &ElementId,
372    expanded: bool,
373    svg: gpui::Svg,
374    window: &mut Window,
375    cx: &mut App,
376) -> AnyElement {
377    // Keep the chevron duration tied to the disclosure motion token so an
378    // application theme can audit one source of truth for both the panel and
379    // its indicator (the stock value is `Motion::DISCLOSURE.ms` = 250ms).
380    rotating_indicator_with_duration(id, expanded, svg, Motion::DISCLOSURE.ms, window, cx)
381}
382
383/// Render a rotating SVG indicator with an owner-specific transition length.
384///
385/// HeroUI's disclosure indicators use the bare 250ms transition, while the
386/// field/picker indicators use the same transform with a 150ms duration. Keep
387/// the keyed/live-frame behavior shared so both families reverse from the
388/// frame that was actually painted.
389pub(crate) fn rotating_indicator_with_duration(
390    id: &ElementId,
391    expanded: bool,
392    svg: gpui::Svg,
393    duration_ms: u64,
394    window: &mut Window,
395    cx: &mut App,
396) -> AnyElement {
397    rotating_indicator_with_angle(
398        id,
399        expanded,
400        svg,
401        duration_ms,
402        std::f32::consts::PI,
403        window,
404        cx,
405    )
406}
407
408/// Render an indicator with an owner-specific rotation angle.
409///
410/// Most HeroUI disclosure-like indicators turn a down chevron through 180°.
411/// Calendar year-picker indicators are the exception: the pinned stylesheet
412/// turns the same glyph through 90° over 150ms. Keeping the angle in this
413/// shared helper preserves keyed reversal and reduced-motion behavior without
414/// making Calendar and RangeCalendar invent separate animation state.
415pub(crate) fn rotating_indicator_with_angle(
416    id: &ElementId,
417    expanded: bool,
418    svg: gpui::Svg,
419    duration_ms: u64,
420    angle: f32,
421    window: &mut Window,
422    cx: &mut App,
423) -> AnyElement {
424    rotating_indicator_with_angle_easing(
425        id,
426        expanded,
427        svg,
428        duration_ms,
429        angle,
430        IndicatorEasing::TailwindDefault,
431        window,
432        cx,
433    )
434}
435
436/// Render an indicator with HeroUI's explicit `--ease-out` curve.
437///
438/// Calendar year-picker CSS names this curve directly, unlike the bare
439/// transition used by Accordion and Disclosure. Its keyed state is shared
440/// with the general angle helper, so interrupted open/close motion still
441/// resumes from the painted frame.
442pub(crate) fn rotating_indicator_with_angle_ease_out(
443    id: &ElementId,
444    expanded: bool,
445    svg: gpui::Svg,
446    duration_ms: u64,
447    angle: f32,
448    window: &mut Window,
449    cx: &mut App,
450) -> AnyElement {
451    rotating_indicator_with_angle_easing(
452        id,
453        expanded,
454        svg,
455        duration_ms,
456        angle,
457        IndicatorEasing::EaseOut,
458        window,
459        cx,
460    )
461}
462
463#[derive(Clone, Copy)]
464enum IndicatorEasing {
465    TailwindDefault,
466    EaseOut,
467}
468
469#[allow(clippy::too_many_arguments)] // the two easing wrappers pass their parameters straight through
470fn rotating_indicator_with_angle_easing(
471    id: &ElementId,
472    expanded: bool,
473    svg: gpui::Svg,
474    duration_ms: u64,
475    angle: f32,
476    easing: IndicatorEasing,
477    window: &mut Window,
478    cx: &mut App,
479) -> AnyElement {
480    let reduce_motion = ActiveTheme::reduce_motion(cx);
481    let target = if expanded { 1.0 } else { 0.0 };
482    let mut rotation = Tween::keyed(id, "indicator-rotation", target, window, cx);
483    rotation.snap_if_reduced(reduce_motion);
484
485    if !rotation.animates(reduce_motion) {
486        rotation.settle();
487        return svg
488            .with_transformation(gpui::Transformation::rotate(gpui::radians(
489                rotation.target() * angle,
490            )))
491            .into_any_element();
492    }
493
494    let from = rotation.from();
495    let to = rotation.target();
496    let value = rotation.value();
497    let animation = gpui::Animation::new(Duration::from_millis(duration_ms));
498    let animation = match easing {
499        IndicatorEasing::TailwindDefault => animation.with_easing(tailwind_default_ease()),
500        IndicatorEasing::EaseOut => animation.with_easing(ease_out()),
501    };
502    svg.with_animation(
503        element_id::indexed(id, "indicator-rotation", rotation.generation()),
504        animation,
505        move |svg, delta| {
506            let progress = from + (to - from) * delta;
507            value.set(progress);
508            svg.with_transformation(gpui::Transformation::rotate(gpui::radians(
509                progress * angle,
510            )))
511        },
512    )
513    .into_any_element()
514}
515
516/// The keyed tween bookkeeping the checkbox's motion slots share: the last
517/// target, the generation that advanced with it, the snapshot a new animation
518/// starts from, and the live value an interrupted one resumes from.
519///
520/// `target`/`from`/`generation` live in the keyed state; `value` is an
521/// `Rc<Cell<_>>` the animation closure writes, so the snapshot a later
522/// generation takes is the frame actually on screen.
523#[derive(Clone)]
524pub(crate) struct Tween<T: Copy + PartialEq + 'static> {
525    target: T,
526    generation: usize,
527    from: T,
528    value: Rc<Cell<T>>,
529}
530
531impl<T: Copy + PartialEq + 'static> Tween<T> {
532    fn settled(value: T) -> Self {
533        Self {
534            target: value,
535            generation: 0,
536            from: value,
537            value: Rc::new(Cell::new(value)),
538        }
539    }
540
541    /// Reads this slot's keyed state for `target`, advancing the generation —
542    /// and re-snapshotting the rendered value as the new start — when the
543    /// target changed.
544    pub(crate) fn keyed(
545        id: &ElementId,
546        tag: &'static str,
547        target: T,
548        window: &mut Window,
549        cx: &mut App,
550    ) -> Self {
551        Self::keyed_with_initial(id, tag, target, None, window, cx)
552    }
553
554    /// Reads a keyed slot, optionally seeding its first frame from an
555    /// explicit value. The seed is used only when the slot is first created;
556    /// later target changes still resume from the live value written by the
557    /// previous animation generation.
558    pub(crate) fn keyed_with_initial(
559        id: &ElementId,
560        tag: &'static str,
561        target: T,
562        initial_from: Option<T>,
563        window: &mut Window,
564        cx: &mut App,
565    ) -> Self {
566        let state =
567            window.use_keyed_state(element_id::scoped(id, tag), cx, |_, _| match initial_from {
568                Some(from) if from != target => Self {
569                    target,
570                    generation: 1,
571                    from,
572                    value: Rc::new(Cell::new(from)),
573                },
574                _ => Self::settled(target),
575            });
576        let mut current = state.read(cx).clone();
577        if current.target != target {
578            current.target = target;
579            current.generation = current.generation.wrapping_add(1);
580            current.from = current.value.get();
581            state.update(cx, |stored, _| *stored = current.clone());
582        }
583        current
584    }
585
586    /// Lands the value on the target under reduced motion — the state still
587    /// applies, with no animation mounted.
588    pub(crate) fn snap_if_reduced(&mut self, reduce_motion: bool) {
589        if reduce_motion && self.value.get() != self.target {
590            self.from = self.target;
591            self.value.set(self.target);
592        }
593    }
594
595    /// Whether this frame mounts an animation: a real change happened (the
596    /// generation moved), motion is allowed, and the live value is short of
597    /// the target — which also keeps a finished tween from re-mounting at
598    /// rest.
599    pub(crate) fn animates(&self, reduce_motion: bool) -> bool {
600        self.generation != 0 && !reduce_motion && self.value.get() != self.target
601    }
602
603    /// Lands exactly on the target, the state a settled tween paints.
604    pub(crate) fn settle(&self) {
605        self.value.set(self.target);
606    }
607
608    pub(crate) fn target(&self) -> T {
609        self.target
610    }
611
612    pub(crate) fn from(&self) -> T {
613        self.from
614    }
615
616    pub(crate) fn generation(&self) -> usize {
617        self.generation
618    }
619
620    /// The live value, shared with the keyed state and the animation closure.
621    pub(crate) fn value(&self) -> Rc<Cell<T>> {
622        Rc::clone(&self.value)
623    }
624}
625
626/// The scale v3 applies to a pressed control (`transform: scale(0.97)`).
627pub const PRESSED_SCALE: f32 = 0.97;
628
629/// The other scales v3 presses with: a menu row and a pagination link squeeze
630/// less than a button, a calendar cell and a radio control more, and a range
631/// calendar cell most.
632pub const PRESSED_SCALE_SUBTLE: f32 = 0.98;
633
634/// `list-box-item.css`'s pressed transform: option rows ease to 98% over
635/// 250ms with the pinned quart curve. Keep this beside the shared press ramp
636/// so ListBox and Select cannot drift into different row motion.
637pub(crate) const LIST_ITEM_PRESS: PressTiming = PressTiming {
638    transform_ms: 250,
639    transform: Curve::OutQuart,
640    background: None,
641};
642/// Pressed scale of 0.96, used for large buttons and pagination links.
643pub const PRESSED_SCALE_FIRM: f32 = 0.96;
644/// Pressed scale of 0.95, used for smaller controls such as calendar cells and radio controls.
645pub const PRESSED_SCALE_DEEP: f32 = 0.95;
646/// Pressed scale of 0.9, used for range calendar cells.
647pub const PRESSED_SCALE_RANGE: f32 = 0.9;
648
649/// The inset that shrinks a control of `height` by a scale about its
650/// centre.
651pub fn pressed_inset(height: gpui::Pixels) -> gpui::Pixels {
652    inset_for(height, PRESSED_SCALE)
653}
654
655/// The inset that shrinks `height` by `scale`, centred.
656fn inset_for(height: gpui::Pixels, scale: f32) -> gpui::Pixels {
657    px(f32::from(height) * (1.0 - scale) / 2.0)
658}
659
660#[cfg(test)]
661fn shrink(value: gpui::Pixels, by: gpui::Pixels) -> gpui::Pixels {
662    px((f32::from(value) - f32::from(by)).max(0.0))
663}
664
665/// `value` scaled by `scale`.
666fn scaled_by(value: gpui::Pixels, scale: f32) -> gpui::Pixels {
667    px(f32::from(value) * scale)
668}
669
670/// Scale the skin's resolved pixel corners independently. Unsupported Rems
671/// retain the existing PressBox fallback; callers keep them on the root.
672pub(crate) fn pressed_corners(
673    corners: &gpui::CornersRefinement<gpui::AbsoluteLength>,
674    fallback: gpui::Pixels,
675    scale: f32,
676) -> gpui::Corners<Option<gpui::Pixels>> {
677    let scale_corner = |corner| {
678        let radius = match corner {
679            Some(gpui::AbsoluteLength::Pixels(radius)) => radius,
680            _ => fallback,
681        };
682        Some(scaled_by(radius, scale))
683    };
684    gpui::Corners {
685        top_left: scale_corner(corners.top_left),
686        top_right: scale_corner(corners.top_right),
687        bottom_right: scale_corner(corners.bottom_right),
688        bottom_left: scale_corner(corners.bottom_left),
689    }
690}
691
692/// Carry the resting corner shape onto the stable press slot.
693///
694/// `pressed_with_optional_background` wraps a button's painted skin in a
695/// footprint-preserving slot.  The slot is the element that owns the focus
696/// ring, so leaving its corners at the default zero radius turns a rounded
697/// button's ring into a square.  Keep partial group corners partial; only a
698/// completely unspecified refinement needs the PressBox fallback.
699fn resting_slot_corners(
700    corners: &gpui::CornersRefinement<gpui::AbsoluteLength>,
701    fallback: gpui::Pixels,
702) -> gpui::Corners<Option<gpui::Pixels>> {
703    let any_specified = corners.top_left.is_some()
704        || corners.top_right.is_some()
705        || corners.bottom_right.is_some()
706        || corners.bottom_left.is_some();
707    let resolve = |corner| match corner {
708        Some(gpui::AbsoluteLength::Pixels(radius)) => Some(radius),
709        Some(_) => Some(fallback),
710        None if any_specified => None,
711        None => Some(fallback),
712    };
713    gpui::Corners {
714        top_left: resolve(corners.top_left),
715        top_right: resolve(corners.top_right),
716        bottom_right: resolve(corners.bottom_right),
717        bottom_left: resolve(corners.bottom_left),
718    }
719}
720
721/// Everything a pressed control scales down.
722#[derive(Clone, Copy, Debug)]
723pub struct PressBox {
724    /// Resting height.
725    pub height: gpui::Pixels,
726    /// Horizontal padding for a control that sizes to its content, or `None`
727    /// for one with a fixed width.
728    pub padding_x: Option<gpui::Pixels>,
729    /// Fixed width, for a square icon-only control.
730    pub width: Option<gpui::Pixels>,
731    /// Minimum width, which has to scale too or it pins the box at full size.
732    pub min_width: Option<gpui::Pixels>,
733    /// Font size.
734    pub text_size: gpui::Pixels,
735    /// Line height.
736    pub line_height: gpui::Pixels,
737    /// Gap between children.
738    pub gap: gpui::Pixels,
739    /// Corner radius.
740    pub radius: gpui::Pixels,
741    /// How far the press scales. v3 uses 0.97 for a button, 0.98 for a menu row,
742    /// 0.96 and 0.95 for the smaller controls, so it is per control rather than
743    /// one constant.
744    pub scale: f32,
745    /// False for a full-width control, whose width is its parent's: a
746    /// horizontal margin there would overflow rather than inset.
747    pub shrink_x: bool,
748}
749
750/// Applies v3's `[data-pressed]` press.
751///
752/// v3 presses with `transform: scale(s)` about the centre, and gpui 0.2.2 has
753/// no paint transform — so the pressed element becomes the painted *skin*
754/// inside a stable slot root: the slot keeps the resting footprint, and the
755/// skin downscales about the centre through fractional absolute insets. The
756/// scale therefore never reflows the slot's neighbours, and the skin's own
757/// content (label, padding) is carried along by its box.
758///
759/// Returns `el` untouched under reduced motion.
760pub fn pressed(el: gpui::Stateful<gpui::Div>, b: PressBox, cx: &App) -> gpui::Stateful<gpui::Div> {
761    pressed_with_optional_background(el, b, None, cx)
762}
763
764/// Applies the same press geometry and an active-state background in one
765/// refinement, for controls whose CSS changes both on `[data-pressed]`.
766pub fn pressed_with_background(
767    el: gpui::Stateful<gpui::Div>,
768    b: PressBox,
769    background: gpui::Hsla,
770    cx: &App,
771) -> gpui::Stateful<gpui::Div> {
772    pressed_with_optional_background(el, b, Some(background), cx)
773}
774
775/// Resting slot widths, recorded by [`press_slot_recorder`] while a
776/// content-sized skin hugs its slot at rest and held while it is pressed.
777#[derive(Default)]
778struct PressSlotSizes(RefCell<HashMap<ElementId, f32>>);
779
780impl gpui::Global for PressSlotSizes {}
781
782fn press_slot_width(cx: &App, id: &ElementId) -> Option<f32> {
783    let slots = cx.try_global::<PressSlotSizes>()?;
784    slots.0.borrow().get(id).copied()
785}
786
787/// An invisible layer over the slot that keeps its resting width fresh:
788/// recorded while the skin hugs the slot at rest, and held while it is
789/// pressed.
790fn press_slot_recorder(id: ElementId) -> AnyElement {
791    gpui::canvas(
792        |_, _, _| {},
793        move |bounds, _, _, cx| {
794            let width = f32::from(bounds.size.width);
795            let slots = cx.default_global::<PressSlotSizes>();
796            if slots.0.borrow().get(&id).copied() != Some(width) {
797                slots.0.borrow_mut().insert(id, width);
798            }
799        },
800    )
801    .absolute()
802    .inset_0()
803    .into_any_element()
804}
805
806fn pressed_with_optional_background(
807    mut el: gpui::Stateful<gpui::Div>,
808    b: PressBox,
809    background: Option<gpui::Hsla>,
810    cx: &App,
811) -> gpui::Stateful<gpui::Div> {
812    if ActiveTheme::reduce_motion(cx) {
813        return match background {
814            Some(background) => el.active(move |style| style.bg(background)),
815            None => el,
816        };
817    }
818    // The skin is sized by all four fractional insets — `(1 - s) / 2` of each
819    // slot axis is exactly the gap a scale of `s` leaves on that side — with
820    // explicit `Auto` extents overriding any way the caller sized the skin
821    // (an explicit `h`, `w_full`, `min_h`). Inset sizing needs no percentage
822    // resolution, which matters because the slot's height is only a minimum:
823    // a percentage height against it would not resolve and the bottom edge
824    // would stay put.
825    let inset = gpui::DefiniteLength::Fraction((1.0 - b.scale) / 2.0);
826    let pressed_min_height = scaled_by(b.height, b.scale);
827    let pressed_radius = scaled_by(b.radius, b.scale);
828    let corners = pressed_corners(&el.style().corner_radii, b.radius, b.scale);
829
830    // `active` state only exists for elements with a hitbox, and a hitbox is
831    // only inserted for elements that track focus, set a cursor, or listen to
832    // the mouse. Skins whose caller keeps every handler on the slot (the
833    // calendar cells) would press invisibly; arm a no-op listener so the
834    // press registers. `on_mouse_down` appends, so a caller's own listener
835    // is untouched.
836    let el = el.on_mouse_down(gpui::MouseButton::Left, |_, _, _| {});
837    let mut el = el.active(move |s: StyleRefinement| {
838        let s = match background {
839            Some(background) => s.bg(background),
840            None => s,
841        };
842        crate::util::round_sx_corners(
843            s.absolute()
844                .left(inset)
845                .right(inset)
846                .top(inset)
847                .bottom(inset)
848                .w(gpui::Length::Auto)
849                .h(gpui::Length::Auto)
850                .min_h(pressed_min_height)
851                .rounded(pressed_radius),
852            &corners,
853        )
854    });
855
856    let Some(id) = el.interactivity().element_id.clone() else {
857        // No identity to anchor the slot's resting width on; skip the scale
858        // rather than risk a reflow.
859        return el;
860    };
861    let slot_corners = resting_slot_corners(&el.style().corner_radii, b.radius);
862    press_slot(el, id, b, &slot_corners, cx)
863}
864
865/// Wraps a built skin in the stable press slot: the resting footprint that
866/// keeps the caller's id, hit-testing, focus and focus-ring geometry while
867/// the skin inside carries the press.
868fn press_slot(
869    el: impl IntoElement,
870    id: ElementId,
871    b: PressBox,
872    slot_corners: &gpui::Corners<Option<gpui::Pixels>>,
873    cx: &App,
874) -> gpui::Stateful<gpui::Div> {
875    // The slot keeps the resting footprint: fixed where the caller gave us a
876    // width or asked for full width, and otherwise the skin's resting width
877    // recorded while it hugged the slot at rest. Callers must add every
878    // visual child to the skin *before* pressing — children added after land
879    // on the slot and fight the skin for its width.
880    let mut slot = gpui::div()
881        .id(element_id::scoped(&id, "press-slot"))
882        .relative()
883        .flex_shrink_0()
884        .flex()
885        .items_center()
886        .justify_center()
887        // A minimum, not a fixed height: a tall content row grows the slot
888        // past the control minimum instead of being clipped to it.
889        .min_h(b.height);
890    slot = crate::util::round_sx_corners(slot, slot_corners);
891    slot = match b.width {
892        Some(width) => slot.w(width),
893        None if !b.shrink_x => slot.w_full(),
894        None => {
895            if let Some(width) = press_slot_width(cx, &id) {
896                slot = slot.w(px(width));
897            }
898            slot.child(press_slot_recorder(id))
899        }
900    };
901    slot.child(el)
902}
903
904/// Resting corner radii resolved against a fallback, ready to be rescaled per
905/// animation frame by [`scale_corners`].
906fn resting_corner_radii(
907    el: &mut gpui::Stateful<gpui::Div>,
908    fallback: gpui::Pixels,
909) -> gpui::Corners<Option<gpui::Pixels>> {
910    pressed_corners(&el.style().corner_radii, fallback, 1.0)
911}
912
913/// The corners a scale `s` paints: every resolved radius shrinks about the
914/// centre with the box.
915fn scale_corners(
916    corners: &gpui::Corners<Option<gpui::Pixels>>,
917    s: f32,
918) -> gpui::Corners<Option<gpui::Pixels>> {
919    let scale = |radius: Option<gpui::Pixels>| radius.map(|radius| scaled_by(radius, s));
920    gpui::Corners {
921        top_left: scale(corners.top_left),
922        top_right: scale(corners.top_right),
923        bottom_right: scale(corners.bottom_right),
924        bottom_left: scale(corners.bottom_left),
925    }
926}
927
928/// Lays the skin out at scale `s`: the four fractional insets, the `Auto`
929/// extents that let them rule the box, and the rescaled minimum height and
930/// corners. Shared verbatim by the settled and animated paint paths so the
931/// ramp's frames land exactly where the instant press did.
932fn skin_at_scale(
933    skin: gpui::Stateful<gpui::Div>,
934    s: f32,
935    b: &PressBox,
936    resting: &gpui::Corners<Option<gpui::Pixels>>,
937) -> gpui::Stateful<gpui::Div> {
938    let inset = gpui::DefiniteLength::Fraction((1.0 - s) / 2.0);
939    crate::util::round_sx_corners(
940        skin.absolute()
941            .left(inset)
942            .right(inset)
943            .top(inset)
944            .bottom(inset)
945            .w(gpui::Length::Auto)
946            .h(gpui::Length::Auto)
947            .min_h(scaled_by(b.height, s))
948            .rounded(scaled_by(b.radius, s)),
949        &scale_corners(resting, s),
950    )
951}
952
953/// One component's pinned press transition, read from its stylesheet's
954/// `transition` block: the `transform` track and the `background-color` track
955/// carry separate durations and easings, exactly as the cascade interpolates
956/// them independently.
957#[derive(Clone, Copy, Debug)]
958pub(crate) struct PressTiming {
959    /// The `transform` duration and easing.
960    pub transform_ms: u64,
961    pub transform: Curve,
962    /// The `background-color` duration and easing, or `None` when the pressed
963    /// state changes no background — the track then has no endpoints to ease
964    /// between.
965    pub background: Option<(u64, Curve)>,
966}
967
968/// `button.css` and `toggle-button.css`, lines 11-15 / 13-17:
969/// `transform 250ms var(--ease-smooth), background-color 100ms
970/// var(--ease-out), box-shadow 100ms var(--ease-out)`. The pressed state
971/// changes no box-shadow, so only the first two tracks have endpoints here.
972pub(crate) const BUTTON_PRESS: PressTiming = PressTiming {
973    transform_ms: PRESS_MS,
974    transform: Curve::Smooth,
975    background: Some((100, Curve::Out)),
976};
977
978/// `close-button.css`, lines 14-19: `transform 250ms var(--ease-out-quart),
979/// color 150ms var(--ease-out), background-color 100ms var(--ease-out),
980/// box-shadow 150ms var(--ease-out)`. Its pressed state declares only
981/// `transform: scale(0.93)` — the colour tracks have no pressed endpoints.
982pub(crate) const CLOSE_BUTTON_PRESS: PressTiming = PressTiming {
983    transform_ms: 250,
984    transform: Curve::OutQuart,
985    background: None,
986};
987
988/// Applies a component's `[data-pressed]` press as the interpolated ramp its
989/// stylesheet's `transition` block declares, instead of the one-frame swap
990/// [`pressed_with_background`] paints.
991///
992/// The geometry and the fill ride separate timelines — `transform` and
993/// `background-color` interpolate on their own pinned durations and easings —
994/// the way the cascade runs them as independent transitions. Each track keeps
995/// a [`Tween`] keyed by the button's own element id, so sibling instances
996/// never share a timeline, and a release mid-press re-snapshots the frame
997/// actually on screen as the new start rather than restarting from rest.
998///
999/// The skin stays the listener-free visual child of the stable press slot:
1000/// the animation ids above it change per generation, and everything that
1001/// owns state — the slot's id, hit-testing, focus and focus ring — never
1002/// moves. Reduced motion snaps both tracks to their endpoints, matching
1003/// `motion-reduce:transition-none`, which removes the timing but keeps the
1004/// pressed property values.
1005///
1006/// `endpoints` is the colour track's `(resting, pressed)` pair, resolved by
1007/// the caller from its variant; `None` for a component whose pressed state
1008/// changes no background. `interaction` supplies the pressed bit when the
1009/// caller already tracks one (a `content` closure's slot); otherwise a
1010/// per-instance slot is created here and `util::track_interaction` is wired
1011/// onto the returned slot — the same handlers that keep a render prop's
1012/// `isPressed` current, including the keyboard press and the release outside
1013/// the control's bounds.
1014pub(crate) fn pressed_with_background_ramp(
1015    mut el: gpui::Stateful<gpui::Div>,
1016    b: PressBox,
1017    endpoints: Option<(gpui::Hsla, gpui::Hsla)>,
1018    timing: PressTiming,
1019    interaction: Option<&crate::util::Interaction>,
1020    window: &mut Window,
1021    cx: &mut App,
1022) -> gpui::Stateful<gpui::Div> {
1023    // A full-width control has a stable slot and a painted skin with the same
1024    // inline extent. Without this, the slot centers the skin at its intrinsic
1025    // min-content width: list rows become narrow, long labels never wrap, and
1026    // absolute indicators/focus rings appear to move when row padding changes.
1027    // Content-sized controls keep their intrinsic width and use the recorder
1028    // below instead.
1029    if !b.shrink_x {
1030        el = el.w_full();
1031    }
1032    let slot_corners = resting_slot_corners(&el.style().corner_radii, b.radius);
1033    let Some(id) = el.interactivity().element_id.clone() else {
1034        // No identity to key the tweens or the slot's resting width on; skip
1035        // the scale rather than risk a reflow, like the instant press.
1036        return el;
1037    };
1038
1039    // The pressed bit a frame behind the pointer, exactly like a render
1040    // prop's: a handler stashes it in the keyed slot and this render reads it.
1041    let (tracked, pressed) = match interaction {
1042        Some(slot) => (None, slot.read(cx).1),
1043        None => {
1044            let tracked =
1045                crate::util::interaction(element_id::scoped(&id, "press-track"), window, cx);
1046            let pressed = tracked.read(cx).1;
1047            (Some(tracked), pressed)
1048        }
1049    };
1050
1051    let (resting, pressed_bg) = endpoints.unwrap_or_default();
1052    let reduce = ActiveTheme::reduce_motion(cx);
1053    let mut scale_tween = Tween::keyed(
1054        &id,
1055        "press-transform",
1056        if pressed { b.scale } else { 1.0 },
1057        window,
1058        cx,
1059    );
1060    let mut color_tween = endpoints.map(|_| {
1061        Tween::keyed(
1062            &id,
1063            "press-bg",
1064            if pressed { pressed_bg } else { resting },
1065            window,
1066            cx,
1067        )
1068    });
1069    scale_tween.snap_if_reduced(reduce);
1070    if let Some(tween) = &mut color_tween {
1071        tween.snap_if_reduced(reduce);
1072    }
1073
1074    let resting_corners = resting_corner_radii(&mut el, b.radius);
1075    let animates = scale_tween.animates(reduce)
1076        || color_tween
1077            .as_ref()
1078            .is_some_and(|tween| tween.animates(reduce));
1079
1080    let skin = if animates {
1081        // Each track mounts keyed by its own generation: an interrupted flip
1082        // re-keys only the track that turned around, and re-renders while it
1083        // runs paint the same delta against the same shared skin.
1084        // `with_animation` keeps its own frames coming until it settles, and
1085        // every painted value is written back into the tween, which is where
1086        // a later generation resumes from.
1087        let transform_ms = timing.transform_ms;
1088        let transform = timing.transform;
1089        let scale_from = scale_tween.from();
1090        let scale_to = scale_tween.target();
1091        let scale_value = scale_tween.value();
1092        let transform_id = element_id::indexed(&id, "press-transform", scale_tween.generation());
1093        let geometry = move |delta: f32| {
1094            let s = if delta >= 1.0 {
1095                scale_to
1096            } else {
1097                scale_from + (scale_to - scale_from) * delta
1098            };
1099            scale_value.set(s);
1100            s
1101        };
1102        match color_tween {
1103            Some(tween) => {
1104                let (color_from, color_to) = (tween.from(), tween.target());
1105                let color_value = tween.value();
1106                let (bg_ms, bg_curve) = timing.background.unwrap_or((100, Curve::Out));
1107                let animated = el
1108                    .with_animation(
1109                        element_id::indexed(&id, "press-bg", tween.generation()),
1110                        gpui::Animation::new(Duration::from_millis(bg_ms))
1111                            .with_easing(move |t| bg_curve.at(t)),
1112                        move |skin, delta| {
1113                            let color = if delta >= 1.0 {
1114                                color_to
1115                            } else {
1116                                herogpui_core::mix_oklab(color_from, color_to, delta)
1117                            };
1118                            color_value.set(color);
1119                            skin.bg(color)
1120                        },
1121                    )
1122                    .with_animation(
1123                        transform_id,
1124                        transform_animation(transform_ms, transform),
1125                        move |el, delta| {
1126                            let s = geometry(delta);
1127                            el.map_element(|skin| skin_at_scale(skin, s, &b, &resting_corners))
1128                        },
1129                    );
1130                animated.into_any_element()
1131            }
1132            None => {
1133                let animated = el.with_animation(
1134                    transform_id,
1135                    transform_animation(transform_ms, transform),
1136                    move |skin, delta| {
1137                        let s = geometry(delta);
1138                        skin_at_scale(skin, s, &b, &resting_corners)
1139                    },
1140                );
1141                animated.into_any_element()
1142            }
1143        }
1144    } else {
1145        // Settled: the tween's painted value is the state. At rest the skin
1146        // stays exactly as the caller built it; pressed it takes the final
1147        // geometry and fill with no animation mounted.
1148        let s = scale_tween.value().get();
1149        let mut skin = el;
1150        #[allow(clippy::float_cmp)] // untouched is exactly the identity scale
1151        if s != 1.0 {
1152            skin = skin_at_scale(skin, s, &b, &resting_corners);
1153            // The pressed fill only exists when the caller named endpoints; a
1154            // transform-only press (CloseButton) leaves the skin's own
1155            // background — its stylesheet changes no colour on `:active`.
1156            if pressed && endpoints.is_some() {
1157                skin = skin.bg(pressed_bg);
1158            }
1159        }
1160        skin.into_any_element()
1161    };
1162
1163    let slot = press_slot(skin, id, b, &slot_corners, cx);
1164    match interaction {
1165        Some(_) => slot,
1166        None => crate::util::track_interaction(
1167            slot,
1168            tracked
1169                .as_ref()
1170                .expect("a press ramp without an interaction slot creates one"),
1171        ),
1172    }
1173}
1174
1175/// The `transform` track's pinned duration and curve, as an [`gpui::Animation`].
1176fn transform_animation(ms: u64, curve: Curve) -> gpui::Animation {
1177    gpui::Animation::new(Duration::from_millis(ms)).with_easing(move |t| curve.at(t))
1178}
1179
1180/// `[data-exiting]` duration. Every overlay in v3 leaves in `duration-100`.
1181pub const EXITING_MS: u64 = 100;
1182
1183/// Everything an entering overlay grows from `ZOOM_FROM` to full size.
1184///
1185/// Every field is optional because the overlays differ in what they know about
1186/// themselves: a `Modal` has a width, a `Popover` only its padding, type and
1187/// corner radius. Whatever is supplied is scaled; whatever is not keeps its
1188/// size, so a panel sized by its content grows by its chrome alone.
1189#[derive(Clone, Copy, Debug, Default)]
1190pub struct ZoomBox {
1191    /// Width, if known.
1192    pub width: Option<gpui::Pixels>,
1193    /// Height, if known.
1194    pub height: Option<gpui::Pixels>,
1195    /// Horizontal padding, if known.
1196    pub padding_x: Option<gpui::Pixels>,
1197    /// Vertical padding, if known.
1198    pub padding_y: Option<gpui::Pixels>,
1199    /// Top padding, if known; applied after `padding_y`.
1200    pub padding_top: Option<gpui::Pixels>,
1201    /// Bottom padding, if known; applied after `padding_y`.
1202    pub padding_bottom: Option<gpui::Pixels>,
1203    /// Gap between children, if known.
1204    pub gap: Option<gpui::Pixels>,
1205    /// Font size, if known.
1206    pub text_size: Option<gpui::Pixels>,
1207    /// Line height, if known.
1208    pub line_height: Option<gpui::Pixels>,
1209    /// Corner radius, if known.
1210    pub radius: Option<gpui::Pixels>,
1211    /// Optional placement-relative entry offset. A positive value starts on
1212    /// the corresponding physical side and eases back to zero with the panel.
1213    pub slide_x: Option<gpui::Pixels>,
1214    /// Vertical counterpart of `slide_x`.
1215    pub slide_y: Option<gpui::Pixels>,
1216}
1217
1218impl ZoomBox {
1219    /// The box for a floating panel: its padding and corner radius, with no
1220    /// fixed extent.
1221    pub fn panel(padding_y: gpui::Pixels, radius: gpui::Pixels) -> Self {
1222        Self {
1223            padding_y: Some(padding_y),
1224            radius: Some(radius),
1225            ..Default::default()
1226        }
1227    }
1228
1229    /// Sets the horizontal padding.
1230    pub fn padding_x(mut self, padding_x: gpui::Pixels) -> Self {
1231        self.padding_x = Some(padding_x);
1232        self
1233    }
1234
1235    /// Adds a fixed width, for a panel that has one.
1236    pub fn sized(mut self, width: gpui::Pixels) -> Self {
1237        self.width = Some(width);
1238        self
1239    }
1240
1241    /// Adds the panel's type size, which grows with the box.
1242    pub fn text(mut self, text_size: gpui::Pixels) -> Self {
1243        self.text_size = Some(text_size);
1244        self
1245    }
1246}
1247
1248fn lerp(value: gpui::Pixels, factor: f32) -> gpui::Pixels {
1249    px(f32::from(value) * factor)
1250}
1251
1252/// v3's `[data-entering]` in full: `zoom-in-90 fade-in-0 duration-200`.
1253///
1254/// gpui 0.2.2 has no transform for a div, so the zoom is reproduced the same
1255/// way [`pressed`] reproduces `scale(0.97)` — by growing the metrics the panel
1256/// is made of, including its **type size**, which gpui accepts fractionally.
1257/// What a real `scale()` would also carry, and this does not, is a child whose
1258/// size the caller fixed: an icon or an image inside the panel keeps its size
1259/// while the chrome around it grows.
1260///
1261/// Returns `el` untouched under reduced motion.
1262pub fn entering_zoom<E>(
1263    el: E,
1264    id: impl Into<ElementId>,
1265    b: ZoomBox,
1266    m: Motion,
1267    cx: &App,
1268) -> AnyElement
1269where
1270    E: IntoElement + Styled + 'static,
1271{
1272    if ActiveTheme::reduce_motion(cx) {
1273        return el.into_any_element();
1274    }
1275
1276    el.with_animation(
1277        id.into(),
1278        gpui::Animation::new(Duration::from_millis(m.ms)).with_easing(move |t| m.curve.at(t)),
1279        move |el, delta| {
1280            // `scale` may be above 1.0: a modal panel settles down from 105%.
1281            let f = m.scale + (1.0 - m.scale) * delta;
1282            let mut el = el.opacity(delta);
1283            if let Some(w) = b.width {
1284                el = el.w(lerp(w, f));
1285            }
1286            if let Some(h) = b.height {
1287                el = el.h(lerp(h, f));
1288            }
1289            if let Some(p) = b.padding_x {
1290                el = el.px(lerp(p, f));
1291            }
1292            if let Some(p) = b.padding_y {
1293                el = el.py(lerp(p, f));
1294            }
1295            if let Some(p) = b.padding_top {
1296                el = el.pt(lerp(p, f));
1297            }
1298            if let Some(p) = b.padding_bottom {
1299                el = el.pb(lerp(p, f));
1300            }
1301            if let Some(g) = b.gap {
1302                el = el.gap(lerp(g, f));
1303            }
1304            if let Some(t) = b.text_size {
1305                el = el.text_size(lerp(t, f));
1306            }
1307            if let Some(l) = b.line_height {
1308                el = el.line_height(lerp(l, f));
1309            }
1310            if let Some(r) = b.radius {
1311                el = el.rounded(lerp(r, f));
1312            }
1313            if b.slide_x.is_some() || b.slide_y.is_some() {
1314                el = el.relative();
1315            }
1316            if let Some(x) = b.slide_x {
1317                el = el.left(lerp(x, 1.0 - delta));
1318            }
1319            if let Some(y) = b.slide_y {
1320                el = el.top(lerp(y, 1.0 - delta));
1321            }
1322            el
1323        },
1324    )
1325    .into_any_element()
1326}
1327
1328/// v3's `[data-exiting]`: `animate-out zoom-out-95 fade-out duration-150`.
1329///
1330/// The mirror of [`entering_zoom`] — the panel shrinks to `ZOOM_TO` and fades
1331/// as it leaves. It only has anything to animate because the component keeps
1332/// rendering for [`EXITING_MS`] after `isOpen` goes false; see
1333/// [`crate::util::overlay_phase`].
1334///
1335/// Returns `el` untouched under reduced motion, which is also what makes the
1336/// panel disappear immediately: with nothing to animate, the extra frames are
1337/// invisible.
1338pub fn exiting<E>(el: E, id: impl Into<ElementId>, b: ZoomBox, m: Motion, cx: &App) -> AnyElement
1339where
1340    E: IntoElement + Styled + 'static,
1341{
1342    if ActiveTheme::reduce_motion(cx) {
1343        return el.into_any_element();
1344    }
1345
1346    el.with_animation(
1347        id.into(),
1348        gpui::Animation::new(Duration::from_millis(m.ms)).with_easing(move |t| m.curve.at(t)),
1349        move |el, delta| {
1350            // `delta` runs 0 -> 1 over the exit, so the scale runs 1 -> m.scale.
1351            let f = 1.0 - (1.0 - m.scale) * delta;
1352            let mut el = el.opacity(1.0 - delta);
1353            if let Some(w) = b.width {
1354                el = el.w(lerp(w, f));
1355            }
1356            if let Some(h) = b.height {
1357                el = el.h(lerp(h, f));
1358            }
1359            if let Some(p) = b.padding_x {
1360                el = el.px(lerp(p, f));
1361            }
1362            if let Some(p) = b.padding_y {
1363                el = el.py(lerp(p, f));
1364            }
1365            if let Some(p) = b.padding_top {
1366                el = el.pt(lerp(p, f));
1367            }
1368            if let Some(p) = b.padding_bottom {
1369                el = el.pb(lerp(p, f));
1370            }
1371            if let Some(g) = b.gap {
1372                el = el.gap(lerp(g, f));
1373            }
1374            if let Some(t) = b.text_size {
1375                el = el.text_size(lerp(t, f));
1376            }
1377            if let Some(l) = b.line_height {
1378                el = el.line_height(lerp(l, f));
1379            }
1380            if let Some(r) = b.radius {
1381                el = el.rounded(lerp(r, f));
1382            }
1383            el
1384        },
1385    )
1386    .into_any_element()
1387}
1388
1389/// v3's `transition-colors`: the background eases between two colours instead
1390/// of switching on the frame the pointer arrives.
1391///
1392/// gpui has no property transitions — `hover` swaps the style outright — so the
1393/// element keeps its own hover flag and a generation counter, and each change
1394/// starts a fresh animation that interpolates in OKLab. `colors` is the
1395/// `(idle, hovered)` pair — the two ends; everything else about the element is
1396/// untouched.
1397///
1398/// `interaction` is the hover source when the element already records one: a
1399/// `content` closure's `isHovered` needs the same enter/leave that drives this
1400/// fade, and gpui allows exactly one `on_hover` per element, so when the caller
1401/// wired `util::track_interaction` the fade reads the slot it keeps rather than
1402/// binding a second listener. `None` leaves the fade owning its own listener.
1403/// Either way exactly one `on_hover` is bound.
1404///
1405/// **The animated colour lives on an absolutely-positioned child fill, not the
1406/// element itself.** gpui keys element state by the *full* element-id path, and
1407/// `with_animation` restarts by changing its id — if the animation wrapped the
1408/// element, the id change would shift the element's path and reset every
1409/// listener latch on it, so hover-out was silently lost the moment the fade
1410/// wrapper appeared (and the button stayed in its hover colour). Here the
1411/// element keeps a constant id — a stable path, working hover listeners — and
1412/// only the fill's animation id moves; the fill has no listeners or hitbox to
1413/// lose. `round_corners` shapes the fill like the element itself (a group
1414/// member's corners are partial).
1415///
1416/// Returns the element with a plain `hover` swap under reduced motion, so the
1417/// state is still visible without motion.
1418#[allow(clippy::too_many_arguments)] // one parameter per endpoint the fade owns
1419pub fn hover_fade(
1420    el: gpui::Stateful<gpui::Div>,
1421    id: impl Into<ElementId>,
1422    colors: (gpui::Hsla, gpui::Hsla),
1423    interaction: Option<&crate::util::Interaction>,
1424    hover_border: Option<gpui::Hsla>,
1425    round_corners: impl Fn(gpui::Div) -> gpui::Div,
1426    window: &mut Window,
1427    cx: &mut App,
1428) -> gpui::Stateful<gpui::Div> {
1429    hover_fade_with_duration_and_easing(
1430        el,
1431        id,
1432        colors,
1433        interaction,
1434        hover_border,
1435        round_corners,
1436        None,
1437        HoverFadeEasing::EaseOut,
1438        window,
1439        cx,
1440    )
1441}
1442
1443/// `hover_fade` with an owner-specific duration. Most HeroUI controls read
1444/// the theme's shared hover token; components whose stylesheet declares a
1445/// different transition (Accordion's 150ms trigger) use this seam so the
1446/// parity implementation does not silently inherit the button's 100ms.
1447#[allow(clippy::too_many_arguments)] // the seam adds one parameter to the stock fade
1448pub(crate) fn hover_fade_with_duration(
1449    el: gpui::Stateful<gpui::Div>,
1450    id: impl Into<ElementId>,
1451    colors: (gpui::Hsla, gpui::Hsla),
1452    interaction: Option<&crate::util::Interaction>,
1453    hover_border: Option<gpui::Hsla>,
1454    round_corners: impl Fn(gpui::Div) -> gpui::Div,
1455    duration_override_ms: Option<u64>,
1456    window: &mut Window,
1457    cx: &mut App,
1458) -> gpui::Stateful<gpui::Div> {
1459    hover_fade_with_duration_and_easing(
1460        el,
1461        id,
1462        colors,
1463        interaction,
1464        hover_border,
1465        round_corners,
1466        duration_override_ms,
1467        HoverFadeEasing::EaseOut,
1468        window,
1469        cx,
1470    )
1471}
1472
1473/// `hover_fade_with_duration` with the component's named HeroUI easing curve.
1474/// The stock helper uses `--ease-out`; NumberField's group is one of the v3
1475/// surfaces that explicitly names `--ease-smooth` for its background transition.
1476#[allow(clippy::too_many_arguments)] // the seam adds one parameter to the stock fade
1477pub(crate) fn hover_fade_with_duration_and_easing(
1478    el: gpui::Stateful<gpui::Div>,
1479    id: impl Into<ElementId>,
1480    colors: (gpui::Hsla, gpui::Hsla),
1481    interaction: Option<&crate::util::Interaction>,
1482    hover_border: Option<gpui::Hsla>,
1483    round_corners: impl Fn(gpui::Div) -> gpui::Div,
1484    duration_override_ms: Option<u64>,
1485    easing: HoverFadeEasing,
1486    window: &mut Window,
1487    cx: &mut App,
1488) -> gpui::Stateful<gpui::Div> {
1489    hover_fade_with_duration_and_easing_suppressed(
1490        el,
1491        id,
1492        colors,
1493        interaction,
1494        hover_border,
1495        false,
1496        round_corners,
1497        duration_override_ms,
1498        easing,
1499        window,
1500        cx,
1501    )
1502}
1503
1504/// `hover_fade_with_duration_and_easing` with a render-time suppression flag.
1505/// Composite triggers use this when a nested affordance owns the pointer: the
1506/// parent fill eases back to its resting endpoint while the nested control is
1507/// hovered, then resumes the normal target when the pointer leaves it.
1508#[allow(clippy::too_many_arguments)] // the seam adds one parameter to the stock fade
1509pub(crate) fn hover_fade_with_duration_and_easing_suppressed(
1510    el: gpui::Stateful<gpui::Div>,
1511    id: impl Into<ElementId>,
1512    colors: (gpui::Hsla, gpui::Hsla),
1513    interaction: Option<&crate::util::Interaction>,
1514    hover_border: Option<gpui::Hsla>,
1515    suppressed: bool,
1516    round_corners: impl Fn(gpui::Div) -> gpui::Div,
1517    duration_override_ms: Option<u64>,
1518    easing: HoverFadeEasing,
1519    window: &mut Window,
1520    cx: &mut App,
1521) -> gpui::Stateful<gpui::Div> {
1522    let (idle, hovered) = colors;
1523    let id = id.into();
1524    let duration = match duration_override_ms {
1525        None => hover_fade_duration(cx),
1526        Some(ms) => hover_fade_duration_with_override(cx, Some(ms)),
1527    };
1528    let state = window.use_keyed_state(id.clone(), cx, |_, _| HoverFade::default());
1529    let mut current = *state.read(cx);
1530
1531    // One hover listener for the whole element. `util::track_interaction` owns
1532    // `on_hover` when the interaction slot exists; the fade then reads the
1533    // hover bit it keeps and only has to notice when the bit *changed*, which
1534    // is the same generation bump the listener used to perform itself.
1535    // `relative()` makes the element the containing block the fill stretches
1536    // across; the resting colour is the element's own, and the fill overlays it
1537    // between generations.
1538    let mut el = el;
1539    if duration.is_some() {
1540        el = el.relative();
1541    }
1542    el = el.bg(if current.hovered { hovered } else { idle });
1543    el = match interaction {
1544        Some(slot) => {
1545            debug_assert!(
1546                hover_border.is_none(),
1547                "the interaction slot's owner keeps its own gpui hover style; \
1548                 `hover_border` is only read by the listener-owned branch"
1549            );
1550            let hovered_now = slot.read(cx).0 && !suppressed;
1551            if hovered_now != current.hovered {
1552                current.hovered = hovered_now;
1553                current.generation = current.generation.wrapping_add(1);
1554                // The refresh that repaints this frame was already requested by
1555                // `track_interaction`'s handler; the new animation id starts
1556                // the transition here, and `with_animation` keeps its own
1557                // frames coming until it settles.
1558                state.update(cx, |s, _| *s = current);
1559            }
1560            el
1561        }
1562        None => {
1563            // This branch owns the element's `on_hover`, so it also owns its
1564            // gpui hover style -- the listener below records the pointer
1565            // without notifying, and this is what makes gpui notify on a real
1566            // crossing. See the hover-slot note in the module documentation.
1567            // `hover_border` carries the caller's immediate border endpoint;
1568            // the fill itself is the interpolated child, never a style swap.
1569            let el = el.hover(move |style| match hover_border {
1570                Some(color) => style.border_color(color),
1571                None => style,
1572            });
1573            let held = state.clone();
1574            el.on_hover(move |over: &bool, _, cx| {
1575                let over = *over && !suppressed;
1576                held.update(cx, |s, _| {
1577                    if s.hovered != over {
1578                        s.hovered = over;
1579                        // A new generation gives the fill's animation a new id,
1580                        // which is what restarts it mid-flight when the pointer
1581                        // turns around.
1582                        s.generation = s.generation.wrapping_add(1);
1583                        // Recording the pointer must not itself ask for a
1584                        // frame -- see the hover-slot note in the module
1585                        // documentation above.
1586                    }
1587                });
1588            })
1589        }
1590    };
1591
1592    // Reduced motion and a zero-duration theme still need an immediate hover
1593    // endpoint. Use the same listener/state path as the animated case instead
1594    // of installing a second `.hover` refinement on a caller that already owns
1595    // one for its border. GPUI deliberately rejects duplicate hover styles.
1596    let Some(duration) = duration else {
1597        return el;
1598    };
1599
1600    // Generation 0 is the first render: the resting colour on the element IS
1601    // the state, and there is nothing to ease from yet.
1602    if current.generation == 0 {
1603        return el;
1604    }
1605    let (from, to) = if current.hovered {
1606        (idle, hovered)
1607    } else {
1608        (hovered, idle)
1609    };
1610    // The fill sits under everything the caller adds afterwards: it exactly
1611    // covers the rounded element and carries only the colour transition, so the
1612    // element's own state — and its hover listeners — survive the id change.
1613    el.child(
1614        round_corners(gpui::div().absolute().inset_0()).with_animation(
1615            element_id::indexed(&id, "fade", current.generation),
1616            gpui::Animation::new(duration).with_easing(move |t| easing.at(t)),
1617            move |fill, delta| fill.bg(herogpui_core::mix_oklab(from, to, delta)),
1618        ),
1619    )
1620}
1621
1622#[derive(Clone, Copy)]
1623pub(crate) enum HoverFadeEasing {
1624    EaseOut,
1625    EaseSmooth,
1626}
1627
1628impl HoverFadeEasing {
1629    fn at(self, t: f32) -> f32 {
1630        match self {
1631            Self::EaseOut => ease_out()(t),
1632            Self::EaseSmooth => ease_smooth()(t),
1633        }
1634    }
1635}
1636
1637/// The duration [`hover_fade`] eases over, or `None` when it must resolve
1638/// immediately: reduced motion, or a theme that set `hover_fade_ms` to zero.
1639fn hover_fade_duration(cx: &App) -> Option<Duration> {
1640    hover_fade_duration_with_override(cx, None)
1641}
1642
1643fn hover_fade_duration_with_override(
1644    cx: &App,
1645    duration_override_ms: Option<u64>,
1646) -> Option<Duration> {
1647    if ActiveTheme::reduce_motion(cx) {
1648        return None;
1649    }
1650    // A theme value of zero is the public global opt-out and must still win
1651    // over a component's stylesheet-specific default.
1652    let configured = cx.layout().hover_fade_ms;
1653    let ms = if configured == 0 {
1654        0
1655    } else {
1656        duration_override_ms.unwrap_or(configured)
1657    };
1658    (ms > 0).then(|| Duration::from_millis(ms))
1659}
1660
1661/// The hover flag and restart counter [`hover_fade`] keeps per element.
1662#[derive(Clone, Copy, Debug, Default)]
1663struct HoverFade {
1664    hovered: bool,
1665    generation: usize,
1666}
1667
1668// ---------------------------------------------------------------------------
1669// Field chrome — the shell transition the field-family sheets share
1670// ---------------------------------------------------------------------------
1671
1672/// How long a v3 field shell takes to change its fill and border colour, and
1673/// on which curve. `.input-otp__slot` (lines 28-32) and `.number-field__group`
1674/// (lines 39-43) declare the identical block — `background-color 150ms
1675/// var(--ease-smooth), border-color 150ms var(--ease-smooth), box-shadow
1676/// 150ms var(--ease-out)` with `motion-reduce:transition-none` after it — so
1677/// one constant quartet keeps both ports on the pinned timings.
1678pub(crate) const FIELD_CHROME_COLOR_MS: u64 = 150;
1679pub(crate) const FIELD_CHROME_COLOR_CURVE: Curve = Curve::Smooth;
1680pub(crate) const FIELD_CHROME_SHADOW_MS: u64 = 150;
1681pub(crate) const FIELD_CHROME_SHADOW_CURVE: Curve = Curve::Out;
1682
1683/// A state ring as the two shadows [`focus_ring_shadows`] paint: the ring
1684/// itself and — for a theme with a `ring-offset` width — the
1685/// background-coloured ring carving the gap.
1686///
1687/// [`focus_ring_shadows`]: crate::util::focus_ring_shadows
1688#[derive(Clone, Copy, Debug, PartialEq)]
1689pub(crate) struct FieldRing {
1690    /// The ring's colour and spread.
1691    pub ring: (gpui::Hsla, f32),
1692    /// The offset-gap ring's colour and spread; spread zero when the theme
1693    /// sets no offset.
1694    pub gap: (gpui::Hsla, f32),
1695}
1696
1697impl FieldRing {
1698    /// The no-ring endpoint: zero spreads, transparent colours — the shape CSS
1699    /// interpolates a missing box shadow from, and what `mix_oklab` treats as
1700    /// "the other colour's hue at reduced alpha".
1701    pub const NONE: Self = Self {
1702        ring: (gpui::hsla(0., 0., 0., 0.), 0.0),
1703        gap: (gpui::hsla(0., 0., 0., 0.), 0.0),
1704    };
1705
1706    /// One ring with no offset gap — the invalid danger ring's shape.
1707    pub(crate) fn solid(color: gpui::Hsla, spread: f32) -> Self {
1708        Self {
1709            ring: (color, spread),
1710            gap: Self::NONE.ring,
1711        }
1712    }
1713
1714    /// The same shape with every colour at zero alpha and every spread at
1715    /// zero: where a ring fades out to.
1716    fn faded(self) -> Self {
1717        Self {
1718            ring: (herogpui_core::with_alpha(self.ring.0, 0.0), 0.0),
1719            gap: (herogpui_core::with_alpha(self.gap.0, 0.0), 0.0),
1720        }
1721    }
1722
1723    /// The shadows one frame of this ring paints — the settled form of
1724    /// `focus_ring_shadows`: one-pixel blur (a zero blur integrates over
1725    /// nothing in gpui's shadow shader), largest first.
1726    fn shadows(self) -> Vec<gpui::BoxShadow> {
1727        let mut shadows = vec![ring_shadow(self.ring)];
1728        if self.gap.1 > 0.0 {
1729            shadows.push(ring_shadow(self.gap));
1730        }
1731        shadows
1732    }
1733
1734    /// CSS box-shadow interpolation: each shadow's colour and spread ease
1735    /// between the endpoints, `mix_oklab` being the pinned colour math.
1736    fn mix(self, to: Self, delta: f32) -> Self {
1737        let lerp = |a: f32, b: f32| a + (b - a) * delta;
1738        Self {
1739            ring: (
1740                herogpui_core::mix_oklab(self.ring.0, to.ring.0, delta),
1741                lerp(self.ring.1, to.ring.1),
1742            ),
1743            gap: (
1744                herogpui_core::mix_oklab(self.gap.0, to.gap.0, delta),
1745                lerp(self.gap.1, to.gap.1),
1746            ),
1747        }
1748    }
1749}
1750
1751fn ring_shadow((color, spread): (gpui::Hsla, f32)) -> gpui::BoxShadow {
1752    gpui::BoxShadow {
1753        color,
1754        offset: gpui::point(px(0.), px(0.)),
1755        blur_radius: px(1.),
1756        spread_radius: px(spread),
1757        inset: false,
1758    }
1759}
1760
1761/// The shared keyboard focus ring — `status-focused-field`, `ring-2
1762/// ring-focus` with no offset — as a chrome-ramp endpoint: the shape
1763/// [`focus_ring_shadows`] paints, including the offset-gap ring for a theme
1764/// that sets a `ring-offset` width.
1765///
1766/// [`focus_ring_shadows`]: crate::util::focus_ring_shadows
1767pub(crate) fn focus_ring_endpoint(cx: &App) -> FieldRing {
1768    let colors = cx.colors();
1769    let gap = cx.layout().ring_offset_width;
1770    let mut ring = FieldRing::solid(colors.focus, 2.0 + f32::from(gap));
1771    if gap > px(0.) {
1772        ring.gap = (colors.background, f32::from(gap));
1773    }
1774    ring
1775}
1776
1777/// The invalid danger ring — the shared chrome helper's focused-invalid
1778/// treatment, `status-invalid-field`'s 2px ring — as a chrome-ramp endpoint.
1779pub(crate) fn danger_ring_endpoint(cx: &App) -> FieldRing {
1780    FieldRing::solid(cx.colors().danger.color, 2.0)
1781}
1782
1783/// One resolved field-shell chrome state: the fill, the border colour, the
1784/// border-box width and the state ring. This is the shape the field-family
1785/// ramp interpolates — the endpoints a caller resolves from its variant and
1786/// flags, exactly what [`apply_field_chrome`] would paint for the same state.
1787///
1788/// [`apply_field_chrome`]: crate::util::apply_field_chrome
1789#[derive(Clone, Copy, Debug, PartialEq)]
1790pub(crate) struct FieldChrome {
1791    /// The state fill: `--field-background`, `--field-focus` or a hover mix.
1792    pub bg: gpui::Hsla,
1793    /// The border colour. The default theme's `--field-border` is
1794    /// transparent, which is what makes the port's invalid outline fade in
1795    /// and out rather than crossfade.
1796    pub border: gpui::Hsla,
1797    /// The painted border-box width. Widths are geometry and the pinned
1798    /// transition names only the colours, so this snaps while they ease.
1799    pub border_width: gpui::Pixels,
1800    /// The keyboard focus ring or invalid danger ring, or `None` — which
1801    /// still gives the `box-shadow` track an endpoint to ease through.
1802    pub ring: Option<FieldRing>,
1803}
1804
1805/// Interpolates a field shell's chrome the way the pinned `transition` block
1806/// runs it, instead of snapping each state's endpoints in one frame.
1807///
1808/// The caller paints the shell's settled chrome through the shared helpers —
1809/// `apply_field_chrome` or `with_focus_ring`, the same paint Input and
1810/// TextArea take — and resolves `idle`, that chrome as endpoints, plus
1811/// `hovered`, the chrome while the pointer rests on the shell. Hover is the
1812/// one bit a render cannot ask for, so this arms the element's hover listener
1813/// against a keyed slot, exactly like [`hover_fade`]. A shell whose tracks are
1814/// all still in their first generation has never transitioned: its painted
1815/// chrome is the state and nothing mounts — CSS transitions do not run on
1816/// load. Past that first flip the interpolating fill, border and ring mount
1817/// as listener-free absolutely-positioned children that own the shell's
1818/// border and state ring for good, painting their settled endpoints whenever
1819/// nothing is in flight.
1820///
1821/// The technique is the press ramp's: three [`Tween`]s keyed on the shell's
1822/// own id keep the last target, the generation and the frame actually on
1823/// screen, so an interrupted flip resumes from the painted frame, and the
1824/// animation ids the layers change per generation never touch the element
1825/// that owns state — its id, hit-testing, focus and listeners. Reduced motion
1826/// snaps all three tracks to their endpoints, matching
1827/// `motion-reduce:transition-none`, which removes the timing but keeps the
1828/// state's property values.
1829///
1830/// `base_shadows` is the shell's constant shadow list (`--field-shadow`); the
1831/// state ring rides on top of it because `shadow()` replaces rather than
1832/// adds. `ring_escapes_clip` mounts the ring layer deferred, for a shell
1833/// whose own `overflow-hidden` would clip an outset ring painted by a child
1834/// to the shell's box — the layer then paints after the subtree, the same
1835/// way every floating surface does.
1836///
1837/// Returns the refined shell and whether the ramp is painting the state ring
1838/// this frame. It is not, on a shell whose ring has never been part of a
1839/// tracked state -- a pristine shell, or one that never takes a ring -- and
1840/// then whatever ring the caller painted is the state. A caller whose ring is
1841/// an overlay on a wrapper reads the flag to decide whether to paint one.
1842#[allow(clippy::too_many_arguments)] // one parameter per chrome track, ring and shell option
1843pub(crate) fn field_chrome_ramp<E>(
1844    mut el: E,
1845    id: &ElementId,
1846    idle: FieldChrome,
1847    hovered: Option<FieldChrome>,
1848    base_shadows: Vec<gpui::BoxShadow>,
1849    radius: gpui::Pixels,
1850    ring_escapes_clip: bool,
1851    window: &mut Window,
1852    cx: &mut App,
1853) -> (E, bool)
1854where
1855    E: InteractiveElement + Styled + ParentElement,
1856{
1857    // Hover is a question about the last frame's pointer, so it lives in a
1858    // keyed slot the handler writes and this render reads.
1859    let slot = window.use_keyed_state(element_id::scoped(id, "chrome-hover"), cx, |_, _| false);
1860    let is_hovered = *slot.read(cx) && hovered.is_some();
1861    // This helper owns the element's `on_hover`, so it owns its gpui hover
1862    // style too. The refinement is deliberately empty: every chrome endpoint
1863    // here is interpolated by the tweens below, and a style swap would snap
1864    // the colour gpui is meant only to notify about. Setting it is what makes
1865    // a real pointer crossing repaint, which is why the listener records
1866    // without notifying. See the hover-slot note in the module documentation.
1867    el = el.hover(|style| style);
1868    el.interactivity().on_hover({
1869        move |over: &bool, _, cx| {
1870            slot.update(cx, |hovered, _| {
1871                if *hovered != *over {
1872                    *hovered = *over;
1873                    // Recording the pointer must not itself ask for a frame
1874                    // -- see the hover-slot note in the module documentation
1875                    // above.
1876                }
1877            });
1878        }
1879    });
1880
1881    let (target, other_ring) = if is_hovered {
1882        (hovered.unwrap_or(idle), idle.ring)
1883    } else {
1884        (idle, hovered.and_then(|hovered| hovered.ring))
1885    };
1886    // Whether the state ring is one of this shell's tracked endpoints. When it
1887    // is, the ring layer owns the shell's shadow list for as long as the
1888    // layers are mounted; when it never is, whatever ring the caller painted
1889    // stays on the shell untouched.
1890    let ring_tracked = idle.ring.is_some() || target.ring.is_some();
1891    let reduce = ActiveTheme::reduce_motion(cx);
1892    let mut bg = Tween::keyed(id, "chrome-bg", target.bg, window, cx);
1893    let mut border = Tween::keyed(id, "chrome-border", target.border, window, cx);
1894    // A missing ring target still eases: it fades from the other endpoint's
1895    // shape — transparent, spread zero — the way CSS interpolates an absent
1896    // box shadow.
1897    let ring_target = target
1898        .ring
1899        .or_else(|| other_ring.map(|ring| ring.faded()))
1900        .unwrap_or(FieldRing::NONE);
1901    let mut ring = Tween::keyed(id, "chrome-ring", ring_target, window, cx);
1902    bg.snap_if_reduced(reduce);
1903    border.snap_if_reduced(reduce);
1904    ring.snap_if_reduced(reduce);
1905
1906    // Pristine: every track still sits in its first generation, so nothing has
1907    // ever transitioned and the chrome the caller painted through the shared
1908    // helpers IS the state — CSS transitions do not run on load. Nothing
1909    // mounts, and the shell keeps every property it was given.
1910    let pristine = bg.generation() == 0 && border.generation() == 0 && ring.generation() == 0;
1911    if pristine {
1912        return (el, false);
1913    }
1914
1915    // The containing block the layers stretch across. The shell's own paint
1916    // stays underneath, but the two properties the layers now own must stop
1917    // being cast by the shell: its border (the border layer repaints it, and
1918    // a semi-transparent frame over an instant one would read too dark) and —
1919    // when the ring is tracked — its shadow list, which drops to the field's
1920    // constant base while the ring layer carries the state ring.
1921    el = el.relative().border(px(0.));
1922    if ring_tracked {
1923        el = if base_shadows.is_empty() {
1924            el
1925        } else {
1926            el.shadow(base_shadows)
1927        };
1928    }
1929
1930    let (bg_from, bg_to) = (bg.from(), bg.target());
1931    let bg_value = bg.value();
1932    let (border_from, border_to) = (border.from(), border.target());
1933    let border_value = border.value();
1934    // Border widths are geometry: the layer paints the wider endpoint's box
1935    // so a fading outline keeps its shape while its colour eases out.
1936    let border_width = idle.border_width.max(target.border_width);
1937    let (ring_from, ring_to) = (ring.from(), ring.target());
1938    let ring_value = ring.value();
1939
1940    let fill = if bg.animates(reduce) {
1941        bg_value.set(bg_from);
1942        gpui::div()
1943            .absolute()
1944            .inset_0()
1945            .rounded(radius)
1946            .with_animation(
1947                element_id::indexed(id, "chrome-bg", bg.generation()),
1948                gpui::Animation::new(Duration::from_millis(FIELD_CHROME_COLOR_MS))
1949                    .with_easing(|t| FIELD_CHROME_COLOR_CURVE.at(t)),
1950                move |fill, delta| {
1951                    let next = if delta >= 1.0 {
1952                        bg_to
1953                    } else {
1954                        herogpui_core::mix_oklab(bg_from, bg_to, delta)
1955                    };
1956                    bg_value.set(next);
1957                    fill.bg(next)
1958                },
1959            )
1960            .into_any_element()
1961    } else {
1962        bg.settle();
1963        gpui::div()
1964            .absolute()
1965            .inset_0()
1966            .rounded(radius)
1967            .bg(bg_to)
1968            .into_any_element()
1969    };
1970    let border_layer = if border.animates(reduce) {
1971        border_value.set(border_from);
1972        gpui::div()
1973            .absolute()
1974            .inset_0()
1975            .rounded(radius)
1976            .with_animation(
1977                element_id::indexed(id, "chrome-border", border.generation()),
1978                gpui::Animation::new(Duration::from_millis(FIELD_CHROME_COLOR_MS))
1979                    .with_easing(|t| FIELD_CHROME_COLOR_CURVE.at(t)),
1980                move |layer, delta| {
1981                    let next = if delta >= 1.0 {
1982                        border_to
1983                    } else {
1984                        herogpui_core::mix_oklab(border_from, border_to, delta)
1985                    };
1986                    border_value.set(next);
1987                    layer.border(border_width).border_color(next)
1988                },
1989            )
1990            .into_any_element()
1991    } else {
1992        border.settle();
1993        gpui::div()
1994            .absolute()
1995            .inset_0()
1996            .rounded(radius)
1997            .border(border_width)
1998            .border_color(border_to)
1999            .into_any_element()
2000    };
2001    let ring_layer: Option<AnyElement> = if ring.generation() == 0 {
2002        // The ring has never been part of this shell's state: the caller's
2003        // painted ring (if any) is the state.
2004        None
2005    } else if ring.animates(reduce) {
2006        ring_value.set(ring_from);
2007        Some(
2008            gpui::div()
2009                .absolute()
2010                .inset_0()
2011                .rounded(radius)
2012                .with_animation(
2013                    element_id::indexed(id, "chrome-ring", ring.generation()),
2014                    gpui::Animation::new(Duration::from_millis(FIELD_CHROME_SHADOW_MS))
2015                        .with_easing(|t| FIELD_CHROME_SHADOW_CURVE.at(t)),
2016                    move |layer, delta| {
2017                        let next = if delta >= 1.0 {
2018                            ring_to
2019                        } else {
2020                            ring_from.mix(ring_to, delta)
2021                        };
2022                        ring_value.set(next);
2023                        layer.shadow(next.shadows())
2024                    },
2025                )
2026                .into_any_element(),
2027        )
2028    } else {
2029        ring.settle();
2030        Some(
2031            gpui::div()
2032                .absolute()
2033                .inset_0()
2034                .rounded(radius)
2035                .shadow(ring_to.shadows())
2036                .into_any_element(),
2037        )
2038    };
2039    let ring_layer: Option<AnyElement> = match ring_layer {
2040        Some(layer) if ring_escapes_clip => Some(crate::util::floating(layer).into_any_element()),
2041        some => some,
2042    };
2043    el = el.child(fill).child(border_layer);
2044    let ring_painted = ring_layer.is_some();
2045    if let Some(ring_layer) = ring_layer {
2046        el = el.child(ring_layer);
2047    }
2048    (el, ring_painted)
2049}
2050
2051/// v3's `@keyframes caret-blink`: opaque at 0/70/100%, transparent at 20/50%.
2052///
2053/// Reproduced as a repeating 1s animation over the same stops, so a text caret
2054/// blinks the way it does on the web instead of sitting solid.
2055pub fn caret_blink<E>(el: E, id: impl Into<ElementId>, cx: &App) -> AnyElement
2056where
2057    E: IntoElement + Styled + 'static,
2058{
2059    if ActiveTheme::reduce_motion(cx) {
2060        return el.into_any_element();
2061    }
2062
2063    el.with_animation(
2064        id.into(),
2065        gpui::Animation::new(Duration::from_millis(1000)).repeat(),
2066        |el, delta| el.opacity(caret_opacity(delta)),
2067    )
2068    .into_any_element()
2069}
2070
2071/// The `caret-blink` keyframe curve, linear between its stops.
2072fn caret_opacity(delta: f32) -> f32 {
2073    match delta {
2074        d if d < 0.20 => 1.0 - (d / 0.20),
2075        d if d < 0.50 => 0.0,
2076        d if d < 0.70 => (d - 0.50) / 0.20,
2077        _ => 1.0,
2078    }
2079}
2080
2081/// Applies the v3 overlay entry animation: a 200ms ease-out fade.
2082///
2083/// The fade alone, for a panel with no metrics worth growing. Prefer
2084/// [`entering_zoom`], which adds v3's `zoom-in-90`. Returns `el` untouched when
2085/// the app has reduced motion enabled.
2086pub fn entering<E>(el: E, id: impl Into<ElementId>, m: Motion, cx: &App) -> AnyElement
2087where
2088    E: IntoElement + Styled + 'static,
2089{
2090    if ActiveTheme::reduce_motion(cx) {
2091        return el.into_any_element();
2092    }
2093
2094    el.with_animation(
2095        id.into(),
2096        gpui::Animation::new(Duration::from_millis(m.ms)).with_easing(move |t| m.curve.at(t)),
2097        Styled::opacity,
2098    )
2099    .into_any_element()
2100}
2101
2102/// Render a measured Accordion/Disclosure panel with the pinned height and
2103/// opacity transition.
2104///
2105/// `panel` owns the component's id and accessibility semantics; `body` is
2106/// wrapped in a non-shrinking measurement slot so its natural height remains
2107/// available even while the outer panel is clipped to an animated height. The
2108/// body stays mounted through the 200ms closing phase, which lets focus and
2109/// layout settle the same way the React Aria panel does before it becomes
2110/// hidden. A reopened panel cancels the stale exit timer and retargets both
2111/// tweens from their live frame.
2112pub(crate) fn collapsible_panel(
2113    id: &ElementId,
2114    is_open: bool,
2115    panel: gpui::Stateful<gpui::Div>,
2116    body: AnyElement,
2117    window: &mut Window,
2118    cx: &mut App,
2119) -> Option<AnyElement> {
2120    collapsible_panel_with_timings(
2121        id,
2122        is_open,
2123        panel,
2124        body,
2125        Motion::DISCLOSURE,
2126        Motion::DISCLOSURE,
2127        window,
2128        cx,
2129    )
2130}
2131
2132/// Render a validation error row with v3's independent height and opacity
2133/// timelines. The message is retained in keyed state while the row exits so
2134/// the height tween has a real measured body instead of collapsing before the
2135/// animation begins. A quick revalidation updates that retained body and
2136/// retargets both tweens from their current painted values.
2137pub(crate) fn field_error_panel(
2138    id: &ElementId,
2139    message: Option<gpui::SharedString>,
2140    window: &mut Window,
2141    cx: &mut App,
2142) -> Option<AnyElement> {
2143    let retained =
2144        window.use_keyed_state(element_id::scoped(id, "field-error-message"), cx, |_, _| {
2145            None::<gpui::SharedString>
2146        });
2147    let is_open = message.is_some();
2148    if let Some(message) = message {
2149        if retained.read(cx).as_ref() != Some(&message) {
2150            retained.update(cx, |stored, cx| {
2151                *stored = Some(message);
2152                cx.notify();
2153            });
2154        }
2155    }
2156    let message = retained.read(cx).clone().unwrap_or_default();
2157    let panel_id = element_id::scoped(id, "field-error-panel");
2158    let panel = gpui::div()
2159        .id(panel_id.clone())
2160        .flex_shrink_0()
2161        .px(px(4.))
2162        .text_size(px(12.))
2163        .line_height(px(16.))
2164        .text_color(cx.colors().danger.color);
2165    let body = gpui::div()
2166        .flex_shrink_0()
2167        .child(message.to_string())
2168        .into_any_element();
2169    collapsible_panel_with_timings(
2170        &panel_id,
2171        is_open,
2172        panel,
2173        body,
2174        Motion::FIELD_ERROR_HEIGHT,
2175        Motion::FIELD_ERROR_OPACITY,
2176        window,
2177        cx,
2178    )
2179}
2180
2181#[allow(clippy::too_many_arguments)] // the two motion tracks are the caller's paired contract
2182fn collapsible_panel_with_timings(
2183    id: &ElementId,
2184    is_open: bool,
2185    panel: gpui::Stateful<gpui::Div>,
2186    body: AnyElement,
2187    height_motion: Motion,
2188    opacity_motion: Motion,
2189    window: &mut Window,
2190    cx: &mut App,
2191) -> Option<AnyElement> {
2192    let reduce_motion = ActiveTheme::reduce_motion(cx);
2193    // Remember whether this panel has ever been visited. A default-open panel
2194    // has no opening transition in React Aria (its initial effect sets the
2195    // height to `auto`), while a panel opened after starting closed animates
2196    // from zero. This state is created even while the panel is closed so the
2197    // first later open is distinguishable from a default-open render.
2198    let lifecycle = window.use_keyed_state(element_id::scoped(id, "lifecycle"), cx, |_, _| {
2199        CollapsibleLifecycle::default()
2200    });
2201    let lifecycle_snapshot = *lifecycle.read(cx);
2202    let default_open = lifecycle_snapshot.default_open;
2203    if !lifecycle_snapshot.seen {
2204        lifecycle.update(cx, |state, _| {
2205            state.seen = true;
2206            state.default_open = is_open;
2207        });
2208    }
2209
2210    let exit_ms = height_motion.ms.max(opacity_motion.ms);
2211    let phase = crate::util::panel_phase(
2212        window,
2213        cx,
2214        element_id::scoped(id, "phase"),
2215        is_open,
2216        !reduce_motion,
2217        exit_ms,
2218    );
2219    if phase == crate::util::OverlayPhase::Closed {
2220        return None;
2221    }
2222
2223    let measured = window.use_keyed_state(element_id::scoped(id, "natural-height"), cx, |_, _| {
2224        Rc::new(Cell::new(None::<gpui::Pixels>))
2225    });
2226    let measured_for_listener = measured.clone();
2227    let body = gpui::div()
2228        .flex_shrink_0()
2229        .on_children_prepainted(move |bounds, _, cx| {
2230            let next = bounds.first().map_or(px(0.), |bound| bound.size.height);
2231            measured_for_listener.update(cx, |height, cx| {
2232                if height.get() != Some(next) {
2233                    height.set(Some(next));
2234                    cx.notify();
2235                }
2236            });
2237        })
2238        .child(body);
2239
2240    let natural_height = measured.read(cx).get();
2241    // The first open frame must remain intrinsically sized so content is
2242    // immediately usable and its natural extent can be measured. Skipping
2243    // the height tween until that measurement exists also prevents an
2244    // initial default-open panel from animating from an invented zero height.
2245    if natural_height.is_none() {
2246        let panel = panel.flex_shrink_0().child(body);
2247        return Some(
2248            panel
2249                .opacity(if is_open { 1.0 } else { 0.0 })
2250                .into_any_element(),
2251        );
2252    }
2253    let target_height = if is_open {
2254        natural_height.unwrap_or(px(0.))
2255    } else {
2256        px(0.)
2257    };
2258    let initial_open = is_open && !default_open && natural_height.is_some();
2259    let mut height = Tween::keyed_with_initial(
2260        id,
2261        "panel-height",
2262        target_height,
2263        initial_open.then_some(px(0.)),
2264        window,
2265        cx,
2266    );
2267    let mut opacity = Tween::keyed_with_initial(
2268        id,
2269        "panel-opacity",
2270        if is_open { 1.0 } else { 0.0 },
2271        initial_open.then_some(0.0),
2272        window,
2273        cx,
2274    );
2275    height.snap_if_reduced(reduce_motion);
2276    opacity.snap_if_reduced(reduce_motion);
2277
2278    let panel = panel.flex_shrink_0().overflow_hidden().child(body);
2279    let animate_height = height.animates(reduce_motion);
2280    let animate_opacity = opacity.animates(reduce_motion);
2281    if !animate_height && !animate_opacity {
2282        height.settle();
2283        opacity.settle();
2284        return Some(
2285            panel
2286                .h(height.target())
2287                .opacity(opacity.target())
2288                .into_any_element(),
2289        );
2290    }
2291
2292    let height_from = height.from();
2293    let height_to = height.target();
2294    let height_value = height.value();
2295    let opacity_from = opacity.from();
2296    let opacity_to = opacity.target();
2297    let opacity_value = opacity.value();
2298    let animation_id = element_id::scoped(
2299        id,
2300        format!(
2301            "panel-motion-{}-{}",
2302            height.generation(),
2303            opacity.generation()
2304        ),
2305    );
2306    let duration_ms = height_motion.ms.max(opacity_motion.ms).max(1);
2307    let panel = panel.with_animation(
2308        animation_id,
2309        gpui::Animation::new(Duration::from_millis(duration_ms)),
2310        move |panel, delta| {
2311            let height_delta = height_motion
2312                .curve
2313                .at((delta * duration_ms as f32 / height_motion.ms.max(1) as f32).min(1.0));
2314            let opacity_delta = opacity_motion
2315                .curve
2316                .at((delta * duration_ms as f32 / opacity_motion.ms.max(1) as f32).min(1.0));
2317            let next_height = height_from + (height_to - height_from) * height_delta;
2318            let next_opacity = opacity_from + (opacity_to - opacity_from) * opacity_delta;
2319            height_value.set(next_height);
2320            opacity_value.set(next_opacity);
2321            panel.h(next_height).opacity(next_opacity)
2322        },
2323    );
2324    Some(panel.into_any_element())
2325}
2326
2327#[derive(Clone, Copy, Debug, Default)]
2328struct CollapsibleLifecycle {
2329    seen: bool,
2330    default_open: bool,
2331}
2332
2333/// Like [`entering`] but for content that also slides in — used by `Drawer`,
2334/// which enters from a window edge.
2335///
2336/// `travel` is the distance in pixels the panel covers; it is applied as a
2337/// margin that relaxes to zero.
2338pub fn entering_from<E>(
2339    el: E,
2340    id: impl Into<ElementId>,
2341    edge: Edge,
2342    travel: gpui::Pixels,
2343    m: Motion,
2344    cx: &App,
2345) -> AnyElement
2346where
2347    E: IntoElement + Styled + 'static,
2348{
2349    if ActiveTheme::reduce_motion(cx) {
2350        return el.into_any_element();
2351    }
2352
2353    el.with_animation(
2354        id.into(),
2355        gpui::Animation::new(Duration::from_millis(m.ms)).with_easing(move |t| m.curve.at(t)),
2356        move |el, delta| {
2357            let remaining = travel * (1.0 - delta);
2358            let el = el.opacity(delta);
2359            match edge {
2360                Edge::Left => el.ml(-remaining),
2361                Edge::Right => el.mr(-remaining),
2362                Edge::Top => el.mt(-remaining),
2363                Edge::Bottom => el.mb(-remaining),
2364            }
2365        },
2366    )
2367    .into_any_element()
2368}
2369
2370/// The mirror of [`entering_from`]: the panel slides back out to `edge`.
2371///
2372/// v3's drawer uses `slide-out-to-*` here, at the shorter exit duration.
2373pub fn exiting_to<E>(
2374    el: E,
2375    id: impl Into<ElementId>,
2376    edge: Edge,
2377    travel: gpui::Pixels,
2378    m: Motion,
2379    cx: &App,
2380) -> AnyElement
2381where
2382    E: IntoElement + Styled + 'static,
2383{
2384    if ActiveTheme::reduce_motion(cx) {
2385        return el.into_any_element();
2386    }
2387
2388    el.with_animation(
2389        id.into(),
2390        gpui::Animation::new(Duration::from_millis(m.ms)).with_easing(move |t| m.curve.at(t)),
2391        move |el, delta| {
2392            let gone = travel * delta;
2393            let el = el.opacity(1.0 - delta);
2394            match edge {
2395                Edge::Left => el.ml(-gone),
2396                Edge::Right => el.mr(-gone),
2397                Edge::Top => el.mt(-gone),
2398                Edge::Bottom => el.mb(-gone),
2399            }
2400        },
2401    )
2402    .into_any_element()
2403}
2404
2405/// Which window edge a sliding panel enters from.
2406#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2407pub enum Edge {
2408    /// The left edge.
2409    Left,
2410    /// The right edge.
2411    Right,
2412    /// The top edge.
2413    Top,
2414    /// The bottom edge.
2415    Bottom,
2416}
2417
2418#[cfg(test)]
2419mod tests {
2420    use super::*;
2421    use gpui::px;
2422    use herogpui_theme::{set_reduce_motion, set_theme, Theme, ThemeProvider};
2423
2424    #[test]
2425    fn press_scales_resolved_corners_without_erasing_partial_overrides() {
2426        let mut skin = crate::util::round_sx_corners(
2427            gpui::div().rounded(px(2.)),
2428            &gpui::Corners {
2429                top_left: Some(px(12.)),
2430                bottom_right: Some(px(0.)),
2431                ..Default::default()
2432            },
2433        );
2434        let corners = pressed_corners(&skin.style().corner_radii, px(8.), 0.5);
2435        assert_eq!(
2436            corners,
2437            gpui::Corners {
2438                top_left: Some(px(6.)),
2439                top_right: Some(px(1.)),
2440                bottom_right: Some(px(0.)),
2441                bottom_left: Some(px(1.)),
2442            }
2443        );
2444        let mut pressed = crate::util::round_sx_corners(gpui::div().rounded(px(4.)), &corners);
2445        assert_eq!(pressed.style().corner_radii.top_left, Some(px(6.).into()));
2446        assert_eq!(
2447            pressed.style().corner_radii.bottom_right,
2448            Some(px(0.).into())
2449        );
2450        let unsupported = gpui::CornersRefinement {
2451            top_left: Some(gpui::rems(1.).into()),
2452            ..Default::default()
2453        };
2454        assert_eq!(
2455            pressed_corners(&unsupported, px(8.), 0.5),
2456            gpui::Corners::all(px(4.)).map(|radius| Some(*radius))
2457        );
2458    }
2459
2460    #[test]
2461    fn stable_press_slot_keeps_button_radius_and_group_seams() {
2462        let full = gpui::CornersRefinement {
2463            top_left: Some(gpui::px(12.).into()),
2464            top_right: Some(gpui::px(12.).into()),
2465            bottom_right: Some(gpui::px(12.).into()),
2466            bottom_left: Some(gpui::px(12.).into()),
2467        };
2468        assert_eq!(
2469            resting_slot_corners(&full, px(8.)),
2470            gpui::Corners {
2471                top_left: Some(px(12.)),
2472                top_right: Some(px(12.)),
2473                bottom_right: Some(px(12.)),
2474                bottom_left: Some(px(12.)),
2475            }
2476        );
2477
2478        let group_start = gpui::CornersRefinement {
2479            top_left: Some(gpui::px(12.).into()),
2480            bottom_left: Some(gpui::px(12.).into()),
2481            ..Default::default()
2482        };
2483        let corners = resting_slot_corners(&group_start, px(8.));
2484        assert_eq!(corners.top_left, Some(px(12.)));
2485        assert_eq!(corners.bottom_left, Some(px(12.)));
2486        assert_eq!(corners.top_right, None);
2487        assert_eq!(corners.bottom_right, None);
2488    }
2489
2490    /// `hover_fade_ms` is public configuration: the stock theme keeps the
2491    /// button's `100ms`, a zero resolves like reduced motion, and any other
2492    /// value reaches the helper the fade reads.
2493    #[gpui::test]
2494    fn hover_fade_duration_follows_the_theme_and_zero_resolves_immediately(
2495        cx: &mut gpui::TestAppContext,
2496    ) {
2497        cx.update(ThemeProvider::init);
2498        cx.update(|cx| {
2499            assert_eq!(
2500                hover_fade_duration(cx),
2501                Some(Duration::from_millis(TRANSITION_MS)),
2502                "the stock theme uses the button's own 100ms"
2503            );
2504            assert_eq!(
2505                hover_fade_duration_with_override(cx, Some(ACCORDION_TRIGGER_HOVER_MS)),
2506                Some(Duration::from_millis(ACCORDION_TRIGGER_HOVER_MS)),
2507                "Accordion keeps its pinned 150ms declaration"
2508            );
2509        });
2510        cx.update(|cx| {
2511            set_theme(
2512                Theme::builder("instant", Theme::light())
2513                    .hover_fade_ms(0)
2514                    .build(),
2515                cx,
2516            );
2517        });
2518        cx.update(|cx| {
2519            assert_eq!(hover_fade_duration(cx), None, "zero resolves immediately");
2520            assert_eq!(
2521                hover_fade_duration_with_override(cx, Some(ACCORDION_TRIGGER_HOVER_MS)),
2522                None,
2523                "the theme-wide zero opt-out still suppresses owner overrides"
2524            );
2525        });
2526        cx.update(|cx| {
2527            set_theme(
2528                Theme::builder("slow", Theme::light())
2529                    .hover_fade_ms(250)
2530                    .build(),
2531                cx,
2532            );
2533        });
2534        cx.update(|cx| {
2535            assert_eq!(hover_fade_duration(cx), Some(Duration::from_millis(250)));
2536        });
2537        cx.update(|cx| set_reduce_motion(true, cx));
2538        cx.update(|cx| {
2539            assert_eq!(
2540                hover_fade_duration(cx),
2541                None,
2542                "reduced motion resolves immediately even with a non-zero token"
2543            );
2544        });
2545        cx.update(|cx| set_reduce_motion(false, cx));
2546    }
2547
2548    #[test]
2549    fn press_inset_matches_the_scale() {
2550        // scale(0.97) on a 40px control moves each edge in by 1.5% of 40.
2551        // `1.0 - 0.97` is not exact in f32, so compare with a tolerance.
2552        assert!((f32::from(pressed_inset(px(40.))) - 0.6).abs() < 1e-4);
2553        assert!((f32::from(pressed_inset(px(32.))) - 0.48).abs() < 1e-4);
2554    }
2555
2556    #[test]
2557    fn press_preserves_the_outer_footprint() {
2558        // The margin the box gains is exactly what its height gives up, so a
2559        // press never moves a neighbour.
2560        for h in [32.0f32, 40.0, 48.0] {
2561            let inset = f32::from(pressed_inset(px(h)));
2562            let shrunk = f32::from(shrink(px(h), pressed_inset(px(h)) + pressed_inset(px(h))));
2563            assert!(
2564                (shrunk + inset * 2.0 - h).abs() < 1e-4,
2565                "footprint changed at {h}"
2566            );
2567        }
2568    }
2569
2570    #[test]
2571    fn cubic_bezier_pins_its_endpoints_and_rises() {
2572        let out = ease_out();
2573        assert!(out(0.0).abs() < 1e-3);
2574        assert!((out(1.0) - 1.0).abs() < 1e-3);
2575        // ease-out leads: it is ahead of linear through the middle.
2576        assert!(out(0.5) > 0.5, "ease-out should lead at the midpoint");
2577        // and it never goes backwards
2578        let mut prev = 0.0;
2579        for i in 0..=20 {
2580            let v = out(i as f32 / 20.0);
2581            assert!(v >= prev - 1e-4, "ease-out dipped at {i}");
2582            prev = v;
2583        }
2584        let smooth = ease_smooth();
2585        assert!(smooth(0.0).abs() < 1e-3);
2586        assert!((smooth(1.0) - 1.0).abs() < 1e-3);
2587    }
2588
2589    #[test]
2590    #[allow(clippy::float_cmp)] // the scales are declared as exact identities
2591    fn drawer_backdrop_uses_its_own_fluid_timing() {
2592        assert_eq!(Motion::DRAWER_BACKDROP_IN.ms, 250);
2593        assert_eq!(Motion::DRAWER_BACKDROP_OUT.ms, 200);
2594        assert_eq!(Motion::DRAWER_BACKDROP_IN.curve, Curve::OutFluid);
2595        assert_eq!(Motion::DRAWER_BACKDROP_OUT.curve, Curve::OutFluid);
2596        assert_eq!(Motion::DRAWER_BACKDROP_IN.scale, 1.0);
2597        assert_eq!(Motion::DRAWER_BACKDROP_OUT.scale, 1.0);
2598    }
2599
2600    #[test]
2601    #[allow(clippy::float_cmp)] // the scales are declared as exact identities
2602    fn field_error_uses_independent_height_and_opacity_timelines() {
2603        assert_eq!(Motion::FIELD_ERROR_HEIGHT.ms, 350);
2604        assert_eq!(Motion::FIELD_ERROR_HEIGHT.curve, Curve::Smooth);
2605        assert_eq!(Motion::FIELD_ERROR_OPACITY.ms, 150);
2606        assert_eq!(Motion::FIELD_ERROR_OPACITY.curve, Curve::Out);
2607        assert_eq!(Motion::FIELD_ERROR_HEIGHT.scale, 1.0);
2608        assert_eq!(Motion::FIELD_ERROR_OPACITY.scale, 1.0);
2609    }
2610
2611    #[test]
2612    #[allow(clippy::float_cmp)] // the keyframe values are meant to be exact
2613    fn caret_blink_matches_its_keyframes() {
2614        // 0%, 70% and 100% are opaque; 20% and 50% are transparent.
2615        let at = |t: f32| caret_opacity(t);
2616        for (t, want) in [
2617            (0.0, 1.0),
2618            (0.20, 0.0),
2619            (0.35, 0.0),
2620            (0.50, 0.0),
2621            (0.70, 1.0),
2622            (1.0, 1.0),
2623        ] {
2624            assert!((at(t) - want).abs() < 1e-6, "{t} should be {want}");
2625        }
2626        // and it ramps rather than jumping
2627        assert!((caret_opacity(0.10) - 0.5).abs() < 1e-6);
2628        assert!((caret_opacity(0.60) - 0.5).abs() < 1e-6);
2629    }
2630
2631    #[test]
2632    fn progress_circle_spin_is_one_linear_turn() {
2633        assert!(progress_circle_spin_turn(0.0).abs() < 1e-6);
2634        assert!((progress_circle_spin_turn(0.25) - std::f32::consts::FRAC_PI_2).abs() < 1e-6);
2635        assert!((progress_circle_spin_turn(1.0) - std::f32::consts::TAU).abs() < 1e-6);
2636
2637        let mut previous = 0.0;
2638        for step in 0..=20 {
2639            let rotation = progress_circle_spin_turn(step as f32 / 20.0);
2640            assert!(rotation >= previous, "spin moved backwards at step {step}");
2641            previous = rotation;
2642        }
2643    }
2644
2645    #[test]
2646    #[allow(clippy::float_cmp)] // clamped to exactly zero, not to near-zero
2647    fn shrink_never_goes_negative() {
2648        assert!(f32::from(shrink(px(1.), px(4.))).abs() < 1e-6);
2649    }
2650}