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}