diff --git a/crates/core/src/switch.rs b/crates/core/src/switch.rs index 53ae54a..1c6e30e 100644 --- a/crates/core/src/switch.rs +++ b/crates/core/src/switch.rs @@ -2,9 +2,9 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ - App, BoxShadow, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, - RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Toggled, Window, - div, px, + AnimationExt as _, App, BoxShadow, ElementId, InteractiveElement as _, IntoElement, + ParentElement as _, RenderOnce, Role, SharedString, SpringAnimation, SpringConfig, + StatefulInteractiveElement as _, Styled as _, Toggled, Window, div, px, }; use gpui_native_theme::{ActiveTheme as _, Size}; @@ -100,11 +100,15 @@ impl RenderOnce for Switch { let knob = metrics.icon_size; let inset = metrics.icon_size * 0.125; + let travel = track_width - knob - inset * 2.; let next = !self.checked; let on_change = self.on_change; let a11y_label = self.a11y_label.or_else(|| self.label.clone()); + let anim_config = SpringConfig::new(500., 35., 1.); + let anim = SpringAnimation::new(anim_config).to(if self.checked { travel } else { px(0.) }); + h_flex() .id(self.id) .gap_1p5() @@ -128,29 +132,31 @@ impl RenderOnce for Switch { }) }) .child( - h_flex() + div() .flex_none() + .relative() .w(track_width) .h(track_height) - .p(inset) .rounded(theme.radius.full) .when(self.checked, |track| { track .bg(ramps.primary.base) - .justify_end() .hover(|style| style.bg(ramps.primary.hover)) }) - .when(!self.checked, |track| { - track.bg(colors.border).justify_start() - }) + .when(!self.checked, |track| track.bg(colors.border)) .child( div() + .absolute() + .top(inset) + .left(inset) .size(knob) - .flex_none() .rounded(theme.radius.full) .bg(colors.primary_foreground) .border_1() - .border_color(colors.border), + .border_color(colors.border) + .with_spring("switch-thumb", anim, move |thumb, offset| { + thumb.left(inset + offset) + }), ), ) .when_some(self.label, |switch, label| switch.child(label)) diff --git a/crates/theme/Cargo.toml b/crates/theme/Cargo.toml index 4177384..4f894ca 100644 --- a/crates/theme/Cargo.toml +++ b/crates/theme/Cargo.toml @@ -10,6 +10,9 @@ gpui = { workspace = true } serde = { version = "1", features = ["derive"] } toml = "0.8" +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } + [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.6" objc2 = "0.6" diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs index 05b9de0..1c992ee 100644 --- a/crates/theme/src/lib.rs +++ b/crates/theme/src/lib.rs @@ -333,6 +333,7 @@ pub(crate) fn reapply(cx: &mut App) { let system = cx.global::().source.snapshot(variant); let resolved = resolve::resolve(&system, preset.as_ref(), mode_override); + cx.set_reduce_motion(resolved.accessibility.reduce_motion); cx.set_global(Theme::from_resolved(resolved)); cx.refresh_windows(); } diff --git a/docs/PLAN.md b/docs/PLAN.md index ab2c219..b352215 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -91,6 +91,7 @@ One-way, no cycles. Nothing depends on `gpui-component`, `gpui-kit`, `gpuikit`, - **Deliberate divergence from base:** our `Link` opens `href` through the platform by default (`cx.open_url`) — native feel is the product — with `on_click` as an override for apps that route internally. Base never opens URLs; we do unless told otherwise. - Each component has a named native reference per platform (NSButton / WinUI Button / Adwaita / Breeze) recorded on its gallery page; "native feel" is reviewed against those references. - Naming seams per AGENTS.md: `is_`/`has_` readers, `with_` builders, `set_` mutation, `on_` callbacks, `State`/`Delegate` suffixes, stable domain-derived `ElementId`s. +- **Motion is inline and per component (D21).** Each component animates with gpui's own primitives (`with_animation`, `with_spring`, the gpui easings, `with_max_fps`) directly in its own file, a few lines at most, using theme durations where they're shared (`Motion.overlay`). No shared motion module, no ghost panels, no hover-wash plumbing; overlay exit animations and hover washes are dropped until a genuinely simple approach exists. ### 4.4 Icons (`crates/icons`) @@ -153,7 +154,7 @@ The set stays role-minimal and grows one concept at a time, driven by the unit t - ~~**Overlay scrim + shadow/elevation tokens**~~ — ✅ landed with unit 11 (2026-10-29): `colors.overlay` (translucent-black scrim, denser in dark; TOML-overridable; no platform states one), `Radius.overlay` (10 pt), `Elevations.popover`/`dialog` (transcribed NSPopover-class shadows, builder → `Vec`), and `Motion.overlay` (200 ms enter transition). Popover consumes elevation + motion; the scrim waits for Dialog. - ~~**Scrollbar thumb token + metrics**~~ — ✅ landed with the ScrollArea unit (2026-10-29): `colors.scrollbar_thumb` (translucent over content: black in light, light gray in dark; TOML-overridable) and `Theme.scrollbar` (`width` 8 pt, `inset` 2 pt, `min_thumb` 24 pt). - ~~**`success` / `warning` roles**~~ — ✅ landed with Toast (2026-10-29): `colors.success`/`warning` (+ foregrounds; light and dark variants; TOML-overridable; no platform states them) and derived `Ramps.success`/`warning` via the OKLCH engine, matching the destructive treatment. -- ~~**Motion tokens**~~ — attempted (unit 14, 2026-10-30) and reverted the same day: the working set (`Curve` + CSS cubic-bezier, `Springs` presets, `[motion]` TOML overrides, an ungated `motion` module with an exit-lifecycle state machine and spring-blended hover washes, exit ghosts on all three overlays, a spring Switch thumb, a store-held toast exiting list) judged too complex for the value, and the motion itself not good enough. The motion system goes back to the drawing board — see the unit 14 row (§6) for what was tried. Two cheap pieces remain worth taking regardless of the rethink: forwarding `reduce_motion` to `cx.set_reduce_motion` in `reapply` (the OS flag is still read but ignored — every animation shipped so far disobeys Reduce Motion), and a 30 fps cap on Progress's indeterminate repeat. +- **Motion tokens** — after the reverted framework attempt (2026-10-30), motion stays minimal: `Motion.overlay` (200 ms) is the only token. Components reach for gpui's own easings and `SpringConfig` inline (D21); a token is added only when a third component shares the same value (D16). TOML `[motion]` overrides and per-platform motion presets wait for a real need. ~~The reduce-motion forwarding fix~~ ✅ landed (pass a, 2026-10-30): `reapply` calls `cx.set_reduce_motion(resolved.accessibility.reduce_motion)`, so gpui's animation elements snap under the OS setting. - **`knob` / control-fill role** — only if Slider (unit 7) confirms the Switch's borrowed roles (`primary_foreground` thumb, `border` off-track) as a second consumer; until then no expansion. Controls through unit 10 paint from existing roles and ramps. @@ -175,7 +176,7 @@ Every target supplies one accent color (or a stated ramp). Where the platform st ### 5.5 Native metrics -Native feel is half geometry. Per-platform tables for: control heights and paddings, corner radii (WinUI control vs card rounding; macOS control radii), font fallback lists resolved through `.SystemUIFont` (SF Pro / Segoe UI Variable / Adwaita Sans / Noto Sans — no fonts bundled), focus-ring policy (macOS keyboard ring, WinUI two-layer ring, GTK outline), scrollbar style (overlay vs inline), popup elevation and menu metrics. Motion presets are metrics too: each platform states its curves and durations (macOS tuned to NSPopover/NSMenu transitions, Windows the Fluent decelerate cubic-bezier, GNOME/KDE shell timings) — the token shape is open again after the unit 14 revert, and Phase F sources fill it once the rethink lands. Values are transcribed from each toolkit's reference metrics and verified with side-by-side screenshots (macOS in Phase B, the rest in Phase F). +Native feel is half geometry. Per-platform tables for: control heights and paddings, corner radii (WinUI control vs card rounding; macOS control radii), font fallback lists resolved through `.SystemUIFont` (SF Pro / Segoe UI Variable / Adwaita Sans / Noto Sans — no fonts bundled), focus-ring policy (macOS keyboard ring, WinUI two-layer ring, GTK outline), scrollbar style (overlay vs inline), popup elevation and menu metrics. Motion stays per-component timing on gpui's own primitives; when a platform source needs to state it (macOS NSPopover-class timing, Fluent's decelerate curve, GNOME/KDE shell timings), it states `Motion.overlay` first and further tokens arrive per the role-minimal rule. Values are transcribed from each toolkit's reference metrics and verified with side-by-side screenshots (macOS in Phase B, the rest in Phase F). ### 5.6 Custom themes @@ -214,12 +215,12 @@ Each row is one review unit: build it, show it in the gallery, review, then move | 12 | core | Menu | ✅ Landed (2026-10-29). Controlled `open`/`on_change` + `trigger`/`items` (MenuItem: label, icon, shortcut hint, disabled, destructive, checked, separator). Popover shell patterns duplicated per D16 (promote at Dialog); panel = surface + overlay radius + popover elevation, role Menu; claims focus while open; keyboard nav — arrows/home/end over selectable items (wrapping, skipping separators + disabled), enter/space activates (closes menu before the action), escape dismisses; hover selects, click activates; trigger clicks toggle (popover-owned); selection resets on each open; long menus scroll via the ScrollArea once a height is styled. Deferred: submenus, type-ahead, scroll-selected-item-into-view, real shortcut handling | | 11 | core | Popover | ✅ Landed (2026-10-29). First overlay. Controlled `open` + `on_change` (Rc-shared handler); `trigger`/`content` element closures; panel = surface + `Radius.overlay` + `Elevations.popover` + border, role Dialog with label; `anchored()` + `deferred()` (window-overflow flip for free); enter fade via gpui `with_animation` at `Motion.overlay` (respects reduce-motion); claims keyboard focus while open; escape and click-outside dismiss (outside clicks swallowed). No scrim — native macOS popover model; the overlay token waits for Dialog. Known v1 gaps: no exit animation, trigger aria-expanded left to the app | | 11+ | core | Tooltip ✅ (2026-10-29), Dialog + AlertDialog ✅ (2026-10-29), Toast ✅ (2026-10-29) | One unit each, same gate. Tooltip rides gpui's own `.tooltip()` lifecycle (hover delay, anchoring, dismissal — all framework-owned); ours is the styled view (`surface` + border + `Radius.sm`, small text) plus a `Tooltip::text` closure and a Button passthrough. Dialog + AlertDialog (one component, `alert(true)` switches semantics): scrim (`colors.overlay`, occluding) + centered panel (surface, `Radius.overlay`, `Elevations.dialog`), role Dialog/AlertDialog, focus claimed while open; modal kind closes on escape + scrim click, alert kind is inert to both (the user answers through the actions); actions footer is caller-built elements closing through owner state. Toast: `Toasts` store entity (push/dismiss/clear + auto-dismiss timers on the GPUI executor, `Toast::DURATION` 5 s, zero = sticky) + `ToastsView` deferred stack (top-right, `Radius.overlay` + popover elevation, severity icon from the new success/warning ramps, close button, `on_dismiss` windowless handler). Toast landed the deferred `success`/`warning` tokens (§5.2). Sheet dropped from the roadmap | -| 14 | core + theme | Motion | ⏪ Attempted and reverted (2026-10-30). What was tried: theme `Motion` tokens (overlay duration + `Curve` with a CSS cubic-bezier + spring presets, `[motion]` TOML); an ungated core `motion` module (curve→easing, `ExitLifecycle` exit ghosts via keyed window state, spring hover washes); consumers — exit ghosts on Popover/Menu/Dialog (scrim fading both ways), Switch thumb on `with_spring`, Toast fade/slide + a store-held exiting list, Button + toast-close hover washes, Progress capped at 30 fps. All reverted — judged too complex for the value and not good enough to watch. The rethink starts from what the attempt proved: gpui's `with_spring`/`with_animation` are the right substrate (D21) and keyed window state CAN drive overlay exits; the cost centered on the exit-lifecycle plumbing and per-control wash machinery. Keep the plan open, redesign before reimplementing | +| 14 | core + theme | Motion (per component) | Replaces the reverted 2026-10-30 framework attempt — no token tower, no `motion` module, no ghost panels; just gpui's own primitives used inline, one separately reviewed pass per component, each a few lines: (a) theme: ✅ landed — `reapply` forwards `reduce_motion` via `cx.set_reduce_motion`, so gpui snaps every animation under the OS setting; (b) Switch: ✅ landed — the thumb absolute-positions in the track and slides on `with_spring` (inline `SpringConfig::new(500, 35, 1)`, `Pixels` target, mounts at its target so the initial render never animates; velocity survives mid-flight retargets); (c) Toast: enter fade via `with_animation`; (d) Dialog: the scrim enters with the panel instead of snapping; (e) Progress: cap the indeterminate sweep at 30 fps. Overlay enter fades already exist from units 11/12. Exits and hover washes stay out — they were the complexity sink (§ deferred list). Each pass reviewed in the gallery on its own | | then | data (new) | List, Table, Tree, Tabs, Sidebar | Created on demand, same gate. | | then | pickers or data | Calendar/DatePicker, TimeField, ColorPicker | Decide at unit time. | | editor | editor (new) | Text/code editor, syntax highlighting (D12) | Same gate. | -Deferred until a real need: Markdown/TextView, Dock, VirtualList, window-wide text selection, a motion system (unit 14 reverted; rethink before reimplementing), charts. +Deferred until a real need: Markdown/TextView, Dock, VirtualList, window-wide text selection, overlay exit animations and hover washes (dropped with the 2026-10-30 revert; only return if a genuinely simple approach appears), charts. ### 6.1 After the core units: sizing API — ✅ landed @@ -255,7 +256,7 @@ Deferred until a real need: bidi/RTL; prefix/suffix adornments and a form field | C — controls | One review unit at a time (§6, units 1–9), each: API per §4.3 + tests + gallery page → review gate. macOS SF Symbols resolver behind the icon seam lands with the first unit that shows it matters | Per unit: activation, controlled round-trip, and disabled inertness tested; gallery page shows the live control against the theme; your approval closes the unit | | D — input | Wave 2 | Single-line input with cursor, selection, clipboard; NumberInput | | E — overlays | Wave 3 | Focus trapping and escape/dismiss semantics per overlay | -| E+ — motion | Unit 14 (§6): Motion tokens + reduce-motion forwarding + overlay exits + Switch thumb + hover washes | ⏪ Attempted and reverted (2026-10-30); rethink pending. The reduce-motion forwarding fix and the Progress 30 fps cap stay on the table as standalone wins | +| E+ — motion | Unit 14 (§6): per-component passes, each a few lines on gpui's own primitives — forwarding, Switch thumb, Toast enter, Dialog scrim, Progress cap | Each pass reviewed in the gallery separately; reduce-motion snaps gpui-side once the flag is forwarded | | F — platforms + data | `crates/data` + Windows/GNOME/KDE/Omarchy theme sources + Segoe Fluent/freedesktop icon resolvers | Side-by-side gallery screenshots per platform | | G — pickers + polish | Wave 5 → 0.1; start `crates/editor` if wanted | — | @@ -307,7 +308,7 @@ Pure tests for resolve/overlay chain, payload parsing (fixtures), icon resolutio | D18 | Crate split for consumption: `core` (every-app components) → `editor`, `data`, … (advanced) → thin `gpui-native-kit` facade; one cargo feature per component, additive, module-gated | | D19 | System icons via semantic `IconRole`s with per-platform resolvers (SF Symbols / Segoe Fluent Icons / freedesktop) and a bundled Lucide fallback; platform glyph sets are used at runtime only, never bundled | | D20 | Components land one element at a time behind a review gate: API sketch → implementation → tests → gallery page → review → next. gpui-kit `base` is the API-design reference for these units; its conventions (controlled state, one activation path, semantic states, a11y labels) and selected utilities are adopted, simplified | -| D21 | Motion is a thin layer over gpui's pinned-rev animation/spring primitives (`with_animation`, `with_spring` with velocity retention, built-in reduce-motion snapping, `max_fps`) — no parallel motion framework (bezel's Painter/Fade store and pulse clock rejected); the theme owns durations, curves (`ease_out_quint` default + CSS-spec `CubicBezier`), and spring presets; hover washes ride settled-spring idleness on controls only, data rows snap. ⏪ Under re-evaluation after the unit 14 revert (2026-10-30): the substrate still holds, the execution complexity does not — the rethink may narrow scope to overlay enters/exits only | +| D21 | Motion is per component and inline: each component uses gpui's own animation/spring primitives (`with_animation`, `with_spring`, the gpui easings, `with_max_fps`) directly in its file; the theme owns only the durations components actually share (today `Motion.overlay`), growing per the role-minimal rule. No shared motion module, no exit ghosts, no hover-wash machinery — exits and washes stay out until a genuinely simple approach exists. Reduce motion is gpui-side: forward the OS flag once in `reapply` and every primitive snaps. Supersedes the reverted 2026-10-30 framework attempt (which proved the substrate but over-built the plumbing) | Dropped from rev 3: D2, D5, D9 (port-era decisions); the review gate returns in a lighter form as D20. D10 (no icon bundling) is superseded by D19. @@ -318,7 +319,8 @@ Dropped from rev 3: D2, D5, D9 (port-era decisions); the review gate returns in 3. ~~Phase C controls~~ ✅ — units 1–9 (Button → Avatar), each through the review gate; sizing API (§6.1). 4. ~~Phase D input~~ ✅ — single-line Input (unit 10) + the §6.2 completion pass (modifiers, undo/redo, read-only, actions + key context, caret blink, paint polish). NumberInput and multiline remain open as deferred units. 5. ~~Phase E overlays~~ ✅ — Popover (unit 11), ScrollArea (unit 11.5), Menu (unit 12), Tooltip + Dialog/AlertDialog + Toast (unit 11+ row; Sheet dropped). Phase gate held: escape/dismiss semantics and focus behavior per overlay. -6. ~~Unit 14 — Motion~~ ⏪ Attempted and reverted (2026-10-30) — judged too complex, and the motion not good enough; the plan and the lessons stay (§6 row 14, D21). Next: **Phase F** — `crates/data` (List, Table, Tree, Tabs, Sidebar) + Windows/GNOME/KDE/Omarchy theme sources (each states its Motion preset, §5.5, once the rethink lands) + Segoe Fluent/freedesktop icon resolvers, with side-by-side gallery screenshots per platform. NumberInput/multiline return as deferred units when needed. Cheap standalone wins to fold in anywhere: `cx.set_reduce_motion` forwarding in `reapply` (§5.2), Progress 30 fps cap. -7. Then — Phase G: pickers, polish, → 0.1; start `crates/editor` if wanted. +6. **Next: unit 14 — motion, per component (§6 row 14, D21)** — ~~(a) theme `reduce_motion` → `cx.set_reduce_motion` in `reapply`~~ ✅ landed; ~~(b) Switch thumb `with_spring`~~ ✅ landed. Remaining passes, each small and separately reviewed: (c) Toast enter fade; (d) Dialog scrim enters with the panel; (e) Progress 30 fps cap. No shared machinery; exits and hover washes stay deferred (§6 deferred list). +7. Then — Phase F: `crates/data` (List, Table, Tree, Tabs, Sidebar) + Windows/GNOME/KDE/Omarchy theme sources (a source may state `Motion.overlay` when it needs platform timing, §5.5) + Segoe Fluent/freedesktop icon resolvers, with side-by-side gallery screenshots per platform. NumberInput/multiline return as deferred units when needed. +8. Then — Phase G: pickers, polish, → 0.1; start `crates/editor` if wanted. Batched polish candidates between units: scrollbar auto-hide + horizontal thumb, `Undo`/`Redo` menu events.