diff --git a/.agents/skills/apple-design/SKILL.md b/.agents/skills/apple-design/SKILL.md new file mode 100644 index 00000000..52241238 --- /dev/null +++ b/.agents/skills/apple-design/SKILL.md @@ -0,0 +1,188 @@ +--- +name: apple-design +description: SwiftUI-native guidance for responsive interaction, gesture-driven motion, Liquid Glass, typography, accessibility, and Reduce Motion on iOS, iPadOS, and macOS 26+. +--- + +# Apple Design + +Use this skill for interaction and motion work in Digests. The repo targets iOS, iPadOS, and macOS 26+ and uses SwiftUI first. Keep views declarative, keep expensive work out of `body`, and use the existing design and animation wrappers. + +The core test is direct manipulation: feedback starts with the gesture, follows the current presentation value, carries release velocity when appropriate, and remains interruptible. + +## 1. Response and direct manipulation + +- Give press feedback at touch-down. Commit the action at touch-up unless the control is continuous. +- For drags, update the presented value continuously. Do not wait for the gesture to end. +- Keep transient gesture state in `@GestureState`; keep the committed/resting value in `@State` or an injected observable owner. +- Respect the user's grab offset. Do not snap content to its center when a drag begins. +- Use `DragGesture` and SwiftUI gesture composition. Prefer the smallest `minimumDistance` that avoids accidental activation. +- Resolve competing gestures deliberately with `simultaneousGesture`, `highPriorityGesture`, or `exclusively`, and preserve scrolling when the child gesture has not clearly won. + +```swift +struct DraggableCard: View { + @GestureState private var translation: CGSize = .zero + @State private var settledOffset: CGSize = .zero + + var body: some View { + CardContent() + .offset( + x: settledOffset.width + translation.width, + y: settledOffset.height + translation.height + ) + .gesture( + DragGesture(minimumDistance: 10) + .updating($translation) { value, state, _ in + state = value.translation + } + .onEnded { value in + withAnimation(.spring(response: 0.35, dampingFraction: 1.0)) { + settledOffset.width += value.translation.width + settledOffset.height += value.translation.height + } + } + ) + } +} +``` + +## 2. Interruptible motion + +- Animate from the value currently presented on screen. A gesture that starts during a settling animation must take over without a jump. +- Never disable input while an animation is running. +- Use `withAnimation` at the state mutation boundary, not around per-pixel gesture updates or raw scroll deltas. +- Scope `.animation(_:value:)` to a stable, meaningful `Equatable` value. Do not attach an ambient animation to a navigation tree. +- Use critically damped springs by default (`dampingFraction: 1.0`). Use a small amount of bounce only when the user supplied momentum, such as a flick or throw. +- Animate independent axes independently when their targets or velocities differ. +- Keep connected transitions to one to three meaningful elements. Let system navigation transitions do most of the work; do not match text blocks, metadata, or entire complex rows. + +For drag release, use SwiftUI's projected endpoint when the interaction should carry momentum, then choose a snap target from that projection. Pass the release direction into the spring decision; do not decide only from the release position. + +```swift +.onEnded { value in + let projectedX = value.predictedEndTranslation.width + let targetX = nearestSnapPoint(to: settledOffset.width + projectedX) + + withAnimation(.spring(response: 0.35, dampingFraction: 0.82)) { + settledOffset.width = targetX + } +} +``` + +## 3. Sheets, boundaries, and spatial consistency + +- Enter and exit along the same path. A sheet that comes from the bottom should dismiss toward the bottom. +- Anchor a popover or sheet to the control that opened it. The source and destination should remain spatially legible. +- Use a small hysteresis threshold before committing a drag direction. Once intent is clear, cancel losing recognizers and track one-to-one. +- At a boundary, rubber-band progressively instead of stopping at a hard edge. Release should settle to a valid bound. +- Keep the UI usable while a transition settles; a new gesture retargets the current state. +- For navigation continuity, follow the repo's connected-transition contracts in `.claude/ai_docs/animation_architecture.md`. + +## 4. Timeline and drawing + +Use `Canvas` for dense, repeated drawing such as the launch rings. Use `TimelineView` for display-sampled animation. Keep geometry pure and deterministic; do not create one SwiftUI view per glyph or mutate state from a drawing closure. + +```pseudocode +TimelineView(animationSchedule) { timeline in + Canvas { context, size in + let frame = scene.frame(at: timeline.date, size: size) + draw(frame, in: &context) + } +} +``` + +- Use one timeline and one immutable scene for a field. +- Pause the timeline when the scene reaches a static hold; retain the final frame rather than continuing a no-op clock. +- Keep per-frame work bounded and compositor-friendly. Precompute stable traits and resolve repeated assets once per pass. +- Do not animate layout, large text trees, or unrelated navigation state to make a drawing feel busy. +- Keep `TimelineView`/`Canvas` rendering synchronous and free of I/O, parsing, and task creation. + +## 5. Liquid Glass and depth + +- App-owned glass goes through `digestsGlassSurface(...)`. +- Group glass-to-glass morphs with `DigestsGlassEffectContainer`; use one namespace per morph group. +- Never scatter raw `glassEffect` calls or availability checks through features. The wrappers provide the iOS Liquid Glass path and the macOS material fallback. +- Do not nest glass surfaces. Use system-owned navigation, tab, and toolbar chrome where the platform provides it. +- Use opaque content surfaces where translucency would reduce reading contrast. Material weight should communicate hierarchy, not decoration. +- Use `glassEffectID` only for a real glass-to-glass morph. Use `matchedGeometryEffect` for non-glass content, and never both for the same element. + +```swift +DigestsGlassEffectContainer(spacing: DigestsDesign.Spacing.medium) { + HStack { + FilterButton() + SortButton() + } + .digestsGlassSurface( + useGlass: true, + cornerRadius: DigestsDesign.CornerRadius.large, + style: .interactive + ) +} +``` + +## 6. Accessibility and Reduce Motion + +- Read `accessibilityReduceMotion` and route animation decisions through `DigestsDesign.Animation.respectingReduceMotion` or the feature's established policy. +- Reduce Motion keeps causality and state feedback but removes travel, parallax, elastic overshoot, and decorative loops. Prefer a short opacity change or an immediate state change. +- Do not make essential information depend on motion, timing, color, translucency, or haptics. +- Provide an `accessibilityLabel`, `accessibilityValue`, and named `accessibilityAction` for custom controls. Keep VoiceOver focus on the invoking control after a local state change. +- Support Dynamic Type with semantic `Font` styles and layouts that can stack. Test large text and accessibility spacing on iPhone, iPad, and Mac. +- Support keyboard and pointer input on iPad and macOS without making touch gestures the only path. +- Use `sensoryFeedback` only for meaningful commits, errors, or snaps. It must not be the sole confirmation and must not fire for every frame of a gesture. + +```swift +struct AnimatedBadge: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var isPresented = false + + var body: some View { + Badge() + .opacity(isPresented ? 1 : 0) + .animation( + reduceMotion ? nil : .spring(response: 0.3, dampingFraction: 1.0), + value: isPresented + ) + .accessibilityLabel("New items") + .accessibilityValue(isPresented ? "Shown" : "Hidden") + } +} +``` + +## 7. Typography and layout + +- Start with the platform system font and semantic text styles. Use a custom face only for a documented product reason. +- Let Dynamic Type determine text size. Avoid fixed dimensions that truncate or prevent stacking at larger sizes. +- Treat weight, leading, and hierarchy as a set. Use spacing and contrast to group related controls. +- Prefer adaptive SwiftUI layout (`ViewThatFits`, grids, size classes, and platform conditionals) over device-specific coordinates. +- Keep content typography separate from navigation, controls, metadata, and Settings typography. Follow the repo's `readerTypeface` boundary. + +## 8. Concurrency and ownership + +- Keep presentation state on the main actor. Put network, parsing, persistence, and other heavy work in the existing service or actor owner. +- Use Swift Concurrency with structured tasks, cancellation, and explicit `Sendable` boundaries. Do not start tasks from `body`. +- A timeline or gesture should publish values; it should not become a second data manager, queue, retry loop, or persistence path. +- Preserve the repo's ownership and injection rules. Do not add hidden global fallbacks or parallel animation coordinators. + +## Quick reference + +| Need | SwiftUI/repo choice | +| --- | --- | +| Immediate press feedback | State mutation at touch-down | +| Continuous drag | `DragGesture` + `@GestureState` | +| Momentum landing | `predictedEndTranslation` + spring | +| Default spring | `.spring(response: 0.3...0.4, dampingFraction: 1.0)` | +| Interrupt a transition | Retarget the current presented state | +| Dense animated drawing | `TimelineView` + one `Canvas` | +| Static hold | Pause the timeline and retain the settled frame | +| App-owned glass | `digestsGlassSurface(...)` | +| Glass morph grouping | `DigestsGlassEffectContainer` | +| Reduced motion | Cross-fade or settle immediately | +| Custom control semantics | Label, value, and named accessibility action | +| Connected navigation | Follow `animation_architecture.md`; match only meaningful elements | + +## Process + +1. Define the user's intent, the direct manipulation value, and the committed state. +2. Prototype the gesture and its interruption path before polishing materials or decoration. +3. Test touch, keyboard/pointer, VoiceOver, Dynamic Type, Reduce Motion, light/dark appearance, rotation, and split-view changes. +4. Check for ownership violations: no work in `body`, no duplicate manager, no raw glass API, no ambient navigation animation. +5. Review motion at normal speed and in slow motion. Keep only motion that explains state, direction, hierarchy, or completion. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb9ecbd7..0a9e2a75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -137,12 +137,26 @@ jobs: if: ${{ matrix.run_on == 'windows-latest' }} shell: pwsh run: | - $output = cargo run -p app_shell -- --fail-start 2>&1 | Out-String + # This step expects the binary to fail, so cargo exits non-zero and + # writes to stderr. Both trip pwsh: a native command's stderr through + # a 2>&1 pipeline can raise a terminating NativeCommandError, and the + # runner appends `exit $LASTEXITCODE`, which would propagate cargo's + # exit 2 as a step failure even when the assertions pass. + # + # So: redirect all streams to a file rather than piping, suppress + # native-error promotion, and exit explicitly. + $ErrorActionPreference = 'Continue' + $PSNativeCommandUseErrorActionPreference = $false + cargo run -p app_shell -- --fail-start *> app_shell_fail_start.log $exitCode = $LASTEXITCODE + $output = Get-Content app_shell_fail_start.log -Raw Write-Output $output if ($exitCode -ne 2) { - throw "expected transactional startup failure to exit 2, got $exitCode" + Write-Output "expected transactional startup failure to exit 2, got $exitCode" + exit 1 } if (-not $output.Contains("APP_SHELL_FAIL_START_REACHED")) { - throw "transactional start callback marker was not emitted" + Write-Output "transactional start callback marker was not emitted" + exit 1 } + exit 0 diff --git a/Cargo.lock b/Cargo.lock index 36be076d..d2cb4ecf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1312,7 +1312,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "gpui_util", "indexmap", @@ -1878,7 +1878,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "proc-macro2", "quote", @@ -3069,7 +3069,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "accesskit", "anyhow", @@ -3340,7 +3340,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "accesskit", "accesskit_unix", @@ -3394,7 +3394,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "accesskit", "accesskit_macos", @@ -3441,7 +3441,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3452,7 +3452,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "console_error_panic_hook", "gpui", @@ -3465,7 +3465,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "schemars", "serde", @@ -3475,7 +3475,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "anyhow", "log", @@ -3485,7 +3485,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3509,7 +3509,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "anyhow", "bytemuck", @@ -3538,7 +3538,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "accesskit", "accesskit_windows", @@ -3854,7 +3854,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "anyhow", "async-compression", @@ -4868,7 +4868,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "anyhow", "bindgen", @@ -5797,7 +5797,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "collections", "serde", @@ -6835,7 +6835,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "derive_refineable", ] @@ -7291,7 +7291,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "async-task", "backtrace", @@ -7949,7 +7949,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "heapless", "log", @@ -9334,7 +9334,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "anyhow", "async-fs", @@ -9373,7 +9373,7 @@ dependencies = [ [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "perf", "quote", @@ -11187,7 +11187,7 @@ dependencies = [ [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "anyhow", "chrono", @@ -11204,7 +11204,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" dependencies = [ "tracing", "tracing-subscriber", @@ -11215,7 +11215,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/BumpyClock/gpui?rev=6646f575c0dc5febbba6791df805e2b6bc2ff3de#6646f575c0dc5febbba6791df805e2b6bc2ff3de" +source = "git+https://github.com/BumpyClock/gpui?rev=1eeb173f71fad65c25cc8ea22319715703f05b5b#1eeb173f71fad65c25cc8ea22319715703f05b5b" [[package]] name = "zune-core" diff --git a/Cargo.toml b/Cargo.toml index 2cc8a4d7..b0bdb708 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,8 +37,8 @@ gpui-component-macros = { path = "crates/macros", version = "0.6.0" } gpui-component-assets = { path = "crates/assets", version = "0.6.0" } story = { path = "crates/story" } -gpui = { git = "https://github.com/BumpyClock/gpui", rev = "6646f575c0dc5febbba6791df805e2b6bc2ff3de", features = ["font-kit"] } -gpui_platform = { git = "https://github.com/BumpyClock/gpui", rev = "6646f575c0dc5febbba6791df805e2b6bc2ff3de", features = ["font-kit"] } +gpui = { git = "https://github.com/BumpyClock/gpui", rev = "1eeb173f71fad65c25cc8ea22319715703f05b5b", features = ["font-kit"] } +gpui_platform = { git = "https://github.com/BumpyClock/gpui", rev = "1eeb173f71fad65c25cc8ea22319715703f05b5b", features = ["font-kit"] } gpui-macros = "0.2.2" sum-tree = { version = "0.2.0", package = "zed-sum-tree" } # reqwest = { version = "0.12.15-zed", package = "zed-reqwest" } diff --git a/crates/story/src/stories/command_palette_story.rs b/crates/story/src/stories/command_palette_story.rs index 3be81e3b..a81309eb 100644 --- a/crates/story/src/stories/command_palette_story.rs +++ b/crates/story/src/stories/command_palette_story.rs @@ -49,58 +49,67 @@ impl CommandPaletteStory { } } - fn show_static_palette(&mut self, window: &mut Window, cx: &mut Context) { - let items = vec![ + fn static_items() -> Vec { + #[cfg(target_os = "macos")] + const PRIMARY_MODIFIER: &str = "cmd"; + #[cfg(not(target_os = "macos"))] + const PRIMARY_MODIFIER: &str = "ctrl"; + + let shortcut = |key: &str| format!("{PRIMARY_MODIFIER}-{key}"); + + vec![ CommandPaletteItem::new("file.new", "New File") .category("File") .icon(IconName::Plus) - .shortcut("cmd-n") + .shortcut(shortcut("n")) .keyword("create"), CommandPaletteItem::new("file.open", "Open File") .category("File") .icon(IconName::FolderOpen) - .shortcut("cmd-o") + .shortcut(shortcut("o")) .keyword("browse"), CommandPaletteItem::new("file.save", "Save File") .category("File") .icon(IconName::File) - .shortcut("cmd-s"), + .shortcut(shortcut("s")), CommandPaletteItem::new("file.save-all", "Save All") .category("File") .icon(IconName::File) - .shortcut("cmd-shift-s"), + .shortcut(shortcut("shift-s")), CommandPaletteItem::new("edit.undo", "Undo") .category("Edit") .icon(IconName::Undo) - .shortcut("cmd-z"), + .shortcut(shortcut("z")), CommandPaletteItem::new("edit.redo", "Redo") .category("Edit") .icon(IconName::Redo) - .shortcut("cmd-shift-z"), + .shortcut(shortcut("shift-z")), CommandPaletteItem::new("edit.copy", "Copy") .category("Edit") .icon(IconName::Copy) - .shortcut("cmd-c"), + .shortcut(shortcut("c")), CommandPaletteItem::new("search.find", "Find") .category("Search") .icon(IconName::Search) - .shortcut("cmd-f") + .shortcut(shortcut("f")) .keyword("locate"), CommandPaletteItem::new("search.replace", "Replace") .category("Search") .icon(IconName::Replace) - .shortcut("cmd-r"), + .shortcut(shortcut("r")), CommandPaletteItem::new("view.terminal", "Toggle Terminal") .category("View") .icon(IconName::SquareTerminal) - .shortcut("cmd-`"), + .shortcut(shortcut("`")), CommandPaletteItem::new("view.sidebar", "Toggle Sidebar") .category("View") .icon(IconName::PanelLeft) - .shortcut("cmd-b"), - ]; + .shortcut(shortcut("b")), + ] + } - let provider = Arc::new(StaticProvider::new(items)); + fn show_static_palette(&mut self, window: &mut Window, cx: &mut Context) { + let provider = Arc::new(StaticProvider::new(Self::static_items())); let handle = CommandPalette::open(window, cx, provider); cx.subscribe(&handle.state(), move |this, _state, event, cx| { @@ -112,6 +121,20 @@ impl CommandPaletteStory { .detach(); } + fn show_loading_palette(&mut self, window: &mut Window, cx: &mut Context) { + let provider = Arc::new(StaticProvider::new(Self::static_items())); + let config = CommandPaletteConfig { + status_provider: Some(Arc::new(|_| Some("Indexing workspace…".into()))), + ..Default::default() + }; + CommandPalette::open_with_config(window, cx, provider, config); + } + + fn show_empty_palette(&mut self, window: &mut Window, cx: &mut Context) { + let provider = Arc::new(StaticProvider::new(Vec::new())); + CommandPalette::open(window, cx, provider); + } + fn show_async_palette(&mut self, window: &mut Window, cx: &mut Context) { let provider = Arc::new(AsyncDemoProvider::new()); let handle = CommandPalette::open(window, cx, provider); @@ -178,7 +201,11 @@ impl AsyncDemoProvider { CommandPaletteItem::new("static.settings", "Settings") .category("Static") .icon(IconName::Settings) - .shortcut("cmd-,"), + .shortcut(if cfg!(target_os = "macos") { + "cmd-," + } else { + "ctrl-," + }), CommandPaletteItem::new("static.about", "About") .category("Static") .icon(IconName::Info), @@ -262,15 +289,38 @@ impl Render for CommandPaletteStory { v_flex() .gap_6() .child( - section("Basic Usage") - .child("Open a command palette with static items and fuzzy search.") + section("Visual Review") .child( - Button::new("show-static") - .outline() - .label("Open Static Palette") - .on_click(cx.listener(|this, _, window, cx| { - this.show_static_palette(window, cx) - })), + "Replay deterministic states for screenshots and motion recording.", + ) + .child( + h_flex() + .gap_2() + .flex_wrap() + .child( + Button::new("show-static") + .outline() + .label("Replay Open Motion") + .on_click(cx.listener(|this, _, window, cx| { + this.show_static_palette(window, cx) + })), + ) + .child( + Button::new("show-loading") + .outline() + .label("Review Loading State") + .on_click(cx.listener(|this, _, window, cx| { + this.show_loading_palette(window, cx) + })), + ) + .child( + Button::new("show-empty") + .outline() + .label("Review Empty State") + .on_click(cx.listener(|this, _, window, cx| { + this.show_empty_palette(window, cx) + })), + ), ), ) .child( diff --git a/crates/story/src/stories/dropdown_button_story.rs b/crates/story/src/stories/dropdown_button_story.rs index 3a21f18b..1b9d9d2b 100644 --- a/crates/story/src/stories/dropdown_button_story.rs +++ b/crates/story/src/stories/dropdown_button_story.rs @@ -128,29 +128,218 @@ impl Render for DropdownButtonStory { ), ) .child( - section("Dropdown Button").child( - DropdownButton::new("btn0") - .primary() - .button(Button::new("primary-action").label("Primary Dropdown")) - .when(self.compact, |this| this.compact()) - .loading(self.loading) - .disabled(self.disabled) - .selected(selected) - .dropdown_menu_with_anchor(Corner::BottomRight, move |this, _, _| { - this.menu_with_check( - "Disabled", - disabled, - Box::new(ButtonAction::Disabled), + section("Interactive split") + .sub_title("Tab moves from the primary action to the menu trigger") + .child( + DropdownButton::new("btn0") + .primary() + .button(Button::new("primary-action").label("Run task")) + .when(self.compact, |this| this.compact()) + .loading(self.loading) + .disabled(self.disabled) + .selected(selected) + .dropdown_menu_with_anchor(Corner::BottomRight, move |this, _, _| { + this.menu_with_check( + "Disabled", + disabled, + Box::new(ButtonAction::Disabled), + ) + .menu_with_check( + "Loading", + loading, + Box::new(ButtonAction::Loading), + ) + .menu_with_check( + "Selected", + selected, + Box::new(ButtonAction::Selected), + ) + .menu_with_check( + "Compact", + compact, + Box::new(ButtonAction::Compact), + ) + }), + ), + ) + .child( + section("State review") + .sub_title("Stable states for screenshot comparison") + .child( + h_flex() + .w_full() + .flex_wrap() + .justify_center() + .gap_6() + .child( + v_flex() + .items_start() + .gap_2() + .child( + gpui::div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Default"), + ) + .child( + DropdownButton::new("state-default") + .button( + Button::new("state-default-action").label("Run"), + ) + .dropdown_menu(|menu, _, _| { + menu.menu( + "Run with options", + Box::new(ButtonAction::Selected), + ) + }), + ), ) - .menu_with_check("Loading", loading, Box::new(ButtonAction::Loading)) - .menu_with_check("Selected", selected, Box::new(ButtonAction::Selected)) - .menu_with_check( - "Compact", - compact, - Box::new(ButtonAction::Compact), + .child( + v_flex() + .items_start() + .gap_2() + .child( + gpui::div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Selected"), + ) + .child( + DropdownButton::new("state-selected") + .button( + Button::new("state-selected-action").label("Run"), + ) + .selected(true) + .dropdown_menu(|menu, _, _| { + menu.menu( + "Run with options", + Box::new(ButtonAction::Selected), + ) + }), + ), ) - }), - ), + .child( + v_flex() + .items_start() + .gap_2() + .child( + gpui::div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Loading"), + ) + .child( + DropdownButton::new("state-loading") + .button( + Button::new("state-loading-action") + .label("Running"), + ) + .loading(true) + .dropdown_menu(|menu, _, _| { + menu.menu( + "Run with options", + Box::new(ButtonAction::Selected), + ) + }), + ), + ) + .child( + v_flex() + .items_start() + .gap_2() + .child( + gpui::div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Disabled"), + ) + .child( + DropdownButton::new("state-disabled") + .button( + Button::new("state-disabled-action").label("Run"), + ) + .disabled(true) + .dropdown_menu(|menu, _, _| { + menu.menu( + "Run with options", + Box::new(ButtonAction::Selected), + ) + }), + ), + ), + ), + ) + .child( + section("Scale and density") + .sub_title( + "Caret stays optically quiet while the hit area tracks control height", + ) + .child( + v_flex() + .items_start() + .gap_2() + .child( + gpui::div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Extra small"), + ) + .child( + DropdownButton::new("size-xs") + .xsmall() + .button(Button::new("size-xs-action").label("Run")) + .dropdown_menu(|menu, _, _| { + menu.menu( + "Run with options", + Box::new(ButtonAction::Selected), + ) + }), + ), + ) + .child( + v_flex() + .items_start() + .gap_2() + .child( + gpui::div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Small"), + ) + .child( + DropdownButton::new("size-sm") + .small() + .button(Button::new("size-sm-action").label("Run")) + .dropdown_menu(|menu, _, _| { + menu.menu( + "Run with options", + Box::new(ButtonAction::Selected), + ) + }), + ), + ) + .child( + v_flex() + .items_start() + .gap_2() + .child( + gpui::div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Compact"), + ) + .child( + DropdownButton::new("size-compact") + .compact() + .button(Button::new("size-compact-action").label("Run")) + .dropdown_menu(|menu, _, _| { + menu.menu( + "Run with options", + Box::new(ButtonAction::Selected), + ) + }), + ), + ), ) .child( section("Borderless Modes") diff --git a/crates/story/src/stories/floating_sidebar_story.rs b/crates/story/src/stories/floating_sidebar_story.rs index efcd90e1..6e612da4 100644 --- a/crates/story/src/stories/floating_sidebar_story.rs +++ b/crates/story/src/stories/floating_sidebar_story.rs @@ -1,10 +1,15 @@ +use std::{cell::Cell, rc::Rc, time::Duration}; + use gpui::{ - App, AppContext, Context, Entity, FocusHandle, Focusable, IntoElement, ParentElement, Render, - Styled, Window, div, prelude::FluentBuilder as _, px, + AnimationExt as _, App, AppContext, Context, Entity, FocusHandle, Focusable, IntoElement, + ParentElement, Render, SharedString, Styled, Window, div, prelude::FluentBuilder as _, px, }; use gpui_component::{ - ActiveTheme, ElevationToken, FloatingSidebar, Icon, IconName, Side, h_flex, + ActiveTheme, ElevationToken, FloatingSidebar, Icon, IconName, Side, Sizable, StyledExt, + animation::{PresenceOptions, keyed_presence, reduced_motion, theme_animation}, + button::Button, + h_flex, radio::RadioGroup, sidebar::{SidebarFooter, SidebarGroup, SidebarHeader, SidebarMenu, SidebarMenuItem}, switch::Switch, @@ -13,9 +18,18 @@ use gpui_component::{ use crate::section; +const STORY_SIDEBAR_WIDTH: gpui::Pixels = px(240.0); +const STORY_COLLAPSED_WIDTH: gpui::Pixels = px(48.0); +const STORY_DEFAULT_INSET: gpui::Pixels = px(8.0); + pub struct FloatingSidebarStory { focus_handle: FocusHandle, collapsed: bool, + content_visual_offset: Rc>, + content_transition_from: gpui::Pixels, + content_transition_duration_ms: u16, + content_transition_generation: u64, + content_last_collapsed: bool, side: Side, wide_inset: bool, elevation: ElevationToken, @@ -26,6 +40,13 @@ impl FloatingSidebarStory { Self { focus_handle: cx.focus_handle(), collapsed: false, + content_visual_offset: Rc::new(Cell::new( + STORY_SIDEBAR_WIDTH + STORY_DEFAULT_INSET * 2.0, + )), + content_transition_from: STORY_SIDEBAR_WIDTH + STORY_DEFAULT_INSET * 2.0, + content_transition_duration_ms: 0, + content_transition_generation: 0, + content_last_collapsed: false, side: Side::Left, wide_inset: false, elevation: ElevationToken::Sm, @@ -45,79 +66,341 @@ impl FloatingSidebarStory { } fn render_example( - &self, + &mut self, id: &'static str, inset: gpui::Pixels, _window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let sidebar_width = px(240.0); - let collapsed_width = px(48.0); - let content_offset = if self.collapsed { - collapsed_width + let side = self.side; + let reduced_motion = reduced_motion(cx); + let motion = cx.theme().motion.clone(); + let content_target_offset = if self.collapsed { + STORY_COLLAPSED_WIDTH } else { - sidebar_width + STORY_SIDEBAR_WIDTH } + inset * 2.0; + if self.content_last_collapsed != self.collapsed { + self.content_last_collapsed = self.collapsed; + self.content_transition_generation += 1; + + self.content_transition_from = self.content_visual_offset.get(); + let full_distance = (STORY_SIDEBAR_WIDTH - STORY_COLLAPSED_WIDTH).as_f32(); + let remaining_distance = (content_target_offset - self.content_transition_from) + .as_f32() + .abs() + .min(full_distance); + let spring_duration_ms = motion.enter_duration_ms; + let base_duration_ms = if self.collapsed { + spring_duration_ms.max(motion.exit_duration_ms) + } else { + spring_duration_ms.max(motion.enter_duration_ms) + }; + self.content_transition_duration_ms = + ((f32::from(base_duration_ms) * remaining_distance / full_distance).round() as u16) + .max(1); + } + let content_presence = keyed_presence( + SharedString::from(format!("{id}-content-offset")), + !self.collapsed, + !reduced_motion, + Duration::from_millis(u64::from(self.content_transition_duration_ms.max(1))), + Duration::from_millis(u64::from(self.content_transition_duration_ms.max(1))), + PresenceOptions::default(), + _window, + cx, + ); + let content_transition = if content_presence.transition_active() { + theme_animation( + self.content_transition_duration_ms, + &motion.standard_easing, + reduced_motion, + ) + .map(|animation| { + ( + self.content_transition_from, + self.content_transition_generation, + animation, + ) + }) + } else { + self.content_visual_offset.set(content_target_offset); + None + }; let sidebar = FloatingSidebar::new(id) .side(self.side) .collapsed(self.collapsed) - .width(sidebar_width) + .width(STORY_SIDEBAR_WIDTH) .inset(inset) .elevation(self.elevation) - .header_with(|collapsed, _, _cx| { + .header_with(move |collapsed, _, cx| { SidebarHeader::new() - .child(Icon::new(IconName::PanelLeft).size_4()) - .when(!collapsed, |this| this.child("Workspace")) + .child( + h_flex() + .min_w_0() + .gap_2() + .child( + div() + .flex_shrink_0() + .size_6() + .items_center() + .justify_center() + .rounded(cx.theme().radius) + .bg(cx.theme().sidebar_primary) + .text_color(cx.theme().sidebar_primary_foreground) + .child( + Icon::new(if side.is_left() { + IconName::PanelLeft + } else { + IconName::PanelRight + }) + .size_4(), + ), + ) + .when(!collapsed, |this| { + this.child( + v_flex() + .min_w_0() + .line_height(px(16.0)) + .child(div().text_sm().font_medium().child("Granite")) + .child( + div() + .text_xs() + .text_color( + cx.theme().sidebar_foreground.opacity(0.64), + ) + .child("Product workspace"), + ), + ) + }), + ) .when(!collapsed, |this| { this.child(Icon::new(IconName::ChevronsUpDown).size_4()) }) }) .child( - SidebarGroup::new("Navigation").child(SidebarMenu::new().children([ - SidebarMenuItem::new("Overview").icon(IconName::LayoutDashboard), - SidebarMenuItem::new("Projects").icon(IconName::Folder), - SidebarMenuItem::new("Settings").icon(IconName::Settings2), - ])), + SidebarGroup::new("Navigation").child( + SidebarMenu::new().children([ + SidebarMenuItem::new("Overview") + .icon(IconName::LayoutDashboard) + .active(true), + SidebarMenuItem::new("Projects") + .icon(IconName::Folder) + .suffix(|_, cx| { + div() + .h_5() + .min_w_5() + .px_1() + .items_center() + .justify_center() + .rounded(cx.theme().radius) + .bg(cx.theme().sidebar_accent) + .text_xs() + .text_color(cx.theme().sidebar_accent_foreground) + .child("7") + }), + SidebarMenuItem::new("Inbox") + .icon(IconName::Inbox) + .suffix(|_, cx| { + div() + .h_5() + .min_w_5() + .px_1() + .items_center() + .justify_center() + .rounded(cx.theme().radius) + .bg(cx.theme().sidebar_accent) + .text_xs() + .text_color(cx.theme().sidebar_accent_foreground) + .child("3") + }), + SidebarMenuItem::new("Settings").icon(IconName::Settings2), + ]), + ), ) - .footer_with(|collapsed, _, _| { + .footer_with(|collapsed, _, cx| { SidebarFooter::new() .child( h_flex() + .min_w_0() .gap_2() - .child(Icon::new(IconName::CircleUser)) - .when(!collapsed, |this| this.child("Avery")), + .child( + div() + .flex_shrink_0() + .size_6() + .items_center() + .justify_center() + .rounded(px(999.0)) + .bg(cx.theme().secondary) + .text_xs() + .font_medium() + .child("MC"), + ) + .when(!collapsed, |this| { + this.child( + v_flex() + .min_w_0() + .line_height(px(16.0)) + .child(div().text_sm().font_medium().child("Mira Chen")) + .child( + div() + .text_xs() + .text_color( + cx.theme().sidebar_foreground.opacity(0.64), + ) + .child("mira@granite.tools"), + ), + ) + }), ) .when(!collapsed, |this| { this.child(Icon::new(IconName::ChevronsUpDown).size_4()) }) }); + let content = div() + .size_full() + .when(self.side.is_left(), |this| this.pl(content_target_offset)) + .when(self.side.is_right(), |this| this.pr(content_target_offset)) + .child( + v_flex() + .size_full() + .gap_4() + .p_4() + .child( + h_flex() + .justify_between() + .gap_3() + .child( + v_flex() + .gap_1() + .child(div().text_lg().font_semibold().child("Overview")) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("Current workspace activity"), + ), + ) + .child( + Button::new("floating-sidebar-canvas-toggle") + .small() + .outline() + .icon(if self.collapsed { + if self.side.is_left() { + IconName::PanelLeftOpen + } else { + IconName::PanelRightOpen + } + } else if self.side.is_left() { + IconName::PanelLeftClose + } else { + IconName::PanelRightClose + }) + .label(if self.collapsed { "Expand" } else { "Collapse" }) + .on_click(cx.listener(|this, _, _, cx| { + this.collapsed = !this.collapsed; + cx.notify(); + })), + ), + ) + .child( + h_flex() + .gap_3() + .child( + v_flex() + .flex_1() + .gap_2() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().card) + .p_3() + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("Open projects"), + ) + .child(div().text_xl().font_semibold().child("7")), + ) + .child( + v_flex() + .flex_1() + .gap_2() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().card) + .p_3() + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("Ready for review"), + ) + .child(div().text_xl().font_semibold().child("3")), + ), + ) + .child( + v_flex() + .gap_3() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().card) + .p_3() + .child(div().text_sm().font_medium().child("Recent activity")) + .child( + h_flex() + .gap_2() + .text_sm() + .child(Icon::new(IconName::Folder).size_4()) + .child("Mira updated desktop navigation"), + ) + .child( + h_flex() + .gap_2() + .text_sm() + .child(Icon::new(IconName::Settings2).size_4()) + .child("Workspace permissions changed"), + ), + ), + ) + .map(move |content| { + let Some((from, generation, animation)) = content_transition else { + return content.into_any_element(); + }; + let visual_offset = self.content_visual_offset.clone(); + content + .with_animation( + format!("floating-sidebar-content-offset-{generation}"), + animation, + move |content, delta| { + let offset = from + (content_target_offset - from) * delta; + visual_offset.set(offset); + if side.is_left() { + content.pl(offset) + } else { + content.pr(offset) + } + }, + ) + .into_any_element() + }); + div() .relative() - .w(px(640.0)) - .h(px(360.0)) + .w(px(720.0)) + .h(px(420.0)) .overflow_hidden() .rounded(cx.theme().radius_lg) .border_1() .border_color(cx.theme().border) + .bg(cx.theme().background) + .child(content) .child(sidebar) - .child( - div() - .size_full() - .pt(px(16.0)) - .when(self.side.is_left(), |this| this.pl(content_offset)) - .when(self.side.is_right(), |this| this.pr(content_offset)) - .child( - div() - .rounded(cx.theme().radius) - .bg(cx.theme().secondary) - .p_4() - .text_sm() - .text_color(cx.theme().secondary_foreground) - .child("Main content area"), - ), - ) } } @@ -151,7 +434,9 @@ impl Render for FloatingSidebarStory { .gap_4() .child( h_flex() - .gap_3() + .flex_wrap() + .gap_x_4() + .gap_y_3() .child( Switch::new("floating-sidebar-collapsed") .label("Collapsed") diff --git a/crates/story/src/stories/menu_story.rs b/crates/story/src/stories/menu_story.rs index a4fd67c4..5ee78b04 100644 --- a/crates/story/src/stories/menu_story.rs +++ b/crates/story/src/stories/menu_story.rs @@ -5,7 +5,7 @@ use gpui::{ use gpui_component::{ ActiveTheme as _, IconName, Side, StyledExt, button::Button, - h_flex, + flyout_secondary_foreground, h_flex, menu::{ContextMenuExt, DropdownMenu as _, PopupMenuItem}, v_flex, }; @@ -69,32 +69,32 @@ impl MenuStory { fn new(_: &mut Window, _: &mut Context) -> Self { Self { check_side: None, - message: "".to_string(), + message: "Open a menu and choose an action.".to_string(), } } fn on_copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { - self.message = "You have clicked copy".to_string(); + self.message = "Copied selection".to_string(); cx.notify() } fn on_cut(&mut self, _: &Cut, _: &mut Window, cx: &mut Context) { - self.message = "You have clicked cut".to_string(); + self.message = "Cut selection".to_string(); cx.notify() } fn on_paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context) { - self.message = "You have clicked paste".to_string(); + self.message = "Pasted from clipboard".to_string(); cx.notify() } fn on_search_all(&mut self, _: &SearchAll, _: &mut Window, cx: &mut Context) { - self.message = "You have clicked search all".to_string(); + self.message = "Opened workspace search".to_string(); cx.notify() } fn on_action_info(&mut self, info: &Info, _: &mut Window, cx: &mut Context) { - self.message = format!("You have clicked info: {}", info.0); + self.message = format!("Opened recent project {}", info.0 + 1); cx.notify() } @@ -107,7 +107,14 @@ impl MenuStory { Some(Side::Left) }; - self.message = format!("You have used check at side: {:?}", self.check_side); + self.message = format!( + "Check alignment: {}", + match self.check_side { + Some(Side::Left) => "left", + Some(Side::Right) => "right", + _ => "hidden", + } + ); cx.notify() } } @@ -129,246 +136,217 @@ impl Render for MenuStory { .min_h(px(400.)) .gap_6() .child( - section("Popup Menu") + section("Menu states") + .v_flex() + .gap_4() .child( - Button::new("popup-menu-1") - .outline() - .label("Edit") - .dropdown_menu(move |this, window, cx| { - this.link("About", "https://github.com/longbridge/gpui-component") - .check_side(check_side.unwrap_or(Side::Left)) - .separator() - .item(PopupMenuItem::new("Handle Click").on_click( - window.listener_for(&view, |this, _, _, cx| { - this.message = - "You have clicked Handle Click".to_string(); - cx.notify(); - }), - )) - .separator() - .menu("Copy", Box::new(Copy)) - .menu("Cut", Box::new(Cut)) - .menu("Paste", Box::new(Paste)) - .separator() - .menu_with_check( - format!("Check Side {:?}", check_side), - check_side.is_some(), - Box::new(ToggleCheck), - ) - .separator() - .menu_with_icon("Search", IconName::Search, Box::new(SearchAll)) - .separator() - .item( - PopupMenuItem::element(|_, cx| { - v_flex().child("Custom Element").child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child("This is sub-title"), + h_flex() + .gap_2() + .flex_wrap() + .child( + Button::new("menu-states") + .outline() + .label("Open menu") + .dropdown_menu(move |this, window, cx| { + this.check_side(check_side.unwrap_or(Side::Left)) + .label("Edit") + .menu_with_icon( + "Find in workspace", + IconName::Search, + Box::new(SearchAll), ) - }) - .on_click( - window.listener_for(&view, |this, _, _, cx| { - this.message = "You have clicked on custom element" - .to_string(); - cx.notify(); - }), - ), - ) - .menu_element_with_check( - check_side.is_some(), - Box::new(ToggleCheck), - |_, cx| { - h_flex().gap_1().child("Custom Element").child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child("checked"), + .menu("Copy", Box::new(Copy)) + .menu("Cut", Box::new(Cut)) + .menu_with_disabled("Paste", Box::new(Paste), true) + .separator() + .label("View") + .menu_with_check( + "Cycle check alignment", + check_side.is_some(), + Box::new(ToggleCheck), ) - }, - ) - .menu_element_with_icon( - IconName::Info, - Box::new(Info(0)), - |_, cx| { - h_flex().gap_1().child("Custom").child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child("element"), + .menu_with_icon_and_disabled( + "Reveal minimap", + IconName::Eye, + Box::new(Info(0)), + true, ) - }, - ) - .separator() - .menu_with_disabled("Disabled Item", Box::new(Info(0)), true) - .separator() - .submenu("Links", window, cx, |menu, _, _| { - menu.link_with_icon( - "GPUI Component", - IconName::GitHub, - "https://github.com/longbridge/gpui-component", - ) - .separator() - .link("GPUI", "https://gpui.rs") - .link("Zed", "https://zed.dev") - }) - .separator() - .submenu("Other Links", window, cx, |menu, _, _| { - menu.link("Crates", "https://crates.io") - .link("Rust Docs", "https://docs.rs") - }) - }), + .separator() + .item( + PopupMenuItem::element(|_, cx| { + v_flex().child("Inspect selection").child( + div() + .text_xs() + // Custom flyout content should use the + // flyout secondary role, not the window + // `muted_foreground`, which is sub-AA on + // the flyout material in dark themes. + .text_color( + flyout_secondary_foreground(cx), + ) + .child("Open details panel"), + ) + }) + .on_click(window.listener_for( + &view, + |this, _, _, cx| { + this.message = + "Opened selection details".to_string(); + cx.notify(); + }, + )), + ) + .separator() + .submenu_with_icon( + Some(IconName::FolderOpen.into()), + "Open recent", + window, + cx, + |menu, _, _| { + menu.menu("gpui-component", Box::new(Info(0))) + .menu("Atlas editor", Box::new(Info(1))) + .menu( + "Window shell demo", + Box::new(Info(2)), + ) + }, + ) + .separator() + .link_with_icon( + "Component docs", + IconName::ExternalLink, + "https://bumpyclock.github.io/gpui-component/", + ) + }), + ) + .child( + Button::new("menu-right-checks") + .outline() + .label("Right-side checks") + .dropdown_menu(|this, _, _| { + this.check_side(Side::Right) + .label("Panels") + .menu_with_check( + "Project navigator", + true, + Box::new(Info(0)), + ) + .menu_with_check( + "Debug console", + false, + Box::new(Info(1)), + ) + .menu_with_disabled("Timeline", Box::new(Info(2)), true) + }), + ), ) - .child(self.message.clone()), - ) - .child( - section("Context Menu") - .v_flex() - .gap_4() .child( - v_flex() - .w_full() - .p_4() + h_flex() + .gap_2() .items_center() - .justify_center() - .min_h_20() - .rounded_lg() - .border_2() - .border_dashed() + .p_3() + .rounded_md() + .border_1() .border_color(cx.theme().border) - .child("Right click to open ContextMenu") - .context_menu({ - move |this, window, cx| { - this.check_side(check_side.unwrap_or(Side::Left)) - .external_link_icon(false) - .link( - "About", - "https://github.com/longbridge/gpui-component", - ) - .separator() - .menu("Cut", Box::new(Cut)) - .menu("Copy", Box::new(Copy)) - .menu("Paste", Box::new(Paste)) - .separator() - .label("This is a label") - .menu_with_check( - format!("Check Side {:?}", check_side), - check_side.is_some(), - Box::new(ToggleCheck), - ) - .separator() - .submenu("Settings", window, cx, move |menu, _, _| { - menu.menu("Info 0", Box::new(Info(0))) - .separator() - .menu("Item 1", Box::new(Info(1))) - .menu("Item 2", Box::new(Info(2))) - }) - .separator() - .menu("Search All", Box::new(SearchAll)) - .separator() - } - }) .child( div() - .text_sm() + .text_xs() + .font_medium() .text_color(cx.theme().muted_foreground) - .child( - "You can right click anywhere in \ - this area to open the context menu.", - ), - ), - ) - .child( - div() - .id("other") - .flex() - .w_full() - .p_4() - .items_center() - .justify_center() - .min_h_20() - .rounded_lg() - .border_2() - .border_dashed() - .border_color(cx.theme().border) - .child("Here is another area with context menu.") - .context_menu({ - move |this, _, _| { - this.link( - "About", - "https://github.com/longbridge/gpui-component", - ) + .child("Last action"), + ) + .child(self.message.clone()), + ), + ) + .child( + section("Context menu").v_flex().gap_4().child( + v_flex() + .w_full() + .p_4() + .items_center() + .justify_center() + .min_h_20() + .rounded_md() + .border_1() + .border_dashed() + .border_color(cx.theme().border) + .child("Right-click to open") + .context_menu({ + move |this, window, cx| { + this.check_side(check_side.unwrap_or(Side::Left)) + .label("Selection") + .menu("Cut", Box::new(Cut)) + .menu("Copy", Box::new(Copy)) + .menu("Paste", Box::new(Paste)) .separator() - .menu("Item 1", Box::new(Info(1))) - } - }), - ) - .child( - div() - .id("other1") - .flex() - .w_full() - .p_4() - .items_center() - .justify_center() - .min_h_20() - .rounded_lg() - .border_2() - .border_dashed() - .border_color(cx.theme().border) - .child("ContextMenu area 1") - .context_menu({ - move |this, _, _| { - this.link( - "About", - "https://github.com/longbridge/gpui-component", - ) + .submenu("Refactor", window, cx, move |menu, _, _| { + menu.menu("Extract method", Box::new(Info(0))) + .menu("Rename symbol", Box::new(Info(1))) + .menu_with_disabled( + "Move to module", + Box::new(Info(2)), + true, + ) + }) .separator() - .menu("Item 1", Box::new(Info(1))) - } - }), - ), + .menu_with_icon( + "Find references", + IconName::Search, + Box::new(SearchAll), + ) + } + }) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("Reopen repeatedly to compare enter and exit motion."), + ), + ), ) .child( - section("Menu with scrollbar") + section("Scrollable menu") .child( Button::new("dropdown-menu-scrollable-1") .outline() - .label("Scrollable Menu (100 items)") + .label("100 items") .dropdown_menu_with_anchor(Corner::TopRight, move |this, _, _| { let mut this = this .scrollable(true) .max_h(px(300.)) - .label(format!("Total {} items", 100)); + .label("100 workspace files"); for i in 0..100 { if i > 0 && i % 5 == 0 { this = this.separator(); } this = this.menu( - SharedString::from(format!("Item {}", i)), + SharedString::from(format!( + "workspace-file-{:02}.rs", + i + 1 + )), Box::new(Info(i)), ) } - this.min_w(px(100.)) + this.min_w(px(200.)) }), ) .child( Button::new("dropdown-menu-scrollable-2") .outline() - .label("Scrollable Menu (5 items)") + .label("5 items") .dropdown_menu_with_anchor(Corner::TopRight, move |this, _, _| { let mut this = this .scrollable(true) .max_h(px(300.)) - .label(format!("Total {} items", 100)); + .label("5 recent files"); for i in 0..5 { this = this.menu( - SharedString::from(format!("Item {}", i)), + SharedString::from(format!("recent-file-{}.rs", i + 1)), Box::new(Info(i)), ) } - this.min_w(px(100.)) + this.min_w(px(180.)) }), ), ) diff --git a/crates/story/src/stories/popover_story.rs b/crates/story/src/stories/popover_story.rs index 6bd31379..d09cd866 100644 --- a/crates/story/src/stories/popover_story.rs +++ b/crates/story/src/stories/popover_story.rs @@ -1,7 +1,7 @@ use gpui::{ Action, App, AppContext, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement as _, Render, - Styled as _, Window, actions, div, px, + Styled as _, Window, actions, div, px, relative, }; use gpui_component::{ ActiveTheme, Anchor, StyledExt, WindowExt, @@ -236,16 +236,47 @@ impl Render for PopoverStory { .child( section("Basic Popover").child( Popover::new("popover-0") - .max_w(px(600.)) - .trigger(Button::new("btn").outline().label("Popover")) - .gap_2() + .trigger(Button::new("btn").outline().label("View build details")) + .w(px(320.)) .text_sm() - .w(px(400.)) - .child("Hello, this is a Popover.") - .child(Divider::horizontal()) .child( - "You can put any content here, including text,\ - buttons, forms, and more.", + v_flex() + .gap_4() + .child( + v_flex() + .child(div().font_semibold().child("Build completed")) + .child( + div() + .text_xs() + .line_height(relative(1.2)) + .text_color(cx.theme().muted_foreground) + .child("Finished 2 minutes ago"), + ), + ) + .child( + v_flex() + .gap_1() + .child( + h_flex() + .justify_between() + .child( + div() + .text_color(cx.theme().muted_foreground) + .child("Branch"), + ) + .child("main"), + ) + .child( + h_flex() + .justify_between() + .child( + div() + .text_color(cx.theme().muted_foreground) + .child("Duration"), + ) + .child("1m 47s"), + ), + ), ), ), ) @@ -314,27 +345,38 @@ impl Render for PopoverStory { Popover::new("popover-1") .trigger(Button::new("btn").outline().label("Style Popover")) .appearance(false) - .py_1() - .px_2() - .bg(cx.theme().primary) - .text_color(cx.theme().primary_foreground) - .max_w(px(600.)) - .rounded_sm() + .p_2() + .bg(cx.theme().secondary) + .text_color(cx.theme().secondary_foreground) + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) .text_sm() - .shadow_2xl() - .child("A styled Popover with custom background and text color."), + .shadow_sm() + .child("Custom surfaces retain the same radius and restrained depth."), ), ) .child( section("Default Open").child( Popover::new("default-open-popover") .default_open(true) + .w(px(280.)) .trigger( Button::new("default-open-btn") - .label("Default Open") + .label("Replay open motion") .outline(), ) - .child("This popover is open by default when first rendered."), + .child( + v_flex() + .gap_1() + .child(div().font_semibold().child("Motion baseline")) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("Click the trigger twice to close and replay."), + ), + ), ), ) .child( diff --git a/crates/ui/src/accordion.rs b/crates/ui/src/accordion.rs index cd4784ee..60005b32 100644 --- a/crates/ui/src/accordion.rs +++ b/crates/ui/src/accordion.rs @@ -9,8 +9,7 @@ use gpui::{ use crate::{ ActiveTheme as _, Icon, IconName, Sizable, Size, animation::{ - PresenceOptions, PresencePhase, SpringPreset, keyed_presence, point_to_point_animation, - spring_preset_animation, spring_preset_duration_ms, + PresenceOptions, PresencePhase, keyed_presence, spring_animation, standard_animation, }, global_state::GlobalState, h_flex, v_flex, @@ -251,21 +250,15 @@ impl RenderOnce for AccordionItem { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let reduced_motion = GlobalState::global(cx).reduced_motion(); let motion = cx.theme().motion.clone(); - let spring_preset = SpringPreset::Mild; - let layout_anim = point_to_point_animation(&motion, reduced_motion); + let layout_anim = standard_animation(&motion, reduced_motion); let close_anim = layout_anim.clone(); let open_layout_anim = layout_anim; - let open_transform_anim = spring_preset_animation(&motion, reduced_motion, spring_preset); - let chevron_open_anim = spring_preset_animation(&motion, reduced_motion, spring_preset); - let chevron_close_anim = spring_preset_animation(&motion, reduced_motion, spring_preset); + let open_transform_anim = spring_animation(&motion, reduced_motion); + let chevron_open_anim = spring_animation(&motion, reduced_motion); + let chevron_close_anim = spring_animation(&motion, reduced_motion); let presence_key = SharedString::from(format!("accordion-presence-{}", self.key_prefix)); - let presence_duration_ms = if reduced_motion { - motion.fast_duration_ms - } else { - spring_preset_duration_ms(&motion, spring_preset).max(motion.fast_duration_ms) - }; - let open_duration = Duration::from_millis(u64::from(presence_duration_ms)); - let close_duration = Duration::from_millis(u64::from(presence_duration_ms)); + let open_duration = Duration::from_millis(u64::from(motion.enter_duration_ms)); + let close_duration = Duration::from_millis(u64::from(motion.exit_duration_ms)); let presence = keyed_presence( presence_key, self.open, diff --git a/crates/ui/src/animation.rs b/crates/ui/src/animation.rs index c656087d..11db1cb3 100644 --- a/crates/ui/src/animation.rs +++ b/crates/ui/src/animation.rs @@ -1,7 +1,15 @@ -use gpui::{Animation, App, SharedString, Window, spring}; +use gpui::{ + Animation, AnimationExt as _, AnyElement, App, Axis, IntoElement, ParentElement as _, + SharedString, Styled as _, Window, div, px, spring, +}; use std::time::Duration; -use crate::ThemeMotion; +use crate::{ActiveTheme as _, ThemeMotion, global_state::GlobalState}; + +/// Returns whether motion should be reduced for the current component context. +pub fn reduced_motion(cx: &App) -> bool { + GlobalState::global(cx).reduced_motion() +} /// A cubic bezier function like CSS `cubic-bezier`. /// @@ -91,110 +99,89 @@ pub fn theme_animation(duration_ms: u16, easing: &str, reduced_motion: bool) -> Some(animation_with_theme_easing(anim, easing)) } -/// Fast invoke animation (187ms, fast_invoke_easing). -pub fn fast_invoke_animation(motion: &ThemeMotion, reduced_motion: bool) -> Option { +/// Enter animation: `enter` duration on the decelerate curve. For opacity/reveal +/// of surfaces that are appearing. +pub fn enter_animation(motion: &ThemeMotion, reduced_motion: bool) -> Option { theme_animation( - motion.fast_duration_ms, - &motion.fast_invoke_easing, + motion.enter_duration_ms, + &motion.decelerate_easing, reduced_motion, ) } -/// Soft dismiss animation (167ms, soft_dismiss_easing). -pub fn soft_dismiss_animation(motion: &ThemeMotion, reduced_motion: bool) -> Option { +/// Exit animation: `exit` duration on the standard curve. The one dismiss +/// motion — presence close windows must use [`exit_duration`] so this can play +/// to completion before its element unmounts. Decelerating rather than +/// accelerating: an accelerating opacity fade dumps most of its change into the +/// final frame and reads as a snap at 60Hz (measured ~5x the per-frame delta of +/// this curve's tail). +pub fn exit_animation(motion: &ThemeMotion, reduced_motion: bool) -> Option { theme_animation( - motion.soft_dismiss_duration_ms, - &motion.soft_dismiss_easing, + motion.exit_duration_ms, + &motion.standard_easing, reduced_motion, ) } -/// Point-to-point animation (187ms, point_to_point_easing). -pub fn point_to_point_animation(motion: &ThemeMotion, reduced_motion: bool) -> Option { +/// Point-to-point animation: `enter` duration on the standard curve. For moves +/// between two on-screen states (switch knobs, progress, widths). +pub fn standard_animation(motion: &ThemeMotion, reduced_motion: bool) -> Option { theme_animation( - motion.fast_duration_ms, - &motion.point_to_point_easing, + motion.enter_duration_ms, + &motion.standard_easing, reduced_motion, ) } -/// Fade animation (83ms, linear). +/// Fade animation (83ms, linear) for micro state changes. pub fn fade_animation(motion: &ThemeMotion, reduced_motion: bool) -> Option { theme_animation(motion.fade_duration_ms, &motion.fade_easing, reduced_motion) } -/// Strong invoke animation (667ms, strong_invoke_easing with overshoot bounce). -pub fn strong_invoke_animation(motion: &ThemeMotion, reduced_motion: bool) -> Option { +/// Emphasis animation: long overshoot curve for attention accents. +pub fn emphasis_animation(motion: &ThemeMotion, reduced_motion: bool) -> Option { theme_animation( - motion.strong_invoke_duration_ms, - &motion.strong_invoke_easing, + motion.emphasis_duration_ms, + &motion.emphasis_easing, reduced_motion, ) } -pub const DEFAULT_SPRING_DAMPING_RATIO: f32 = 0.75; -pub const DEFAULT_SPRING_FREQUENCY: f32 = 1.8; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SpringPreset { - Mild, - Medium, +/// The enter window as a [`Duration`] — how long a presence must keep an +/// entering element mounted (also the spring settle window). +pub fn enter_duration(motion: &ThemeMotion) -> Duration { + Duration::from_millis(u64::from(motion.enter_duration_ms)) } -/// Duration (ms) for a spring preset. -pub fn spring_preset_duration_ms(motion: &ThemeMotion, preset: SpringPreset) -> u16 { - match preset { - SpringPreset::Mild => motion.spring_mild_duration_ms, - SpringPreset::Medium => motion.spring_medium_duration_ms, - } +/// Two frames of headroom after the exit animation before unmount: presence +/// timers and the animation clock do not start on the same frame, and without +/// slack the last frames of a fade get clipped (measured as a visible pop). +/// The extra frames render at opacity 0, so the slack is invisible. +const EXIT_UNMOUNT_GRACE_MS: u16 = 33; + +/// The exit window as a [`Duration`] — how long a presence must keep an exiting +/// element mounted so [`exit_animation`] plays to completion. Slightly longer +/// than the animation itself; see [`EXIT_UNMOUNT_GRACE_MS`]. +pub fn exit_duration(motion: &ThemeMotion) -> Duration { + Duration::from_millis(u64::from(motion.exit_duration_ms + EXIT_UNMOUNT_GRACE_MS)) } -/// Spring animation preset for transform-only motion. -pub fn spring_preset_animation( - motion: &ThemeMotion, - reduced_motion: bool, - preset: SpringPreset, -) -> Option { +/// The one spring, for transform-only reveal motion. Settles within the `enter` +/// window. Unbounded easing: pair it with transforms, not opacity. +pub fn spring_animation(motion: &ThemeMotion, reduced_motion: bool) -> Option { if reduced_motion { return None; } - let (duration_ms, damping_ratio, frequency) = match preset { - SpringPreset::Mild => ( - motion.spring_mild_duration_ms, - motion.spring_mild_damping_ratio, - motion.spring_mild_frequency, - ), - SpringPreset::Medium => ( - motion.spring_medium_duration_ms, - motion.spring_medium_damping_ratio, - motion.spring_medium_frequency, - ), - }; - Some( - Animation::new(Duration::from_millis(u64::from(duration_ms))) - .with_unbounded_easing(spring(damping_ratio, frequency)), + Animation::new(enter_duration(motion)) + .with_unbounded_easing(spring(motion.spring_damping_ratio, motion.spring_frequency)), ) } /// Shared open/close durations for expand-collapse patterns. -pub fn expand_collapse_durations( - motion: &ThemeMotion, - reduced_motion: bool, - preset: SpringPreset, -) -> (Duration, Duration) { - let open_duration_ms = if reduced_motion { - motion.fast_duration_ms - } else { - spring_preset_duration_ms(motion, preset).max(motion.fast_duration_ms) - }; - let close_duration_ms = motion.soft_dismiss_duration_ms; - - ( - Duration::from_millis(u64::from(open_duration_ms)), - Duration::from_millis(u64::from(close_duration_ms)), - ) +pub fn expand_collapse_durations(motion: &ThemeMotion) -> (Duration, Duration) { + (enter_duration(motion), exit_duration(motion)) } /// Shared layout animation for expand-collapse wrappers. @@ -204,40 +191,12 @@ pub fn expand_collapse_layout_animation( entering: bool, ) -> Option { if entering { - theme_animation( - motion.fast_duration_ms, - &motion.fast_invoke_easing, - reduced_motion, - ) + enter_animation(motion, reduced_motion) } else { - theme_animation( - motion.soft_dismiss_duration_ms, - &motion.soft_dismiss_easing, - reduced_motion, - ) + exit_animation(motion, reduced_motion) } } -/// Shared subtle spring for transform-only content reveal motion. -/// -/// This is a semantic wrapper over `spring_preset_animation` for call sites that -/// animate inner content translation/scale/rotation during reveal, as opposed to -/// `expand_collapse_layout_animation` which is for layout-driven expand/collapse -/// wrappers. It delegates to `spring_preset_animation` with the provided motion, -/// reduced-motion flag, and spring preset. -pub fn subtle_reveal_transform_animation( - motion: &ThemeMotion, - reduced_motion: bool, - preset: SpringPreset, -) -> Option { - spring_preset_animation(motion, reduced_motion, preset) -} - -/// Spring invoke animation (uses unbounded easing, transform-only). -pub fn spring_invoke_animation(motion: &ThemeMotion, reduced_motion: bool) -> Option { - spring_preset_animation(motion, reduced_motion, SpringPreset::Mild) -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PresencePhase { Entering, @@ -378,10 +337,151 @@ pub fn keyed_presence( } } +/// The slide a flyout travels as it enters and exits, in logical pixels. +/// +/// `direction` is +1.0 when the surface opens away from its trigger along the +/// positive axis (downward / rightward) and -1.0 when flipped. +#[derive(Clone, Copy, Debug)] +pub struct FlyoutSlide { + pub axis: Axis, + pub direction: f32, + pub enter_distance: f32, + pub exit_distance: f32, +} + +impl FlyoutSlide { + /// Vertical slide with the shared flyout distances (4px in, 2px out). + pub fn vertical(direction: f32) -> Self { + Self { + axis: Axis::Vertical, + direction, + enter_distance: 4.0, + exit_distance: 2.0, + } + } + + /// Horizontal slide with the submenu distances (6px both ways). + pub fn horizontal(direction: f32) -> Self { + Self { + axis: Axis::Horizontal, + direction, + enter_distance: 6.0, + exit_distance: 6.0, + } + } + + /// Override the exit travel distance. + pub fn exit_distance(mut self, distance: f32) -> Self { + self.exit_distance = distance; + self + } + + fn offset(&self, distance: f32, progress: f32) -> gpui::Pixels { + px(distance * (1.0 - progress) * self.direction) + } +} + +/// The shared presence for flyout surfaces: enter/exit windows come from the +/// motion tokens (so an exit always outlives its animation) and reduced motion +/// applies the final state immediately. +pub fn flyout_presence( + key: SharedString, + open: bool, + options: PresenceOptions, + window: &mut Window, + cx: &mut App, +) -> PresenceTransition { + let motion = cx.theme().motion.clone(); + let reduced_motion = GlobalState::global(cx).reduced_motion(); + keyed_presence( + key, + open, + !reduced_motion, + enter_duration(&motion), + exit_duration(&motion), + options, + window, + cx, + ) +} + +/// The one flyout open/close motion, shared by popover, context menu, submenu, +/// select popup and hover card: enter is the spring sliding from the trigger +/// plus a standard fade, exit is a standard fade sliding back. +/// +/// Wrap the surface element with this at the point where its host already +/// paints it (inside any `deferred`/`anchored` — wrappers outside `anchored` +/// have no visual effect). `id_base` scopes the animation element ids per +/// surface instance. +pub fn flyout_motion( + id_base: impl Into, + presence: PresenceTransition, + slide: FlyoutSlide, + motion: &ThemeMotion, + reduced_motion: bool, + el: impl IntoElement, +) -> AnyElement { + if !presence.transition_active() { + return el.into_any_element(); + } + + let id_base = id_base.into(); + if matches!(presence.phase, PresencePhase::Entering) { + let transformed = if let Some(anim) = spring_animation(motion, reduced_motion) { + div() + .child(el.into_any_element()) + .with_animation( + SharedString::from(format!("{}-open-transform", id_base)), + anim, + move |el, delta| match slide.axis { + Axis::Vertical => el.translate_y(slide.offset(slide.enter_distance, delta)), + Axis::Horizontal => { + el.translate_x(slide.offset(slide.enter_distance, delta)) + } + }, + ) + .into_any_element() + } else { + el.into_any_element() + }; + if let Some(anim) = standard_animation(motion, reduced_motion) { + div() + .child(transformed) + .with_animation( + SharedString::from(format!("{}-open-fade", id_base)), + anim, + move |el, delta| el.opacity(presence.progress(delta).clamp(0.0, 1.0)), + ) + .into_any_element() + } else { + transformed + } + } else if let Some(anim) = exit_animation(motion, reduced_motion) { + div() + .child(el.into_any_element()) + .with_animation( + SharedString::from(format!("{}-close", id_base)), + anim, + move |el, delta| { + let progress = presence.progress(delta).clamp(0.0, 1.0); + let offset = slide.offset(slide.exit_distance, progress); + let el = el.opacity(progress); + match slide.axis { + Axis::Vertical => el.translate_y(offset), + Axis::Horizontal => el.translate_x(offset), + } + }, + ) + .into_any_element() + } else { + el.into_any_element() + } +} + #[cfg(test)] mod tests { use super::{ - cubic_bezier, cubic_bezier_unbounded, parse_cubic_bezier_easing, spring_invoke_animation, + cubic_bezier, cubic_bezier_unbounded, parse_cubic_bezier_easing, spring_animation, }; use crate::ThemeMotion; @@ -434,9 +534,9 @@ mod tests { } #[test] - fn spring_invoke_respects_reduced_motion() { + fn spring_respects_reduced_motion() { let motion = ThemeMotion::default(); - assert!(spring_invoke_animation(&motion, true).is_none()); - assert!(spring_invoke_animation(&motion, false).is_some()); + assert!(spring_animation(&motion, true).is_none()); + assert!(spring_animation(&motion, false).is_some()); } } diff --git a/crates/ui/src/badge.rs b/crates/ui/src/badge.rs index b4800a44..1cd085e7 100644 --- a/crates/ui/src/badge.rs +++ b/crates/ui/src/badge.rs @@ -5,7 +5,7 @@ use gpui::{ }; use crate::{ - ActiveTheme, Icon, Sizable, Size, StyledExt, animation::strong_invoke_animation, + ActiveTheme, Icon, Sizable, Size, StyledExt, animation::emphasis_animation, global_state::GlobalState, h_flex, white, }; @@ -126,7 +126,7 @@ impl RenderOnce for Badge { let animation = self.id.as_ref().and_then(|_| { let motion = &cx.theme().motion; let reduced_motion = GlobalState::global(cx).reduced_motion(); - strong_invoke_animation(motion, reduced_motion) + emphasis_animation(motion, reduced_motion) }); div() diff --git a/crates/ui/src/button/dropdown_button.rs b/crates/ui/src/button/dropdown_button.rs index 469c2943..14b830a2 100644 --- a/crates/ui/src/button/dropdown_button.rs +++ b/crates/ui/src/button/dropdown_button.rs @@ -7,7 +7,7 @@ use gpui::{ }; use crate::{ - Disableable, Icon, IconName, Selectable, Sizable, Size, StyledExt as _, + ActiveTheme, Disableable, Icon, IconName, Selectable, Sizable, Size, StyledExt as _, menu::{DropdownMenu, PopupMenu}, }; @@ -179,9 +179,9 @@ impl Selectable for DropdownButton { } impl RenderOnce for DropdownButton { - fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let grouped = self.button.is_some() && self.menu.is_some(); - let joined = grouped && self.bordered && !(self.variant.is_ghost() && !self.selected); + let joined = grouped && self.bordered && !self.variant.is_ghost(); let button_corners = if joined { Corners { top_left: true, @@ -214,6 +214,18 @@ impl RenderOnce for DropdownButton { Edges::all(self.bordered) }; let trigger_disabled = self.disabled || (grouped && self.loading); + let trigger_focus_background = cx.theme().selection; + let trigger_focus_foreground = cx.theme().foreground; + let trigger_size = if grouped { + match self.size { + Size::XSmall => Size::XSmall, + Size::Small => Size::Small, + Size::Medium | Size::Large => Size::Medium, + Size::Size(size) => Size::Size(size), + } + } else { + self.size + }; let icon = self .icon .unwrap_or_else(|| Icon::new(IconName::ChevronDown)); @@ -221,7 +233,7 @@ impl RenderOnce for DropdownButton { div() .id(self.id) .h_flex() - .when(grouped && !self.bordered, |this| this.gap_1()) + .when(grouped && !joined, |this| this.gap_1()) .refine_style(&self.style) .when_some(self.button, |this, button| { this.child( @@ -231,7 +243,7 @@ impl RenderOnce for DropdownButton { .border_edges(button_edges) .loading(self.loading) .selected(self.selected) - .disabled(self.disabled) + .disabled(self.disabled || self.loading) .when(self.compact, |this| this.compact()) .when(self.outline, |this| this.outline()) .with_size(self.size) @@ -251,8 +263,19 @@ impl RenderOnce for DropdownButton { .when(self.compact, |this| this.compact()) .when(self.outline, |this| this.outline()) .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)) - .with_size(self.size) + .with_size(trigger_size) + .when(grouped, |this| match self.size { + Size::XSmall => this.w_5().h_5(), + Size::Small => this.w_5().h_6(), + Size::Medium | Size::Large => this.w_6().h_8(), + Size::Size(size) => this.size(size), + }) .with_variant(self.variant) + .focus_visible(move |this| { + this.bg(trigger_focus_background) + .border_color(trigger_focus_background) + .text_color(trigger_focus_foreground) + }) .dropdown_menu_with_anchor(self.anchor, menu), ) }) diff --git a/crates/ui/src/checkbox.rs b/crates/ui/src/checkbox.rs index f363be2a..7c84866d 100644 --- a/crates/ui/src/checkbox.rs +++ b/crates/ui/src/checkbox.rs @@ -173,7 +173,8 @@ pub(crate) fn checkbox_check_icon( } if value_changed { - let duration = Duration::from_millis(u64::from(cx.theme().motion.fast_duration_ms)); + let duration = + Duration::from_millis(u64::from(cx.theme().motion.enter_duration_ms)); let animation = animation_with_theme_easing( Animation::new(duration), cx.theme().motion.fade_easing.as_ref(), diff --git a/crates/ui/src/closing_scope.rs b/crates/ui/src/closing_scope.rs new file mode 100644 index 00000000..df4515a4 --- /dev/null +++ b/crates/ui/src/closing_scope.rs @@ -0,0 +1,116 @@ +//! ClosingScope — exposes a root layer's closing state to its content. +//! +//! The Root keeps a closing dialog or sheet mounted for the exit window +//! ([`crate::animation::exit_duration`]) before unmounting it. This element is +//! how content inside that layer *learns* it is closing, so it can render its +//! own exit animation during that window: the layer wraps its content in a +//! `ClosingScope`, and content reads [`is_layer_closing`] during render. +//! +//! The value is pushed during layout as well as paint: entity children build +//! their element trees during the layout pass, so a paint-only scope (like the +//! reduced-motion scope) would be invisible to a child view's `render`. + +use gpui::{ + AnyElement, App, Bounds, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, + LayoutId, Pixels, Window, +}; + +use crate::global_state::GlobalState; + +/// Returns whether the enclosing root layer (dialog, sheet) is closing. +/// +/// Content hosted in a layer that defers its unmount (see +/// [`crate::dialog::Dialog::defer_close`]) can use this to switch to an exit +/// animation; the layer stays mounted for +/// [`crate::animation::exit_duration`], then is torn down regardless — the +/// window is the ceiling, there is no completion signal to forget. +pub fn is_layer_closing(cx: &App) -> bool { + GlobalState::global(cx).layer_closing() +} + +/// A wrapper element that provides the layer-closing context to its children. +pub struct ClosingScope { + closing: bool, + child: Option, +} + +impl ClosingScope { + /// Wrap `child`, exposing `closing` to everything inside it via + /// [`is_layer_closing`]. + pub fn new(closing: bool, child: impl IntoElement) -> Self { + Self { + closing, + child: Some(child.into_any_element()), + } + } + + fn scoped(&self, cx: &mut App, f: impl FnOnce(&mut App) -> R) -> R { + GlobalState::global_mut(cx).push_layer_closing(self.closing); + let result = f(cx); + GlobalState::global_mut(cx).pop_layer_closing(); + result + } +} + +impl IntoElement for ClosingScope { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +/// Layout state for ClosingScope, holds the child element. +pub struct ClosingScopeLayoutState { + child: AnyElement, +} + +impl Element for ClosingScope { + type RequestLayoutState = ClosingScopeLayoutState; + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let mut child = self.child.take().expect("ClosingScope child already taken"); + let layout_id = self.scoped(cx, |cx| child.request_layout(window, cx)); + (layout_id, ClosingScopeLayoutState { child }) + } + + fn prepaint( + &mut self, + _global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + self.scoped(cx, |cx| request_layout.child.prepaint(window, cx)); + } + + fn paint( + &mut self, + _global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + request_layout: &mut Self::RequestLayoutState, + _prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + self.scoped(cx, |cx| request_layout.child.paint(window, cx)); + } +} diff --git a/crates/ui/src/collapsible.rs b/crates/ui/src/collapsible.rs index 811005f6..953babcb 100644 --- a/crates/ui/src/collapsible.rs +++ b/crates/ui/src/collapsible.rs @@ -4,7 +4,7 @@ use gpui::{ }; use crate::{ - ActiveTheme, StyledExt, animation::fast_invoke_animation, global_state::GlobalState, v_flex, + ActiveTheme, StyledExt, animation::enter_animation, global_state::GlobalState, v_flex, }; /// Generous max for animated height reveal. Content fully visible @@ -67,7 +67,7 @@ impl RenderOnce for Collapsible { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let motion = &cx.theme().motion; let reduced_motion = GlobalState::global(cx).reduced_motion(); - let anim = fast_invoke_animation(motion, reduced_motion); + let anim = enter_animation(motion, reduced_motion); let mut non_content = Vec::new(); let mut content_elements = Vec::new(); diff --git a/crates/ui/src/command_palette/mod.rs b/crates/ui/src/command_palette/mod.rs index e94d4ec8..890f67b1 100644 --- a/crates/ui/src/command_palette/mod.rs +++ b/crates/ui/src/command_palette/mod.rs @@ -36,8 +36,6 @@ mod state; mod types; mod view; -use std::time::Duration; - pub use matcher::{FuzzyMatcherWrapper, NucleoMatcher}; pub use provider::{CommandPaletteProvider, StaticProvider}; pub use state::{CommandPaletteEvent, CommandPaletteState}; @@ -46,17 +44,13 @@ pub use types::{ CommandPaletteMatch, MatchedItem, }; -const REVEAL_DELAY_MS: u64 = 100; -const REVEAL_ANIMATION_DURATION_MS: u64 = 180; -pub(crate) const REVEAL_QUERY_DELAY: Duration = - Duration::from_millis(REVEAL_DELAY_MS + REVEAL_ANIMATION_DURATION_MS); - -pub(crate) fn reveal_delay(cx: &App) -> Duration { - Duration::from_millis(u64::from(cx.theme().motion.fade_duration_ms)) +pub(crate) fn reveal_delay(cx: &App) -> std::time::Duration { + std::time::Duration::from_millis(u64::from(cx.theme().motion.fade_duration_ms)) } -pub(crate) fn reveal_animation_duration(cx: &App) -> Duration { - Duration::from_millis(u64::from(cx.theme().motion.fast_duration_ms)) +pub(crate) fn reveal_query_delay(cx: &App) -> std::time::Duration { + reveal_delay(cx) + + std::time::Duration::from_millis(u64::from(cx.theme().motion.enter_duration_ms)) } use gpui::{App, AppContext as _, Entity, KeyBinding, ParentElement as _, Styled, Window, actions}; @@ -162,6 +156,9 @@ impl CommandPalette { .overlay_closable(true) .keyboard(true) .animate(false) + // No dialog-chrome animation, but keep the dialog mounted + // through the exit window so the palette can animate out. + .defer_close(true) .appearance(false) .close_button(false) .p_0() diff --git a/crates/ui/src/command_palette/state.rs b/crates/ui/src/command_palette/state.rs index 036a74d6..cc945750 100644 --- a/crates/ui/src/command_palette/state.rs +++ b/crates/ui/src/command_palette/state.rs @@ -1,8 +1,8 @@ //! State management for the Command Palette. -use super::REVEAL_QUERY_DELAY; use super::matcher::{FuzzyMatcherWrapper, NucleoMatcher}; use super::provider::CommandPaletteProvider; +use super::reveal_query_delay; use super::types::{ CommandMatcher, CommandMatcherKind, CommandPaletteConfig, CommandPaletteItem, MatchedItem, }; @@ -80,7 +80,7 @@ impl CommandPaletteState { let reveal_deadline = if GlobalState::global(cx).reduced_motion() { None } else { - Some(Instant::now() + REVEAL_QUERY_DELAY) + Some(Instant::now() + reveal_query_delay(cx)) }; let mut state = Self { diff --git a/crates/ui/src/command_palette/view.rs b/crates/ui/src/command_palette/view.rs index 61927204..6d7c6619 100644 --- a/crates/ui/src/command_palette/view.rs +++ b/crates/ui/src/command_palette/view.rs @@ -1,22 +1,24 @@ //! View component for the Command Palette. use super::provider::CommandPaletteProvider; +use super::reveal_delay; use super::state::{CommandPaletteEvent, CommandPaletteState}; use super::types::{CommandPaletteConfig, MatchedItem}; -use super::{reveal_animation_duration, reveal_delay}; use crate::actions::{Cancel, Confirm, SelectDown, SelectUp}; -use crate::animation::spring_invoke_animation; +use crate::animation::{exit_animation, fade_animation, spring_animation, standard_animation}; use crate::global_state::GlobalState; use crate::input::{Input, InputEvent, InputState}; use crate::kbd::Kbd; +use crate::spinner::Spinner; use crate::{ - ActiveTheme, Icon, IconName, Sizable, Size, SurfaceContext, SurfacePreset, - VirtualListScrollHandle, WindowExt as _, h_flex, v_flex, v_virtual_list, + ActiveTheme, FlyoutTokens, Icon, IconName, Sizable, Size, SurfaceContext, SurfacePreset, + VirtualListScrollHandle, WindowExt as _, flyout_secondary_foreground, h_flex, is_layer_closing, + v_flex, v_virtual_list, }; use gpui::{ - Animation, AnimationExt, App, AppContext as _, Context, ElementId, Entity, FocusHandle, - Focusable, InteractiveElement, IntoElement, KeyBinding, ParentElement, Pixels, Render, - ScrollStrategy, SharedString, Size as GpuiSize, Styled, Subscription, Task, Window, div, + AnimationExt, App, AppContext as _, Context, ElementId, Entity, FocusHandle, Focusable, + InteractiveElement, IntoElement, KeyBinding, ParentElement, Pixels, Render, ScrollStrategy, + SharedString, Size as GpuiSize, Styled, Subscription, Task, Window, div, prelude::FluentBuilder, px, }; use std::rc::Rc; @@ -24,20 +26,13 @@ use std::sync::Arc; const CONTEXT: &str = "CommandPalette"; -// Height constants for layout calculations -const HEADER_HEIGHT: f32 = 52.0; -const FOOTER_HEIGHT: f32 = 36.0; -const SECTION_HEADER_HEIGHT: f32 = 28.0; +// Height constants for layout calculations. Row geometry (section header height, +// list padding, row radius) comes from `FlyoutTokens` so the palette stays in the +// same geometry family as menus and select popups. +const HEADER_HEIGHT: f32 = 56.0; +const FOOTER_HEIGHT: f32 = 40.0; const EMPTY_STATE_HEIGHT: f32 = 120.0; -/// Monotonic spring-like easing (critically damped) to avoid bounce oscillation. -fn gentle_spring(delta: f32) -> f32 { - let t = delta.clamp(0.0, 1.0); - let omega = 10.0; - let value = 1.0 - (1.0 + omega * t) * (-omega * t).exp(); - value.clamp(0.0, 1.0) -} - /// A render row for the command palette list. #[derive(Clone)] enum CommandPaletteRow { @@ -103,7 +98,7 @@ impl CommandPaletteView { input_state, focus_handle, scroll_handle: VirtualListScrollHandle::new(), - item_height: px(48.), + item_height: px(46.), did_focus: false, list_revealed: false, _reveal_task: None, @@ -173,18 +168,21 @@ impl CommandPaletteView { } fn on_action_cancel(&mut self, _: &Cancel, _: &mut Window, cx: &mut Context) { + cx.stop_propagation(); self.state.update(cx, |state, cx| { state.dismiss(cx); }); } fn on_action_confirm(&mut self, _: &Confirm, _: &mut Window, cx: &mut Context) { + cx.stop_propagation(); self.state.update(cx, |state, cx| { state.confirm(cx); }); } fn on_action_select_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context) { + cx.stop_propagation(); self.state.update(cx, |state, cx| { state.select_prev(cx); }); @@ -192,6 +190,7 @@ impl CommandPaletteView { } fn on_action_select_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context) { + cx.stop_propagation(); self.state.update(cx, |state, cx| { state.select_next(cx); }); @@ -216,31 +215,46 @@ impl CommandPaletteView { _window: &mut Window, cx: &mut Context, ) -> impl IntoElement { + let tokens = FlyoutTokens::new(cx); let item_data = item.item.clone(); let match_info = item.match_info.clone(); let disabled = item_data.disabled; let show_inline_category = show_category && !item_data.category.is_empty(); + let secondary_foreground = flyout_secondary_foreground(cx); let shortcut_element = item_data .shortcut .as_ref() - .and_then(|s| gpui::Keystroke::parse(s).ok().map(|k| Kbd::new(k))); + .and_then(|s| gpui::Keystroke::parse(s).ok()) + .map(|keystroke| { + Kbd::new(keystroke) + .outline() + .when(!selected && !disabled, |this| { + this.text_color(secondary_foreground) + }) + .when(selected && !disabled, |this| { + this.bg(cx.theme().list_active) + .border_color(cx.theme().list_active_border) + .text_color(cx.theme().foreground) + }) + }); let has_shortcut = shortcut_element.is_some(); h_flex() .id(SharedString::from(format!("cmd-item-{}", item_index))) .w_full() .h(self.item_height) - .px_3() - .gap_3() + .px(tokens.item_padding_x) + .gap(tokens.accessory_gap) .items_center() - .rounded(cx.theme().radius) + .rounded(tokens.item_radius) .cursor_pointer() - .my_1() .when(disabled, |this| this.opacity(0.5).cursor_not_allowed()) .when(selected && !disabled, |this| { this.bg(cx.theme().list_active) - .text_color(cx.theme().accent_foreground) + .border_1() + .border_color(cx.theme().list_active_border) + .text_color(cx.theme().foreground) }) .when(!selected && !disabled, |this| { this.hover(|this| this.bg(cx.theme().list_hover)) @@ -257,59 +271,65 @@ impl CommandPaletteView { }), ) }) - // Icon - .when_some(item_data.icon, |this, icon| { - this.child( - Icon::new(icon) - .size_4() - .text_color(cx.theme().muted_foreground), - ) - }) - // Title and subtitle + // Icon + title/subtitle share the tight icon gutter, matching a menu row. .child( - v_flex() + h_flex() .flex_1() + .items_center() .overflow_hidden() - .child( - div() - .text_sm() - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(if selected { - cx.theme().accent_foreground - } else { + .gap(tokens.icon_gap) + .when_some(item_data.icon, |this, icon| { + this.child(Icon::new(icon).size_4().flex_shrink_0().text_color( + if selected && !disabled { cx.theme().foreground - }) - .truncate() - .child(self.render_highlighted_text( - &item_data.title, - &match_info.title_ranges, - cx, - )), - ) - .when_some(item_data.subtitle.clone(), |this, subtitle| { - this.child( - div() - .text_xs() - .text_color(if selected { - cx.theme().accent_foreground.opacity(0.8) - } else { - cx.theme().muted_foreground - }) - .truncate() - .child(subtitle), - ) - }), + } else { + secondary_foreground + }, + )) + }) + .child( + v_flex() + .flex_1() + .overflow_hidden() + .child( + div() + .text_size(tokens.label_size) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(cx.theme().foreground) + .truncate() + .child(self.render_highlighted_text( + &item_data.title, + &match_info.title_ranges, + cx, + )), + ) + .when_some(item_data.subtitle.clone(), |this, subtitle| { + this.child( + div() + .text_size(tokens.meta_size) + .text_color(secondary_foreground) + .truncate() + .child(subtitle), + ) + }), + ), ) .when(show_inline_category || has_shortcut, |this| { this.child( h_flex() + .flex_shrink_0() .items_center() - .gap_2() + .gap(tokens.icon_gap) .when(show_inline_category, |this| { this.child( div() - .text_xs() - .text_color(cx.theme().muted_foreground) + .text_size(tokens.meta_size) + .text_color(if selected && !disabled { + cx.theme().foreground + } else { + secondary_foreground + }) + .truncate() .child(item_data.category.clone()), ) }) @@ -319,13 +339,19 @@ impl CommandPaletteView { } fn render_section_header(&self, title: SharedString, cx: &App) -> impl IntoElement { + let tokens = FlyoutTokens::new(cx); + div() .w_full() - .px_3() - .py_1() - .text_xs() - .font_weight(gpui::FontWeight::SEMIBOLD) - .text_color(cx.theme().muted_foreground) + .h(tokens.section_header_height) + .flex() + .items_center() + // Same left edge as a row's label, so headers and rows share one + // alignment line instead of stepping in and out by 4px. + .px(tokens.item_padding_x) + .text_size(tokens.meta_size) + .font_weight(tokens.section_weight) + .text_color(flyout_secondary_foreground(cx)) .child(title) } @@ -356,7 +382,7 @@ impl CommandPaletteView { if start < end { elements.push( div() - .text_color(cx.theme().accent) + .text_color(cx.theme().list_active_border) .font_weight(gpui::FontWeight::SEMIBOLD) .child(text[start..end].to_string()) .into_any_element(), @@ -374,18 +400,23 @@ impl CommandPaletteView { } fn render_footer(&self, status_text: Option, cx: &App) -> impl IntoElement { + let reduced_motion = GlobalState::global(cx).reduced_motion(); + let has_status = status_text.is_some(); + let tokens = FlyoutTokens::new(cx); + h_flex() .w_full() - .px_3() - .py_2() + .h(px(FOOTER_HEIGHT)) + // Align with row labels: list inset + row padding. + .px(tokens.inset + tokens.item_padding_x) .border_t_1() .border_color(cx.theme().border) .justify_between() - .text_xs() - .text_color(cx.theme().muted_foreground) + .text_size(tokens.meta_size) + .text_color(flyout_secondary_foreground(cx)) .child( h_flex() - .gap_3() + .gap_4() .child( h_flex() .gap_1() @@ -395,7 +426,7 @@ impl CommandPaletteView { .child( Kbd::new(gpui::Keystroke::parse("down").unwrap()).appearance(false), ) - .child("to navigate"), + .child("Navigate"), ) .child( h_flex() @@ -404,16 +435,7 @@ impl CommandPaletteView { Kbd::new(gpui::Keystroke::parse("enter").unwrap()) .appearance(false), ) - .child("to select"), - ) - .child( - h_flex() - .gap_1() - .child( - Kbd::new(gpui::Keystroke::parse("escape").unwrap()) - .appearance(false), - ) - .child("to close"), + .child("Run"), ), ) .when_some(status_text, |this, status| { @@ -421,22 +443,62 @@ impl CommandPaletteView { h_flex() .gap_2() .items_center() - .text_color(cx.theme().muted_foreground) - .child(Icon::new(IconName::LoaderCircle).size_4()) + .text_color(flyout_secondary_foreground(cx)) + .when(reduced_motion, |this| { + this.child(Icon::new(IconName::LoaderCircle).size_4()) + }) + .when(!reduced_motion, |this| { + this.child( + Spinner::new() + .icon(IconName::LoaderCircle) + .with_size(Size::Small) + .color(flyout_secondary_foreground(cx)), + ) + }) .child(status), ) }) + .when(!has_status, |this| { + this.child( + h_flex() + .gap_1() + .child( + Kbd::new(gpui::Keystroke::parse("escape").unwrap()).appearance(false), + ) + .child("Close"), + ) + }) } - fn render_empty(&self, cx: &App) -> impl IntoElement { + fn render_empty(&self, query_empty: bool, cx: &App) -> impl IntoElement { + let tokens = FlyoutTokens::new(cx); + v_flex() .size_full() .items_center() - .pt_6() - .gap_2() - .text_color(cx.theme().muted_foreground) - .child(Icon::new(IconName::Search).size_8().opacity(0.5)) - .child("No results found") + .justify_center() + .gap_1() + .text_color(flyout_secondary_foreground(cx)) + .child(Icon::new(IconName::Search).size_6().opacity(0.62)) + .child( + div() + .mt_1() + .text_size(tokens.label_size) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(cx.theme().foreground) + .child(if query_empty { + "No commands available" + } else { + "No commands found" + }), + ) + .when(!query_empty, |this| { + this.child( + div() + .text_size(tokens.meta_size) + .child("Try a different search."), + ) + }) } fn build_rows( @@ -500,6 +562,7 @@ impl Focusable for CommandPaletteView { impl Render for CommandPaletteView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let tokens = FlyoutTokens::new(cx); let state = self.state.read(cx); let config = state.config.clone(); let matched_items = state.matched_items.clone(); @@ -513,7 +576,7 @@ impl Render for CommandPaletteView { .map(|row| GpuiSize { width: px(0.), height: match row { - CommandPaletteRow::Header(_) => px(SECTION_HEADER_HEIGHT), + CommandPaletteRow::Header(_) => tokens.section_header_height, CommandPaletteRow::Item(_) => self.item_height, }, }) @@ -522,6 +585,7 @@ impl Render for CommandPaletteView { let show_categories = config.show_categories_inline; let show_footer = config.show_footer; + let query_empty = state.query.is_empty(); let footer_status = config .status_provider .as_ref() @@ -529,10 +593,8 @@ impl Render for CommandPaletteView { let max_height = px(config.max_height); let reduced_motion = GlobalState::global(cx).reduced_motion(); let motion = cx.theme().motion.clone(); - let reveal_animation_duration = reveal_animation_duration(cx); - let expand_animation = (!reduced_motion) - .then(|| Animation::new(reveal_animation_duration).with_easing(gentle_spring)); - let list_reveal_animation = spring_invoke_animation(&motion, reduced_motion); + let reveal_opacity_animation = fade_animation(&motion, reduced_motion); + let reveal_transform_animation = spring_animation(&motion, reduced_motion); // Focus input once after opening to avoid render jitter if !self.did_focus { @@ -554,12 +616,15 @@ impl Render for CommandPaletteView { let list_content_height = if row_count == 0 { px(EMPTY_STATE_HEIGHT) } else { - rows.iter().fold(px(0.0), |sum, row| { - sum + match row { - CommandPaletteRow::Header(_) => px(SECTION_HEADER_HEIGHT), - CommandPaletteRow::Item(_) => self.item_height, - } - }) + // Must match the padding actually applied to the list below, or the + // surface ends up taller than its content. + tokens.inset * 2. + + rows.iter().fold(px(0.0), |sum, row| { + sum + match row { + CommandPaletteRow::Header(_) => tokens.section_header_height, + CommandPaletteRow::Item(_) => self.item_height, + } + }) }; let list_height = max_height.min(list_content_height); let expanded_height = px(HEADER_HEIGHT) @@ -569,11 +634,9 @@ impl Render for CommandPaletteView { } else { px(0.0) }; - let collapsed_height = px(HEADER_HEIGHT); - let surface_ctx = SurfaceContext { - blur_enabled: GlobalState::global(cx).blur_enabled(), - }; + let closing = is_layer_closing(cx); + let surface_ctx = SurfaceContext::new(cx); let content = v_flex() .key_context(CONTEXT) @@ -582,11 +645,7 @@ impl Render for CommandPaletteView { .on_action(cx.listener(Self::on_action_confirm)) .on_action(cx.listener(Self::on_action_select_up)) .on_action(cx.listener(Self::on_action_select_down)) - .h(if self.list_revealed { - expanded_height - } else { - collapsed_height - }) + .h(expanded_height) .w_full() .overflow_hidden() // Search input @@ -596,7 +655,6 @@ impl Render for CommandPaletteView { .h(px(HEADER_HEIGHT)) .flex() .items_center() - .px_3() .border_b_1() .border_color(cx.theme().border) .child( @@ -605,84 +663,121 @@ impl Render for CommandPaletteView { .prefix( Icon::new(IconName::Search) .size_4() - .text_color(cx.theme().muted_foreground), + .text_color(flyout_secondary_foreground(cx)), ) .appearance(false) - .cleanable(true), + .cleanable(true) + // The input carries its own size-derived padding. Override it + // onto the row rail so the search glyph sits on the same + // vertical line as the result-row icons directly below it. + .px(tokens.inset + tokens.item_padding_x), ), ) // Results list .when(self.list_revealed, |this| { this.child({ - let list = div() + let results = v_flex() .w_full() - .h(list_height) - .overflow_hidden() - .when(row_count == 0, |this| this.child(self.render_empty(cx))) - .when(row_count > 0, |this| { - this.child( - v_virtual_list(cx.entity(), "command-palette-list", item_sizes, { - let matched_items = matched_items.clone(); - let rows = rows.clone(); - move |view, visible_range, window, cx| { - visible_range - .filter_map(|ix| { - let row = rows.get(ix)?; - match row { - CommandPaletteRow::Header(title) => Some( - view.render_section_header( - title.clone(), - cx, - ) - .into_any_element(), - ), - CommandPaletteRow::Item(item_index) => { - matched_items.get(*item_index).map(|item| { - view.render_item( - item, - *item_index, - selected_index == Some(*item_index), - show_categories, - window, - cx, - ) - .into_any_element() + .child( + div() + .w_full() + .h(list_height) + .overflow_hidden() + .when(row_count == 0, |this| { + this.child(self.render_empty(query_empty, cx)) + }) + .when(row_count > 0, |this| { + this.child( + v_virtual_list( + cx.entity(), + "command-palette-list", + item_sizes, + { + let matched_items = matched_items.clone(); + let rows = rows.clone(); + move |view, visible_range, window, cx| { + visible_range + .filter_map(|ix| { + let row = rows.get(ix)?; + match row { + CommandPaletteRow::Header( + title, + ) => Some( + view.render_section_header( + title.clone(), + cx, + ) + .into_any_element(), + ), + CommandPaletteRow::Item( + item_index, + ) => matched_items + .get(*item_index) + .map(|item| { + view.render_item( + item, + *item_index, + selected_index + == Some( + *item_index, + ), + show_categories, + window, + cx, + ) + .into_any_element() + }), + } }) - } + .collect() } - }) - .collect() - } - }) - .track_scroll(&self.scroll_handle) - .py_1(), - ) + }, + ) + .track_scroll(&self.scroll_handle) + .p(tokens.inset), + ) + }), + ) + .when(show_footer, |this| { + this.child(self.render_footer(footer_status.clone(), cx)) }); - if let Some(anim) = list_reveal_animation { - list.with_animation( - ElementId::NamedInteger("command-palette-list-reveal".into(), 1), - anim, - move |el, delta| { - let opacity = delta.clamp(0.0, 1.0); - let transform_progress = delta.max(0.0); - el.opacity(opacity) - .translate_y(px(6.0 * (1.0 - transform_progress))) - }, - ) - .into_any_element() + let transformed = if let Some(anim) = reveal_transform_animation { + div() + .child(results) + .with_animation( + ElementId::NamedInteger( + "command-palette-results-transform".into(), + 1, + ), + anim, + move |el, delta| el.translate_y(px(4.0 * (1.0 - delta.max(0.0)))), + ) + .into_any_element() } else { - list.into_any_element() + div().child(results).into_any_element() + }; + + if let Some(anim) = reveal_opacity_animation { + div() + .child(transformed) + .with_animation( + ElementId::NamedInteger( + "command-palette-results-opacity".into(), + 1, + ), + anim, + move |el, delta| el.opacity(delta.clamp(0.0, 1.0)), + ) + .into_any_element() + } else { + transformed } }) - }) - // Footer - .when(show_footer && self.list_revealed, |this| { - this.child(self.render_footer(footer_status.clone(), cx)) }); - // Wrap in glassmorphic surface let surface = SurfacePreset::flyout() + .with_radius(tokens.radius) .wrap_with_bounds( content, px(config.width), @@ -691,31 +786,49 @@ impl Render for CommandPaletteView { cx, surface_ctx, ) - .h(if self.list_revealed { - expanded_height - } else { - collapsed_height - }) + .h(expanded_height) .w(px(config.width)); - if self.list_revealed { - if let Some(reveal_animation) = expand_animation { - surface - .with_animation( - ElementId::NamedInteger("command-palette-expand".into(), 1), - reveal_animation, - move |this, delta| { - let height = - collapsed_height + (expanded_height - collapsed_height) * delta; - this.h(height) - }, - ) + // Enter motion for the surface itself (it previously popped in while + // only the results animated). Mount-only: the view is created on open, + // so the animation plays exactly once. On close the host dialog defers + // unmount (`defer_close`) and flags the subtree via `is_layer_closing`, + // which is when the exit below runs. + if closing { + return if let Some(anim) = exit_animation(&motion, reduced_motion) { + div() + .child(surface) + .with_animation("command-palette-exit", anim, |el, delta| { + let progress = (1.0 - delta).clamp(0.0, 1.0); + el.opacity(progress).translate_y(px(4.0 * delta)) + }) .into_any_element() } else { surface.into_any_element() - } + }; + } + + let open_fade_anim = standard_animation(&motion, reduced_motion); + let open_transform_anim = spring_animation(&motion, reduced_motion); + let transformed = if let Some(anim) = open_transform_anim { + div() + .child(surface) + .with_animation("command-palette-open-transform", anim, |el, delta| { + el.translate_y(px(4.0 * (1.0 - delta))) + }) + .into_any_element() } else { surface.into_any_element() + }; + if let Some(anim) = open_fade_anim { + div() + .child(transformed) + .with_animation("command-palette-open-fade", anim, |el, delta| { + el.opacity(delta.clamp(0.0, 1.0)) + }) + .into_any_element() + } else { + transformed } } } diff --git a/crates/ui/src/dialog.rs b/crates/ui/src/dialog.rs index 6ca43c7f..c09007a3 100644 --- a/crates/ui/src/dialog.rs +++ b/crates/ui/src/dialog.rs @@ -1,4 +1,4 @@ -use std::{rc::Rc, time::Duration}; +use std::rc::Rc; use gpui::{ AnimationExt as _, AnyElement, App, Bounds, BoxShadow, ClickEvent, Edges, ElementId, @@ -9,13 +9,12 @@ use gpui::{ use rust_i18n::t; use crate::{ - ActiveTheme as _, FocusTrapElement as _, IconName, Root, Sizable as _, StyledExt, + ActiveTheme as _, ClosingScope, FocusTrapElement as _, IconName, Root, Sizable as _, StyledExt, TITLE_BAR_HEIGHT, WindowExt as _, actions::{Cancel, Confirm}, animation::{ - PresenceOptions, PresencePhase, SpringPreset, fade_animation, fast_invoke_animation, - keyed_presence, point_to_point_animation, soft_dismiss_animation, spring_preset_animation, - spring_preset_duration_ms, + PresenceOptions, PresencePhase, enter_animation, exit_animation, fade_animation, + keyed_presence, spring_animation, standard_animation, }, button::{Button, ButtonVariant, ButtonVariants as _}, global_state::GlobalState, @@ -53,16 +52,6 @@ fn dialog_shadow(delta: f32) -> Vec { ] } -pub(crate) fn close_animation_duration(cx: &App) -> Duration { - let motion = &cx.theme().motion; - Duration::from_millis(u64::from( - motion - .fast_duration_ms - .max(motion.soft_dismiss_duration_ms) - .max(motion.fade_duration_ms), - )) -} - type RenderButtonFn = Box AnyElement>; type FooterFn = Box Vec>; @@ -132,6 +121,7 @@ pub struct Dialog { overlay_closable: bool, keyboard: bool, animate: bool, + defer_close: bool, appearance: bool, /// This will be change when open the dialog, the focus handle is create when open the dialog. @@ -165,6 +155,7 @@ impl Dialog { overlay: true, keyboard: true, animate: true, + defer_close: false, appearance: true, id: 0, layer_ix: 0, @@ -183,6 +174,14 @@ impl Dialog { self.animate && !GlobalState::global(cx).reduced_motion() } + /// Whether closing should keep the dialog mounted for the exit window + /// before unmounting: true when the chrome animates, or when the opener + /// requested [`Dialog::defer_close`] for content-driven exits. Reduced + /// motion always unmounts immediately. + pub(crate) fn should_defer_close(&self, cx: &App) -> bool { + (self.animate || self.defer_close) && !GlobalState::global(cx).reduced_motion() + } + /// Sets the title of the dialog. pub fn title(mut self, title: impl IntoElement) -> Self { self.title = Some(title.into_any_element()); @@ -326,6 +325,19 @@ impl Dialog { self } + /// Keep the dialog mounted through the exit window while closing, so + /// content can run its own exit animation even when the dialog chrome does + /// not animate (`animate(false)`). + /// + /// Content learns the closing state via [`crate::is_layer_closing`]. The + /// window is [`crate::animation::exit_duration`] and is also the ceiling — + /// the dialog is torn down when it elapses whether or not the content + /// finished; there is no completion signal to leak. + pub fn defer_close(mut self, defer: bool) -> Self { + self.defer_close = defer; + self + } + /// Set whether the dialog renders its default background, border, radius, and shadow. pub fn appearance(mut self, appearance: bool) -> Self { self.appearance = appearance; @@ -365,6 +377,11 @@ impl RenderOnce for Dialog { let has_title = self.title.is_some(); let reduced_motion = GlobalState::global(cx).reduced_motion(); let should_animate = self.should_animate(cx); + // The presence runs whenever the close is deferred — including + // content-driven exits with a non-animating chrome — so the Exiting + // phase exists for the whole deferral window. + let presence_active = self.should_defer_close(cx); + let closing = self.closing; let target_open = !self.closing; let appearance = self.appearance; @@ -462,17 +479,13 @@ impl RenderOnce for Dialog { paddings.top -= px(6.); } - let open_duration = Duration::from_millis(u64::from(if reduced_motion { - cx.theme().motion.fast_duration_ms - } else { - spring_preset_duration_ms(&cx.theme().motion, SpringPreset::Medium) - .max(cx.theme().motion.fast_duration_ms) - })); - let close_duration = close_animation_duration(cx); + let open_duration = crate::animation::enter_duration(&cx.theme().motion); + let close_duration = crate::animation::exit_duration(&cx.theme().motion); + let presence = keyed_presence( SharedString::from(format!("dialog-{}-presence", dialog_id)), target_open, - should_animate, + presence_active, open_duration, close_duration, PresenceOptions { @@ -484,21 +497,19 @@ impl RenderOnce for Dialog { let transition_active = presence.transition_active(); let motion = &cx.theme().motion; - let open_panel_layout_animation = point_to_point_animation(motion, reduced_motion) - .or_else(|| fast_invoke_animation(motion, reduced_motion)) + let open_panel_layout_animation = standard_animation(motion, reduced_motion) + .or_else(|| enter_animation(motion, reduced_motion)) .unwrap_or_else(|| { gpui::Animation::new(std::time::Duration::from_millis(u64::from( - motion.fast_duration_ms, - ))) - }); - let open_panel_transform_animation = - spring_preset_animation(motion, reduced_motion, SpringPreset::Medium); - let close_panel_animation = - soft_dismiss_animation(motion, reduced_motion).unwrap_or_else(|| { - gpui::Animation::new(std::time::Duration::from_millis(u64::from( - motion.soft_dismiss_duration_ms, + motion.enter_duration_ms, ))) }); + let open_panel_transform_animation = spring_animation(motion, reduced_motion); + let close_panel_animation = exit_animation(motion, reduced_motion).unwrap_or_else(|| { + gpui::Animation::new(std::time::Duration::from_millis(u64::from( + motion.exit_duration_ms, + ))) + }); let fade_in_animation = fade_animation(motion, reduced_motion).unwrap_or_else(|| { gpui::Animation::new(std::time::Duration::from_millis(u64::from( motion.fade_duration_ms, @@ -636,13 +647,19 @@ impl RenderOnce for Dialog { })) .child( div().flex_1().overflow_hidden().child( - // Body - v_flex() - .size_full() - .overflow_y_scrollbar() - .pl(paddings.left) - .pr(paddings.right) - .children(self.children), + // Body. ClosingScope exposes the closing + // state so content can run its own exit + // animation during the deferral window + // (see `Dialog::defer_close`). + ClosingScope::new( + closing, + v_flex() + .size_full() + .overflow_y_scrollbar() + .pl(paddings.left) + .pr(paddings.right) + .children(self.children), + ), ), ) .when_some(self.footer, |this, footer| { @@ -663,7 +680,16 @@ impl RenderOnce for Dialog { }) .map(move |this| { if !should_animate || !transition_active { - let progress = presence.progress(1.0); + // A non-animating chrome stays fully present + // while a content-driven deferred close plays + // out; the content owns the exit visuals. + let progress = if !should_animate + && matches!(presence.phase, PresencePhase::Exiting) + { + 1.0 + } else { + presence.progress(1.0) + }; this.when(appearance, |this| { this.shadow(dialog_shadow(progress)) }) @@ -731,7 +757,16 @@ impl RenderOnce for Dialog { ) .map(move |this| { if !should_animate || !transition_active { - this.opacity(presence.progress(1.0)).into_any_element() + // Hold the layer visible through a content-driven + // deferred close (see `Dialog::defer_close`). + let progress = if !should_animate + && matches!(presence.phase, PresencePhase::Exiting) + { + 1.0 + } else { + presence.progress(1.0) + }; + this.opacity(progress).into_any_element() } else { let fade_animation = if matches!(presence.phase, PresencePhase::Entering) { diff --git a/crates/ui/src/floating_sidebar/mod.rs b/crates/ui/src/floating_sidebar/mod.rs index 1ccb57a6..1e379d21 100644 --- a/crates/ui/src/floating_sidebar/mod.rs +++ b/crates/ui/src/floating_sidebar/mod.rs @@ -1,6 +1,8 @@ //! FloatingSidebar combines SidebarShell and Sidebar with internal resize handling. +use std::cell::Cell; use std::rc::Rc; +use std::time::Duration; use gpui::{ App, Element, ElementId, Hsla, IntoElement, MouseMoveEvent, MouseUpEvent, ParentElement, @@ -8,7 +10,9 @@ use gpui::{ }; use crate::{ - ElevationToken, Side, StyledExt, + ActiveTheme, ElevationToken, Side, StyledExt, + animation::{PresenceOptions, keyed_presence, theme_animation}, + global_state::GlobalState, sidebar::{COLLAPSED_WIDTH, DEFAULT_WIDTH, Sidebar, SidebarItem}, sidebar_shell::SidebarShell, }; @@ -16,32 +20,83 @@ use crate::{ /// Default values for floating sidebar configuration. const DEFAULT_MIN_WIDTH: Pixels = px(200.0); const DEFAULT_MAX_WIDTH: Pixels = px(400.0); -const DEFAULT_RESIZER_WIDTH: Pixels = px(6.0); +const DEFAULT_RESIZER_WIDTH: Pixels = px(8.0); const DEFAULT_INSET: Pixels = px(8.0); #[derive(Clone)] struct FloatingSidebarState { expanded_width: Pixels, + visual_width: Rc>, + transition_from: Pixels, + transition_duration_ms: u16, + transition_generation: u64, + last_collapsed: bool, resizing: bool, drag_origin_x: Pixels, drag_origin_width: Pixels, } impl FloatingSidebarState { - fn new(width: Pixels) -> Self { + fn new(width: Pixels, collapsed: bool) -> Self { + let visual_width = if collapsed { COLLAPSED_WIDTH } else { width }; Self { expanded_width: width, + visual_width: Rc::new(Cell::new(visual_width)), + transition_from: visual_width, + transition_duration_ms: 0, + transition_generation: 0, + last_collapsed: collapsed, resizing: false, drag_origin_x: px(0.0), drag_origin_width: width, } } + + fn begin_width_transition( + &mut self, + collapsed: bool, + target_width: Pixels, + expanded_width: Pixels, + base_duration_ms: u16, + ) { + if self.last_collapsed == collapsed { + return; + } + + self.last_collapsed = collapsed; + self.transition_from = self.visual_width.get(); + self.transition_duration_ms = scaled_width_transition_duration_ms( + self.transition_from, + target_width, + expanded_width, + base_duration_ms, + ); + self.transition_generation += 1; + } +} + +fn scaled_width_transition_duration_ms( + from: Pixels, + target: Pixels, + expanded_width: Pixels, + base_duration_ms: u16, +) -> u16 { + let full_distance = (expanded_width - COLLAPSED_WIDTH).as_f32().abs(); + if full_distance <= f32::EPSILON { + return 1; + } + + let remaining_distance = (target - from).as_f32().abs().min(full_distance); + ((f32::from(base_duration_ms) * remaining_distance / full_distance).round() as u16).max(1) } /// A floating sidebar that composes SidebarShell and Sidebar with internal resize handling. /// /// By default, the sidebar uses the theme's panel elevation. Explicit elevation overrides may /// require a larger inset near window edges to avoid native-window clipping. +/// +/// Collapse and expand transitions use the theme's point-to-point motion and become immediate +/// when reduced motion is enabled. #[derive(IntoElement)] pub struct FloatingSidebar { id: ElementId, @@ -256,7 +311,7 @@ impl RenderOnce for FloatingSidebar { let initial_width = width.max(min_width).min(max_width); let state_key = SharedString::from(format!("{}-floating-sidebar-state", id)); let state = window.use_keyed_state(state_key, cx, |_, _| { - FloatingSidebarState::new(initial_width) + FloatingSidebarState::new(initial_width, collapsed) }); let expanded_width = { @@ -265,18 +320,62 @@ impl RenderOnce for FloatingSidebar { if clamped_width != current_width { state.update(cx, |state, cx| { state.expanded_width = clamped_width; + if !state.last_collapsed { + state.visual_width.set(clamped_width); + } cx.notify(); }); } clamped_width }; - let shell_width = if collapsed { + let reduced_motion = GlobalState::global(cx).reduced_motion(); + let motion = cx.theme().motion.clone(); + let width_spring_duration_ms = motion.enter_duration_ms; + let target_width = if collapsed { COLLAPSED_WIDTH } else { expanded_width }; - let resizer_width = if collapsed { px(0.0) } else { resizer_width }; + let base_duration_ms = if collapsed { + width_spring_duration_ms.max(motion.exit_duration_ms) + } else { + width_spring_duration_ms.max(motion.enter_duration_ms) + }; + state.update(cx, |state, _| { + state.begin_width_transition(collapsed, target_width, expanded_width, base_duration_ms); + }); + let (from_width, width_duration_ms, transition_generation, visual_width) = { + let state = state.read(cx); + ( + state.transition_from, + state.transition_duration_ms, + state.transition_generation, + state.visual_width.clone(), + ) + }; + let width_presence = keyed_presence( + SharedString::from(format!("{}-floating-sidebar-width", id)), + !collapsed, + !reduced_motion, + Duration::from_millis(u64::from(width_duration_ms.max(1))), + Duration::from_millis(u64::from(width_duration_ms.max(1))), + PresenceOptions::default(), + window, + cx, + ); + let transition_active = !reduced_motion && width_presence.transition_active(); + if !transition_active { + visual_width.set(target_width); + } + let visual_collapsed = collapsed && !transition_active; + let resizer_width = if collapsed || transition_active { + px(0.0) + } else { + resizer_width + }; + let resizer_hover_bg = + resizer_hover_bg.unwrap_or_else(|| cx.theme().sidebar_primary.opacity(0.18)); let end_resize = { let state = state.clone(); @@ -306,6 +405,7 @@ impl RenderOnce for FloatingSidebar { let width = width.max(min_width).min(max_width); state.update(cx, |state, cx| { state.expanded_width = width; + state.visual_width.set(width); state.drag_origin_width = width; state.drag_origin_x = x; state.resizing = true; @@ -324,9 +424,9 @@ impl RenderOnce for FloatingSidebar { }; let mut shell = if side.is_left() { - SidebarShell::left(shell_width) + SidebarShell::left(target_width) } else { - SidebarShell::right(shell_width) + SidebarShell::right(target_width) }; shell = shell @@ -339,12 +439,25 @@ impl RenderOnce for FloatingSidebar { end_resize(window, cx); }); + if transition_active { + if let Some(animation) = + theme_animation(width_duration_ms, &motion.standard_easing, reduced_motion) + { + shell = shell.animate_width_from( + from_width, + SharedString::from(format!( + "{}-floating-sidebar-width-{}", + id, transition_generation + )), + animation, + visual_width, + ); + } + } if let Some(elevation) = elevation { shell = shell.elevation(elevation); } - if let Some(color) = resizer_hover_bg { - shell = shell.resizer_hover_bg(color); - } + shell = shell.resizer_hover_bg(resizer_hover_bg); if let Some(inset) = inset { shell = shell.inset(inset); } @@ -356,7 +469,7 @@ impl RenderOnce for FloatingSidebar { .child( sidebar .side(side) - .collapsed(collapsed) + .collapsed(visual_collapsed) .width(expanded_width) .animate_width(false) .bg(gpui::transparent_black()) @@ -460,6 +573,7 @@ impl Element for FloatingSidebarResizeTracker { } state.update(cx, |state, cx| { state.expanded_width = next_width; + state.visual_width.set(next_width); cx.notify(); }); } @@ -493,6 +607,7 @@ mod tests { assert_eq!(default_sidebar.width, DEFAULT_WIDTH); assert_eq!(default_sidebar.inset, Some(DEFAULT_INSET)); assert_eq!(default_sidebar.resizer_width, DEFAULT_RESIZER_WIDTH); + assert_eq!(default_sidebar.blur_enabled, None); let sidebar = FloatingSidebar::::new("floating-sidebar") .side(Side::Right) @@ -548,4 +663,21 @@ mod tests { .width(px(100.0)); assert_eq!(clamped_width.width, px(220.0)); } + + #[test] + fn test_width_transition_reversal_starts_from_current_visual_width() { + let mut state = FloatingSidebarState::new(px(260.0), false); + + state.visual_width.set(px(172.0)); + state.begin_width_transition(true, COLLAPSED_WIDTH, px(260.0), 300); + assert_eq!(state.transition_from, px(172.0)); + assert_eq!(state.transition_generation, 1); + assert!(state.transition_duration_ms < 300); + + state.visual_width.set(px(116.0)); + state.begin_width_transition(false, px(260.0), px(260.0), 300); + assert_eq!(state.transition_from, px(116.0)); + assert_eq!(state.transition_generation, 2); + assert!(state.transition_duration_ms < 300); + } } diff --git a/crates/ui/src/global_state.rs b/crates/ui/src/global_state.rs index 4c037e7b..ddf403e5 100644 --- a/crates/ui/src/global_state.rs +++ b/crates/ui/src/global_state.rs @@ -14,6 +14,8 @@ pub struct GlobalState { blur_enabled_stack: Vec, /// Stack for reduced_motion context values. reduced_motion_stack: Vec, + /// Stack for layer_closing context values (see [`crate::ClosingScope`]). + layer_closing_stack: Vec, /// Stack for floating inset values. floating_inset_stack: Vec, } @@ -24,6 +26,7 @@ impl GlobalState { text_view_state_stack: Vec::new(), blur_enabled_stack: vec![true], // Default to enabled reduced_motion_stack: vec![false], // Default to not reduced + layer_closing_stack: vec![false], // Default to not closing floating_inset_stack: vec![px(4.0)], } } @@ -91,6 +94,23 @@ impl GlobalState { } } + /// Returns whether the enclosing root layer (dialog, sheet) is closing. + pub fn layer_closing(&self) -> bool { + self.layer_closing_stack.last().copied().unwrap_or(false) + } + + /// Push a layer_closing value onto the context stack. + pub(crate) fn push_layer_closing(&mut self, closing: bool) { + self.layer_closing_stack.push(closing); + } + + /// Pop a layer_closing value from the context stack. + pub(crate) fn pop_layer_closing(&mut self) { + if self.layer_closing_stack.len() > 1 { + self.layer_closing_stack.pop(); + } + } + /// Returns the current floating inset from the context stack. pub fn floating_inset(&self) -> Pixels { self.floating_inset_stack.last().copied().unwrap_or(px(4.0)) diff --git a/crates/ui/src/hover_card.rs b/crates/ui/src/hover_card.rs index 99aeb3b8..0f1b6519 100644 --- a/crates/ui/src/hover_card.rs +++ b/crates/ui/src/hover_card.rs @@ -1,12 +1,17 @@ use gpui::{ AnyElement, App, Bounds, Context, ElementId, InteractiveElement as _, IntoElement, - ParentElement, Pixels, Render, RenderOnce, StatefulInteractiveElement, StyleRefinement, Styled, - Task, Window, div, prelude::FluentBuilder as _, + ParentElement, Pixels, Render, RenderOnce, SharedString, StatefulInteractiveElement, + StyleRefinement, Styled, Task, Window, div, prelude::FluentBuilder as _, }; use std::rc::Rc; use std::time::Duration; -use crate::{Anchor, ElementExt, StyledExt as _, popover::Popover}; +use crate::{ + ActiveTheme as _, Anchor, ElementExt, StyledExt as _, + animation::{FlyoutSlide, PresenceOptions, flyout_motion, flyout_presence}, + global_state::GlobalState, + popover::Popover, +}; /// A hover card element that displays content when hovering over a trigger element. /// @@ -293,10 +298,31 @@ impl RenderOnce for HoverCard { }), ); - if !open { + // Same presence and motion as Popover: enter spring+fade, exit fade — + // previously the card popped in and out with no transition at all. + let motion = cx.theme().motion.clone(); + let reduced_motion = GlobalState::global(cx).reduced_motion(); + let presence = flyout_presence( + SharedString::from(format!("hover-card-presence-{}", state.entity_id())), + open, + PresenceOptions::default(), + window, + cx, + ); + if !presence.should_render() { return root; } + let vertical_direction = if matches!( + self.anchor, + Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight + ) { + -1.0 + } else { + 1.0 + }; + let slide = FlyoutSlide::vertical(vertical_direction); + let popover_content = Popover::render_popover_content(self.anchor, self.appearance, window, cx) .overflow_hidden() @@ -307,7 +333,10 @@ impl RenderOnce for HoverCard { this.child(state.update(cx, |state, cx| (content)(state, window, cx))) }) .children(self.children) - .refine_style(&self.style); + .refine_style(&self.style) + .map(move |el| { + flyout_motion("hover-card", presence, slide, &motion, reduced_motion, el) + }); root.child(Popover::render_popover( self.anchor, diff --git a/crates/ui/src/input/popovers/code_action_menu.rs b/crates/ui/src/input/popovers/code_action_menu.rs index ca494ebc..9699dc32 100644 --- a/crates/ui/src/input/popovers/code_action_menu.rs +++ b/crates/ui/src/input/popovers/code_action_menu.rs @@ -12,7 +12,7 @@ const MAX_MENU_WIDTH: Pixels = px(320.); const MAX_MENU_HEIGHT: Pixels = px(480.); use crate::{ - ActiveTheme, IndexPath, Selectable, actions, h_flex, + ActiveTheme, FlyoutTokens, IndexPath, Selectable, actions, h_flex, input::{self, InputState, popovers::editor_popover}, list::{List, ListDelegate, ListEvent, ListState}, }; @@ -78,16 +78,18 @@ impl ParentElement for MenuItem { impl RenderOnce for MenuItem { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let item = self.item; + let tokens = FlyoutTokens::new(cx); let highlights = vec![]; h_flex() .id(self.ix) - .gap_2() - .p_1() - .text_xs() + .gap(tokens.icon_gap) + .py_1() + .px(tokens.item_padding_x) + .text_size(tokens.meta_size) .line_height(relative(1.)) - .rounded_sm() + .rounded(tokens.item_radius) .hover(|this| this.bg(cx.theme().accent.opacity(0.8))) .when(self.selected, |this| { this.bg(cx.theme().accent) diff --git a/crates/ui/src/input/popovers/completion_menu.rs b/crates/ui/src/input/popovers/completion_menu.rs index 24e00b3b..61ba9fd3 100644 --- a/crates/ui/src/input/popovers/completion_menu.rs +++ b/crates/ui/src/input/popovers/completion_menu.rs @@ -13,7 +13,7 @@ const MAX_MENU_HEIGHT: Pixels = px(240.); const POPOVER_GAP: Pixels = px(4.); use crate::{ - ActiveTheme, IndexPath, Selectable, actions, h_flex, + ActiveTheme, FlyoutTokens, IndexPath, Selectable, actions, h_flex, input::{ self, InputState, RopeExt, popovers::{editor_popover, render_markdown}, @@ -101,13 +101,16 @@ impl RenderOnce for CompletionMenuItem { }, )]; + let tokens = FlyoutTokens::new(cx); + h_flex() .id(self.ix) - .gap_2() - .p_1() - .text_xs() + .gap(tokens.icon_gap) + .py_1() + .px(tokens.item_padding_x) + .text_size(tokens.meta_size) .line_height(relative(1.)) - .rounded_sm() + .rounded(tokens.item_radius) .when(item.deprecated.unwrap_or(false), |this| this.line_through()) .hover(|this| this.bg(cx.theme().accent.opacity(0.8))) .when(self.selected, |this| { @@ -443,7 +446,7 @@ impl Render for CompletionMenu { div().child( editor_popover("completion-menu", cx) .w(MAX_MENU_WIDTH) - .px_2() + .p(FlyoutTokens::new(cx).item_padding_x) .child(render_markdown("doc", doc, window, cx)), ), ) diff --git a/crates/ui/src/input/popovers/hover_popover.rs b/crates/ui/src/input/popovers/hover_popover.rs index 453942b5..7fd03dc3 100644 --- a/crates/ui/src/input/popovers/hover_popover.rs +++ b/crates/ui/src/input/popovers/hover_popover.rs @@ -8,7 +8,7 @@ use gpui::{ }; use crate::{ - StyledExt, + FlyoutTokens, StyledExt, SurfaceContext, SurfacePreset, flyout_primary_foreground, input::{InputState, popovers::render_markdown}, }; @@ -189,16 +189,24 @@ impl Element for Popover { let is_open = *open_state.read(cx); + let tokens = FlyoutTokens::new(cx); let mut popover = deferred( - div() - .id("hover-popover-content") - .when(!is_open, |s| s.invisible()) - .flex_none() - .occlude() - .p_1() - .text_xs() - .popover_style(cx) - .shadow_md() + SurfacePreset::flyout() + .with_radius(tokens.radius) + .apply_material( + div() + .id("hover-popover-content") + .when(!is_open, |s| s.invisible()) + .flex_none() + .occlude(), + cx, + SurfaceContext::new(cx), + ) + .text_color(flyout_primary_foreground(cx)) + // Prose, not rows: pad to the shared row rhythm so hover docs line up + // with the completion menu labels they sit beside. + .p(tokens.item_padding_x) + .text_size(tokens.meta_size) .max_w(max_width) .max_h(max_height) .overflow_y_scroll() diff --git a/crates/ui/src/input/popovers/mod.rs b/crates/ui/src/input/popovers/mod.rs index 4abeef76..0c96b228 100644 --- a/crates/ui/src/input/popovers/mod.rs +++ b/crates/ui/src/input/popovers/mod.rs @@ -16,7 +16,7 @@ use gpui::{ }; use crate::{ - ActiveTheme, StyledExt as _, + ActiveTheme, FlyoutTokens, SurfaceContext, SurfacePreset, flyout_primary_foreground, text::{TextView, TextViewStyle}, }; @@ -69,13 +69,23 @@ pub(super) fn render_markdown( .selectable(true) } +/// Container for the editor's transient surfaces (completion, code actions, mouse +/// context menu, documentation). +/// +/// Shares the flyout material and geometry with the rest of the library, but stays +/// on the `meta` type step: these surfaces overlay live code, so density is +/// functional rather than decorative. pub(super) fn editor_popover(id: impl Into, cx: &App) -> Stateful
{ - div() - .id(id) - .flex_none() - .occlude() - .popover_style(cx) - .shadow_md() - .text_xs() - .p_1() + let tokens = FlyoutTokens::new(cx); + + SurfacePreset::flyout() + .with_radius(tokens.radius) + .apply_material( + div().id(id).flex_none().occlude(), + cx, + SurfaceContext::new(cx), + ) + .text_color(flyout_primary_foreground(cx)) + .text_size(tokens.meta_size) + .p(tokens.inset) } diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index b801bb91..c6896666 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -2,6 +2,7 @@ use gpui::{App, SharedString}; use std::ops::Deref; mod anchored; +mod closing_scope; mod element_ext; mod event; mod floating_sidebar; @@ -81,6 +82,7 @@ pub mod tree; pub use crate::Disableable; pub(crate) use anchored::*; +pub use closing_scope::{ClosingScope, is_layer_closing}; pub use element_ext::ElementExt; pub use event::InteractiveElementExt; pub use floating_sidebar::*; diff --git a/crates/ui/src/menu/context_menu.rs b/crates/ui/src/menu/context_menu.rs index 5ffa2099..bc2a02fc 100644 --- a/crates/ui/src/menu/context_menu.rs +++ b/crates/ui/src/menu/context_menu.rs @@ -1,14 +1,17 @@ use std::{cell::RefCell, rc::Rc}; use gpui::{ - AnimationExt as _, AnyElement, App, Context, Corner, DismissEvent, Element, ElementId, Entity, - Focusable, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, InteractiveElement, - IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, StyleRefinement, + AnyElement, App, Context, Corner, DismissEvent, Element, ElementId, Entity, Focusable, + GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, InteractiveElement, IntoElement, + MouseButton, MouseDownEvent, ParentElement, Pixels, Point, SharedString, StyleRefinement, Styled, Subscription, Window, anchored, deferred, div, prelude::FluentBuilder, px, }; use crate::{ - ActiveTheme, animation::fast_invoke_animation, global_state::GlobalState, menu::PopupMenu, + ActiveTheme, + animation::{FlyoutSlide, PresenceOptions, flyout_motion, flyout_presence}, + global_state::GlobalState, + menu::PopupMenu, }; /// A extension trait for adding a context menu to an element. @@ -167,16 +170,42 @@ impl Element for ContextMenu< }; let menu_view = state.shared_state.borrow().menu_view.clone(); let mut menu_element = None; - if open { + let reduced_motion = GlobalState::global(cx).reduced_motion(); + let motion = cx.theme().motion.clone(); + let presence = flyout_presence( + SharedString::from(format!("context-menu-presence-{:?}", this.id)), + open, + PresenceOptions::default(), + window, + cx, + ); + + if presence.should_render() { let has_menu_item = menu_view .as_ref() .map(|menu| !menu.read(cx).is_empty()) .unwrap_or(false); if has_menu_item { - let motion = &cx.theme().motion; - let reduced_motion = GlobalState::global(cx).reduced_motion(); - let anim = fast_invoke_animation(motion, reduced_motion); + let positioned_menu = anchored() + .position(position) + .snap_to_window_with_margin(px(8.)) + .anchor(anchor) + .when_some(menu_view, |this, menu| { + if open && !menu.focus_handle(cx).contains_focused(window, cx) { + menu.focus_handle(cx).focus(window, cx); + } + + this.child(menu) + }); + let animated_menu = flyout_motion( + "context-menu", + presence, + FlyoutSlide::vertical(1.0), + &motion, + reduced_motion, + positioned_menu, + ); menu_element = Some( deferred( @@ -187,35 +216,7 @@ impl Element for ContextMenu< .on_scroll_wheel(|_, _, cx| { cx.stop_propagation(); }) - .child( - anchored() - .position(position) - .snap_to_window_with_margin(px(8.)) - .anchor(anchor) - .when_some(menu_view, |this, menu| { - // Focus the menu, so that can be handle the action. - if !menu - .focus_handle(cx) - .contains_focused(window, cx) - { - menu.focus_handle(cx).focus(window, cx); - } - - this.child(menu) - }), - ) - .map(|el| { - if let Some(anim) = anim { - el.with_animation( - "context-menu-enter", - anim, - |el, delta| el.opacity(delta), - ) - .into_any_element() - } else { - el.into_any_element() - } - }), + .child(animated_menu), ), ) .with_priority(1) diff --git a/crates/ui/src/menu/dropdown_menu.rs b/crates/ui/src/menu/dropdown_menu.rs index f77edb13..b85a0a22 100644 --- a/crates/ui/src/menu/dropdown_menu.rs +++ b/crates/ui/src/menu/dropdown_menu.rs @@ -128,9 +128,9 @@ where let dismiss_duration = if reduced_motion { std::time::Duration::ZERO } else { - std::time::Duration::from_millis(u64::from( - cx.theme().motion.fade_duration_ms, - )) + // Must outlast the exit animation or + // the menu pops mid-fade. + crate::animation::exit_duration(&cx.theme().motion) }; cx.spawn({ let menu_state = menu_state.clone(); diff --git a/crates/ui/src/menu/menu_item.rs b/crates/ui/src/menu/menu_item.rs index c680f385..5f68496e 100644 --- a/crates/ui/src/menu/menu_item.rs +++ b/crates/ui/src/menu/menu_item.rs @@ -1,8 +1,11 @@ -use crate::{ActiveTheme, Disableable, StyledExt, h_flex}; +use crate::{ + ActiveTheme, Disableable, StyledExt, animation::spring_animation, global_state::GlobalState, + h_flex, +}; use gpui::{ - AnyElement, App, ClickEvent, ElementId, InteractiveElement, IntoElement, MouseButton, - ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement, - Styled, Window, prelude::FluentBuilder as _, + AnimationExt as _, AnyElement, App, ClickEvent, ElementId, InteractiveElement, IntoElement, + MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, + StyleRefinement, Styled, Window, prelude::FluentBuilder as _, px, }; use smallvec::SmallVec; @@ -83,7 +86,27 @@ impl ParentElement for MenuItemElement { } impl RenderOnce for MenuItemElement { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let selected_state = window.use_keyed_state( + ElementId::Name(SharedString::from(format!("{}:selected", self.group_name))), + cx, + |_, _| self.selected, + ); + let became_selected = { + let was_selected = *selected_state.read(cx); + if was_selected != self.selected { + selected_state.update(cx, |selected, _| *selected = self.selected); + } + !self.disabled && self.selected && !was_selected + }; + let reduced_motion = GlobalState::global(cx).reduced_motion(); + let selection_animation = became_selected + .then(|| spring_animation(&cx.theme().motion, reduced_motion)) + .flatten(); + let selected = self.selected; + let selection_animation_id = + SharedString::from(format!("{}:selection-feedback", self.group_name)); + h_flex() .id(self.id) .group(&self.group_name) @@ -101,12 +124,16 @@ impl RenderOnce for MenuItemElement { }) .when(!self.disabled, |this| { this.group_hover(self.group_name, |this| { - this.bg(cx.theme().accent) - .text_color(cx.theme().accent_foreground) + if selected { + this.bg(cx.theme().primary) + .text_color(cx.theme().primary_foreground) + } else { + this.bg(cx.theme().list_hover) + } }) .when(self.selected, |this| { - this.bg(cx.theme().accent) - .text_color(cx.theme().accent_foreground) + this.bg(cx.theme().primary) + .text_color(cx.theme().primary_foreground) }) .when_some(self.on_click, |this, on_click| { this.on_mouse_down(MouseButton::Left, move |_, _, cx| { @@ -116,8 +143,18 @@ impl RenderOnce for MenuItemElement { }) }) .when(self.disabled, |this| { - this.text_color(cx.theme().muted_foreground) + this.text_color(crate::flyout_disabled_foreground(cx)) }) .children(self.children) + .map(|this| { + if let Some(animation) = selection_animation { + this.with_animation(selection_animation_id, animation, |this, delta| { + this.translate_x(px(1.5 * (delta - 1.0))) + }) + .into_any_element() + } else { + this.into_any_element() + } + }) } } diff --git a/crates/ui/src/menu/popup_menu.rs b/crates/ui/src/menu/popup_menu.rs index 42c1ea46..414b2a14 100644 --- a/crates/ui/src/menu/popup_menu.rs +++ b/crates/ui/src/menu/popup_menu.rs @@ -1,23 +1,23 @@ use crate::actions::{Cancel, Confirm, SelectDown, SelectUp}; use crate::actions::{SelectLeft, SelectRight}; -use crate::animation::{ - PresenceOptions, PresencePhase, SpringPreset, keyed_presence, point_to_point_animation, - spring_preset_animation, spring_preset_duration_ms, -}; +use crate::animation::{FlyoutSlide, PresenceOptions, flyout_motion, flyout_presence}; use crate::global_state::GlobalState; use crate::menu::menu_item::MenuItemElement; use crate::scroll::ScrollableElement; -use crate::{ActiveTheme, ElementExt, Icon, IconName, Sizable as _, SurfaceContext}; +use crate::{ + ActiveTheme, ElementExt, FlyoutTokens, Icon, IconName, Sizable as _, SurfaceContext, + flyout_disabled_foreground, flyout_primary_foreground, flyout_secondary_foreground, + flyout_selected_secondary_foreground, +}; use crate::{Side, Size, SurfacePreset, h_flex, kbd::Kbd, v_flex}; use gpui::{ - Action, AnimationExt as _, AnyElement, App, AppContext, Bounds, Context, Corner, DismissEvent, - Edges, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, - KeyBinding, ParentElement, Pixels, Render, ScrollHandle, SharedString, - StatefulInteractiveElement, Styled, WeakEntity, Window, anchored, div, prelude::FluentBuilder, - px, rems, + Action, AnyElement, App, AppContext, Bounds, Context, Corner, DismissEvent, Edges, Entity, + EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding, + ParentElement, Pixels, Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, + WeakEntity, Window, anchored, div, prelude::FluentBuilder, px, }; -use gpui::{ClickEvent, Half, MouseDownEvent, OwnedMenuItem, Point, Subscription}; -use std::{rc::Rc, time::Duration}; +use gpui::{ClickEvent, MouseDownEvent, OwnedMenuItem, Point, Subscription}; +use std::rc::Rc; const CONTEXT: &str = "PopupMenu"; @@ -252,6 +252,16 @@ impl PopupMenuItem { matches!(self, PopupMenuItem::Separator) } + #[inline] + fn is_disabled(&self) -> bool { + matches!( + self, + PopupMenuItem::Item { disabled: true, .. } + | PopupMenuItem::ElementItem { disabled: true, .. } + | PopupMenuItem::Submenu { disabled: true, .. } + ) + } + fn has_left_icon(&self, check_side: Side) -> bool { match self { PopupMenuItem::Item { icon, checked, .. } => { @@ -980,10 +990,19 @@ impl PopupMenu { fn render_key_binding( &self, action: Option<&dyn Action>, + selected: bool, + disabled: bool, window: &mut Window, - _: &mut Context, + cx: &mut Context, ) -> Option { let action = action?; + let color = if selected && !disabled { + flyout_selected_secondary_foreground(cx) + } else if disabled { + flyout_disabled_foreground(cx) + } else { + flyout_secondary_foreground(cx) + }; match self .action_context @@ -998,6 +1017,7 @@ impl PopupMenu { this.p_0() .flex_nowrap() .border_0() + .text_color(color) .bg(gpui::transparent_white()) }) } @@ -1006,8 +1026,10 @@ impl PopupMenu { has_icon: bool, checked: bool, icon: Option, + selected: bool, + disabled: bool, _: &mut Window, - _: &mut Context, + cx: &mut Context, ) -> Option { if !has_icon { return None; @@ -1021,18 +1043,34 @@ impl PopupMenu { Icon::empty() }; - Some(icon.xsmall()) + let color = if selected && !disabled { + cx.theme().primary_foreground + } else if checked && !disabled { + cx.theme().primary + } else if disabled { + flyout_disabled_foreground(cx) + } else { + flyout_secondary_foreground(cx) + }; + + Some( + h_flex() + .w_4() + .justify_center() + .child(icon.xsmall().text_color(color)), + ) } #[inline] - fn max_width(&self) -> Pixels { - self.max_width.unwrap_or(px(500.)) + fn max_width(&self, cx: &App) -> Pixels { + self.max_width + .unwrap_or_else(|| FlyoutTokens::sized(self.size, cx).max_width) } /// Calculate the anchor corner and left offset for child submenu - fn update_submenu_menu_anchor(&mut self, window: &Window) { + fn update_submenu_menu_anchor(&mut self, window: &Window, cx: &App) { let bounds = self.bounds; - let max_width = self.max_width(); + let max_width = self.max_width(cx); let (anchor, left) = if max_width + bounds.origin.x > window.bounds().size.width { (Corner::TopRight, -px(16.)) } else { @@ -1057,30 +1095,38 @@ impl PopupMenu { ) -> MenuItemElement { let has_left_icon = options.has_left_icon; let is_left_check = options.check_side.is_left() && item.is_checked(); + let selected = self.selected_index == Some(ix); + let disabled = item.is_disabled(); let right_check_icon = if options.check_side.is_right() && item.is_checked() { - Some(Icon::new(IconName::Check).xsmall()) + Some( + Icon::new(IconName::Check) + .xsmall() + .text_color(if selected && !disabled { + cx.theme().primary_foreground + } else if disabled { + flyout_disabled_foreground(cx) + } else { + cx.theme().primary + }), + ) } else { None }; - let selected = self.selected_index == Some(ix); + /// Margin kept between a submenu and the window edge when it snaps. const EDGE_PADDING: Pixels = px(4.); - const INNER_PADDING: Pixels = px(8.); + let tokens = options.tokens; let is_submenu = matches!(item, PopupMenuItem::Submenu { .. }); let group_name = format!("{}:item-{}", cx.entity().entity_id(), ix); - - let (item_height, radius) = match self.size { - Size::Small => (px(20.), options.radius.half()), - _ => (px(26.), options.radius), - }; + let item_height = tokens.item_height; let this = MenuItemElement::new(ix, &group_name) .relative() - .text_sm() + .text_size(tokens.label_size) .py_0() - .px(INNER_PADDING) - .rounded(radius) + .px(tokens.item_padding_x) + .rounded(tokens.item_radius) .items_center() .selected(selected) .on_hover(cx.listener(move |this, hovered, _, cx| { @@ -1095,22 +1141,42 @@ impl PopupMenu { })); match item { + // The rule spans the full row box so it shares one alignment line with + // the row hover/selection background instead of introducing a second. PopupMenuItem::Separator => this .h_auto() .p_0() - .my_0p5() - .mx_neg_1() - .border_b(px(2.)) + .my(tokens.separator_margin) + .mx_0() + .border_b(px(1.)) .border_color(cx.theme().border) .disabled(true), - PopupMenuItem::Label(label) => this.disabled(true).cursor_default().child( - h_flex() - .cursor_default() - .items_center() - .gap_x_1() - .children(Self::render_icon(has_left_icon, false, None, window, cx)) - .child(div().flex_1().child(label.clone())), - ), + PopupMenuItem::Label(label) => this + .h(tokens.section_header_height) + .text_size(tokens.meta_size) + .font_weight(tokens.section_weight) + .disabled(true) + .cursor_default() + .child( + h_flex() + .cursor_default() + // `disabled(true)` above only makes the header inert; its text + // stays on the secondary step so it separates from genuinely + // disabled rows one step further down. + .text_color(flyout_secondary_foreground(cx)) + .items_center() + .gap(tokens.icon_gap) + .children(Self::render_icon( + has_left_icon, + false, + None, + false, + true, + window, + cx, + )) + .child(div().flex_1().child(label.clone())), + ), PopupMenuItem::ElementItem { render, icon, @@ -1128,16 +1194,18 @@ impl PopupMenu { .flex_1() .min_h(item_height) .items_center() - .gap_x_1() + .gap(tokens.icon_gap) .children(Self::render_icon( has_left_icon, is_left_check, icon.clone(), + selected, + *disabled, window, cx, )) .child((render)(window, cx)) - .children(right_check_icon.map(|icon| icon.ml_3())), + .children(right_check_icon.map(|icon| icon.ml(tokens.accessory_gap))), ), PopupMenuItem::Item { icon, @@ -1148,7 +1216,15 @@ impl PopupMenu { .. } => { let show_link_icon = *is_link && self.external_link_icon; - let key = self.render_key_binding(action.as_deref(), window, cx); + let key = + self.render_key_binding(action.as_deref(), selected, *disabled, window, cx); + let accessory_color = if selected && !disabled { + flyout_selected_secondary_foreground(cx) + } else if *disabled { + flyout_disabled_foreground(cx) + } else { + flyout_secondary_foreground(cx) + }; this.when(!disabled, |this| { this.on_click( @@ -1157,18 +1233,20 @@ impl PopupMenu { }) .disabled(*disabled) .h(item_height) - .gap_x_1() + .gap(tokens.icon_gap) .children(Self::render_icon( has_left_icon, is_left_check, icon.clone(), + selected, + *disabled, window, cx, )) .child( h_flex() .w_full() - .gap_3() + .gap(tokens.accessory_gap) .items_center() .justify_between() .when(!show_link_icon, |this| this.child(label.clone())) @@ -1178,12 +1256,12 @@ impl PopupMenu { h_flex() .w_full() .justify_between() - .gap_1p5() + .gap(tokens.icon_gap) .child(label.clone()) .child( Icon::new(IconName::ExternalLink) .xsmall() - .text_color(cx.theme().muted_foreground), + .text_color(accessory_color), ), ) }) @@ -1198,30 +1276,17 @@ impl PopupMenu { } => { let reduced_motion = GlobalState::global(cx).reduced_motion(); let motion = cx.theme().motion.clone(); - let open_duration_ms = if reduced_motion { - motion.fast_duration_ms - } else { - spring_preset_duration_ms(&motion, SpringPreset::Medium) - .max(motion.fast_duration_ms) - }; - let submenu_presence = keyed_presence( + let submenu_presence = flyout_presence( SharedString::from(format!( "popup-menu-submenu-presence-{}-{}", cx.entity().entity_id(), ix )), selected, - !reduced_motion, - Duration::from_millis(u64::from(open_duration_ms)), - Duration::from_millis(u64::from(motion.fade_duration_ms)), PresenceOptions::default(), window, cx, ); - let submenu_open_opacity_anim = point_to_point_animation(&motion, reduced_motion); - let submenu_open_transform_anim = - spring_preset_animation(&motion, reduced_motion, SpringPreset::Medium); - let submenu_close_anim = point_to_point_animation(&motion, reduced_motion); this.selected(selected) .disabled(*disabled) @@ -1231,26 +1296,32 @@ impl PopupMenu { .min_h(item_height) .size_full() .items_center() - .gap_x_1() + .gap(tokens.icon_gap) .children(Self::render_icon( has_left_icon, false, icon.clone(), + selected, + *disabled, window, cx, )) .child( h_flex() .flex_1() - .gap_2() + .gap(tokens.accessory_gap) .items_center() .justify_between() .child(label.clone()) - .child( - Icon::new(IconName::ChevronRight) - .xsmall() - .text_color(cx.theme().muted_foreground), - ), + .child(Icon::new(IconName::ChevronRight).xsmall().text_color( + if selected && !disabled { + flyout_selected_secondary_foreground(cx) + } else if *disabled { + flyout_disabled_foreground(cx) + } else { + flyout_secondary_foreground(cx) + }, + )), ), ) .when(submenu_presence.should_render(), |this| { @@ -1264,92 +1335,34 @@ impl PopupMenu { } else { 1.0 }; - let submenu = anchored() - .anchor(anchor) - .child( - div() - .id("submenu") - .occlude() - .when(is_bottom_pos, |this| this.bottom_0()) - .when(!is_bottom_pos, |this| this.top_neg_1()) - .left(left) - .child(menu.clone()), - ) - .snap_to_window_with_margin(Edges::all(EDGE_PADDING)); - - if submenu_presence.transition_active() { - if matches!(submenu_presence.phase, PresencePhase::Entering) { - let transformed = - if let Some(anim) = submenu_open_transform_anim { - div() - .child(submenu) - .with_animation( - SharedString::from(format!( - "popup-submenu-open-transform-{}", - ix - )), - anim, - move |el, delta| { - el.translate_x(px(direction - * 6.0 - * (1.0 - delta))) - }, - ) - .into_any_element() - } else { - div().child(submenu).into_any_element() - }; - if let Some(anim) = submenu_open_opacity_anim { - div() - .child(transformed) - .with_animation( - SharedString::from(format!( - "popup-submenu-open-opacity-{}", - ix - )), - anim, - move |el, delta| { - el.opacity( - submenu_presence - .progress(delta) - .clamp(0.0, 1.0), - ) - }, - ) - .into_any_element() - } else { - div() - .child(transformed) - .opacity(submenu_presence.progress(1.0)) - .into_any_element() - } - } else { - if let Some(anim) = submenu_close_anim { - div() - .child(submenu) - .with_animation( - SharedString::from(format!( - "popup-submenu-close-motion-{}", - ix - )), - anim, - move |el, delta| { - let progress = submenu_presence - .progress(delta) - .clamp(0.0, 1.0); - el.opacity(progress).translate_x(px(direction - * 6.0 - * (1.0 - progress))) - }, - ) - .into_any_element() - } else { - submenu.into_any_element() - } - } - } else { - submenu.into_any_element() - } + let submenu_inner = div() + .id("submenu") + .occlude() + .when(is_bottom_pos, |this| this.bottom_0()) + .when(!is_bottom_pos, |this| this.top_neg_1()) + .left(left) + .child(menu.clone()); + + // The motion wraps the *content inside* `anchored`: + // wrappers outside it have no visual effect, because + // anchored repositions its children after layout. + let animated_inner = flyout_motion( + SharedString::from(format!("popup-submenu-{}", ix)), + submenu_presence, + FlyoutSlide::horizontal(direction), + &motion, + reduced_motion, + submenu_inner, + ); + + gpui::deferred( + anchored() + .anchor(anchor) + .child(animated_inner) + .snap_to_window_with_margin(Edges::all(EDGE_PADDING)), + ) + .with_priority(2) + .into_any_element() }) }) } @@ -1369,13 +1382,14 @@ impl Focusable for PopupMenu { struct RenderOptions { has_left_icon: bool, check_side: Side, - radius: Pixels, + tokens: FlyoutTokens, } impl Render for PopupMenu { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - self.update_submenu_menu_anchor(window); + self.update_submenu_menu_anchor(window, cx); + let tokens = FlyoutTokens::sized(self.size, cx); let view = cx.entity(); let items_count = self.menu_items.len(); @@ -1389,16 +1403,14 @@ impl Render for PopupMenu { .iter() .any(|item| item.has_left_icon(self.check_side)); - let max_width = self.max_width(); + let max_width = self.max_width(cx); let options = RenderOptions { has_left_icon, check_side: self.check_side, - radius: cx.theme().radius.min(px(8.)), + tokens, }; - let surface_ctx = SurfaceContext { - blur_enabled: GlobalState::global(cx).blur_enabled(), - }; + let surface_ctx = SurfaceContext::new(cx); let surface_width = if self.bounds.size.width > px(0.) { self.bounds.size.width } else { @@ -1421,15 +1433,15 @@ impl Render for PopupMenu { .on_action(cx.listener(Self::confirm)) .on_action(cx.listener(Self::dismiss)) .on_mouse_down_out(cx.listener(Self::on_mouse_down_out)) - .text_color(cx.theme().popover_foreground) + .text_color(flyout_primary_foreground(cx)) .relative() .occlude() .child( v_flex() .id("items") - .p_1() - .gap_y_0p5() - .min_w(rems(8.)) + .p(tokens.inset) + .gap(tokens.item_gap) + .min_w(tokens.min_width) .when_some(self.min_width, |this, min_width| this.min_w(min_width)) .max_w(max_width) .when(self.scrollable, |this| { @@ -1453,7 +1465,7 @@ impl Render for PopupMenu { }); SurfacePreset::flyout() - .with_radius(cx.theme().radius) + .with_radius(tokens.radius) .wrap_with_bounds( content, surface_width, diff --git a/crates/ui/src/notification.rs b/crates/ui/src/notification.rs index 68db7a2e..4e4654e3 100644 --- a/crates/ui/src/notification.rs +++ b/crates/ui/src/notification.rs @@ -19,7 +19,7 @@ use smol::Timer; use crate::{ ActiveTheme as _, Anchor, Edges, Icon, IconName, Sizable as _, StyledExt, TITLE_BAR_HEIGHT, - animation::{fast_invoke_animation, soft_dismiss_animation}, + animation::{enter_animation, exit_animation}, button::{Button, ButtonVariants as _}, global_state::GlobalState, h_flex, v_flex, @@ -255,7 +255,7 @@ impl Notification { let dismiss_duration = if reduced_motion { Duration::ZERO } else { - Duration::from_millis(u64::from(cx.theme().motion.soft_dismiss_duration_ms)) + crate::animation::exit_duration(&cx.theme().motion) }; cx.spawn(async move |view, cx| { @@ -311,9 +311,9 @@ impl Render for Notification { let reduced_motion = GlobalState::global(cx).reduced_motion(); let motion = &cx.theme().motion; let animation = if closing { - soft_dismiss_animation(motion, reduced_motion) + exit_animation(motion, reduced_motion) } else { - fast_invoke_animation(motion, reduced_motion) + enter_animation(motion, reduced_motion) }; let notification = h_flex() diff --git a/crates/ui/src/popover.rs b/crates/ui/src/popover.rs index 0fdd2804..626f51ed 100644 --- a/crates/ui/src/popover.rs +++ b/crates/ui/src/popover.rs @@ -1,19 +1,18 @@ use gpui::{ - AnimationExt as _, AnyElement, App, Bounds, Context, Deferred, DismissEvent, Div, ElementId, - EventEmitter, FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, KeyBinding, - MouseButton, ParentElement, Pixels, Point, Render, RenderOnce, SharedString, Stateful, - StyleRefinement, Styled, Subscription, Window, deferred, div, prelude::FluentBuilder as _, px, + AnyElement, App, Bounds, Context, Deferred, DismissEvent, Div, ElementId, EventEmitter, + FocusHandle, Focusable, Half, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, + ParentElement, Pixels, Point, Render, RenderOnce, SharedString, Stateful, StyleRefinement, + Styled, Subscription, Window, deferred, div, prelude::FluentBuilder as _, px, }; use std::rc::Rc; use crate::{ - ActiveTheme, Anchor, ElementExt, Selectable, StyledExt as _, + ActiveTheme, Anchor, ElementExt, FlyoutTokens, Selectable, StyledExt as _, SurfaceContext, + SurfacePreset, actions::Cancel, anchored, - animation::{ - PresenceOptions, PresencePhase, SpringPreset, keyed_presence, point_to_point_animation, - spring_preset_animation, spring_preset_duration_ms, - }, + animation::{FlyoutSlide, PresenceOptions, flyout_motion, flyout_presence}, + flyout_primary_foreground, global_state::GlobalState, v_flex, }; @@ -323,11 +322,19 @@ impl Popover { _: &mut Window, cx: &mut App, ) -> Stateful
{ + let tokens = FlyoutTokens::new(cx); + v_flex() .id("content") .occlude() .tab_group() - .when(appearance, |this| this.popover_style(cx).p_3()) + .when(appearance, |this| { + SurfacePreset::flyout() + .with_radius(tokens.radius) + .apply_material(this, cx, SurfaceContext::new(cx)) + .text_color(flyout_primary_foreground(cx)) + .p(tokens.content_padding) + }) .map(|this| match anchor { Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight => this.top_1(), Anchor::BottomLeft | Anchor::BottomCenter | Anchor::BottomRight => this.bottom_1(), @@ -392,17 +399,9 @@ impl RenderOnce for Popover { let motion = cx.theme().motion.clone(); let reduced_motion = GlobalState::global(cx).reduced_motion(); - let open_duration_ms = if reduced_motion { - motion.fast_duration_ms - } else { - spring_preset_duration_ms(&motion, SpringPreset::Medium).max(motion.fast_duration_ms) - }; - let presence = keyed_presence( + let presence = flyout_presence( SharedString::from(format!("popover-presence-{}", popover_id)), open, - !reduced_motion, - std::time::Duration::from_millis(u64::from(open_duration_ms)), - std::time::Duration::from_millis(u64::from(motion.fade_duration_ms)), PresenceOptions { animate_on_mount: true, }, @@ -413,10 +412,6 @@ impl RenderOnce for Popover { return el; } - let open_fade_anim = point_to_point_animation(&motion, reduced_motion); - let open_transform_anim = - spring_preset_animation(&motion, reduced_motion, SpringPreset::Medium); - let close_anim = point_to_point_animation(&motion, reduced_motion); let vertical_direction = if matches!( self.anchor, Anchor::TopLeft | Anchor::TopCenter | Anchor::TopRight @@ -425,6 +420,7 @@ impl RenderOnce for Popover { } else { 1.0 }; + let slide = FlyoutSlide::vertical(vertical_direction).exit_distance(4.0); let popover_content = Self::render_popover_content(self.anchor, self.appearance, window, cx) @@ -448,62 +444,7 @@ impl RenderOnce for Popover { }) .refine_style(&self.style) .map(move |el| { - if !presence.transition_active() { - el.opacity(presence.progress(1.0)) - .translate_y(px(0.0)) - .into_any_element() - } else if matches!(presence.phase, PresencePhase::Entering) { - let translated = if let Some(anim) = open_transform_anim { - div() - .child(el) - .with_animation( - SharedString::from("popover-open-transform"), - anim, - move |el, delta| { - el.translate_y(px(6.0 * (1.0 - delta) * vertical_direction)) - }, - ) - .into_any_element() - } else { - el.into_any_element() - }; - if let Some(anim) = open_fade_anim { - div() - .child(translated) - .with_animation( - SharedString::from("popover-open-fade"), - anim, - move |el, delta| { - let opacity = presence.progress(delta).clamp(0.0, 1.0); - el.opacity(opacity) - }, - ) - .into_any_element() - } else { - div() - .child(translated) - .opacity(presence.progress(1.0)) - .into_any_element() - } - } else { - if let Some(anim) = close_anim { - el.with_animation( - SharedString::from(format!( - "popover-close-motion-{}", - u8::from(matches!(presence.phase, PresencePhase::Entering)) - )), - anim, - move |el, delta| { - let progress = presence.progress(delta).clamp(0.0, 1.0); - let offset = px(6.0 * (1.0 - progress) * vertical_direction); - el.opacity(progress).translate_y(offset) - }, - ) - .into_any_element() - } else { - el.into_any_element() - } - } + flyout_motion("popover", presence, slide, &motion, reduced_motion, el) }); el.child(Self::render_popover( diff --git a/crates/ui/src/progress/progress.rs b/crates/ui/src/progress/progress.rs index 796ef8e9..503a68e1 100644 --- a/crates/ui/src/progress/progress.rs +++ b/crates/ui/src/progress/progress.rs @@ -122,11 +122,11 @@ impl RenderOnce for Progress { } let duration = Duration::from_millis(u64::from( - cx.theme().motion.fast_duration_ms, + cx.theme().motion.enter_duration_ms, )); let animation = animation_with_theme_easing( Animation::new(duration), - cx.theme().motion.point_to_point_easing.as_ref(), + cx.theme().motion.standard_easing.as_ref(), ); cx.spawn({ let state = state.clone(); diff --git a/crates/ui/src/progress/progress_circle.rs b/crates/ui/src/progress/progress_circle.rs index e6bec8ab..a7228480 100644 --- a/crates/ui/src/progress/progress_circle.rs +++ b/crates/ui/src/progress/progress_circle.rs @@ -192,10 +192,10 @@ impl RenderOnce for ProgressCircle { } let duration = - Duration::from_millis(u64::from(cx.theme().motion.fast_duration_ms)); + Duration::from_millis(u64::from(cx.theme().motion.enter_duration_ms)); let animation = animation_with_theme_easing( Animation::new(duration), - cx.theme().motion.point_to_point_easing.as_ref(), + cx.theme().motion.standard_easing.as_ref(), ); cx.spawn({ let state = state.clone(); diff --git a/crates/ui/src/root.rs b/crates/ui/src/root.rs index e593ba82..b2a71918 100644 --- a/crates/ui/src/root.rs +++ b/crates/ui/src/root.rs @@ -1,7 +1,8 @@ use crate::{ ActiveTheme, Anchor, ElementExt, Placement, StyledExt, - dialog::{Dialog, close_animation_duration}, + dialog::Dialog, focus_trap::FocusTrapManager, + global_state::GlobalState, input::InputState, notification::{Notification, NotificationList}, sheet::Sheet, @@ -44,6 +45,7 @@ pub(crate) struct ActiveSheet { /// The previous focused handle before opening the Sheet. previous_focused_handle: Option, placement: Placement, + closing: bool, builder: Rc Sheet + 'static>, } @@ -170,6 +172,7 @@ impl Root { sheet = (active_sheet.builder)(sheet, window, cx); sheet.focus_handle = active_sheet.focus_handle.clone(); sheet.placement = active_sheet.placement; + sheet.closing = active_sheet.closing; let size = sheet.size; @@ -286,19 +289,21 @@ impl Root { .and_then(|h| h.upgrade()); let dialog_id = active_dialog.id; - let should_animate_close = { + let should_defer_close = { let mut dialog = Dialog::new(window, cx); dialog = (active_dialog.builder)(dialog, window, cx); - dialog.should_animate(cx) + dialog.should_defer_close(cx) }; - if !should_animate_close { + if !should_defer_close { self.finalize_dialog_close(dialog_id, restore_focus, window, cx); return; } active_dialog.closing = true; - let duration = close_animation_duration(cx); + // The deferral window doubles as the ceiling: teardown happens when it + // elapses whether or not the content finished animating. + let duration = crate::animation::exit_duration(&cx.theme().motion); window .spawn(cx, async move |cx| { cx.background_executor().timer(duration).await; @@ -346,12 +351,13 @@ impl Root { focus_handle, previous_focused_handle, placement, + closing: false, builder: Rc::new(build), }); cx.notify(); } - pub fn close_sheet(&mut self, window: &mut Window, cx: &mut Context<'_, Root>) { + fn finalize_sheet_close(&mut self, window: &mut Window, cx: &mut Context<'_, Root>) { self.focused_input = None; if let Some(previous_handle) = self .active_sheet @@ -365,6 +371,38 @@ impl Root { cx.notify(); } + pub fn close_sheet(&mut self, window: &mut Window, cx: &mut Context<'_, Root>) { + let Some(active_sheet) = self.active_sheet.as_mut() else { + return; + }; + if active_sheet.closing { + return; + } + + if GlobalState::global(cx).reduced_motion() { + self.finalize_sheet_close(window, cx); + return; + } + + // Keep the sheet mounted for the exit window so it can slide out; the + // window is also the ceiling — teardown happens when it elapses. + active_sheet.closing = true; + let duration = crate::animation::exit_duration(&cx.theme().motion); + window + .spawn(cx, async move |cx| { + cx.background_executor().timer(duration).await; + _ = cx.update(|window, cx| { + Root::update(window, cx, |root, window, cx| { + if root.active_sheet.as_ref().is_some_and(|s| s.closing) { + root.finalize_sheet_close(window, cx); + } + }); + }); + }) + .detach(); + cx.notify(); + } + pub fn push_notification( &mut self, note: impl Into, diff --git a/crates/ui/src/select.rs b/crates/ui/src/select.rs index 56cd8ef9..26416081 100644 --- a/crates/ui/src/select.rs +++ b/crates/ui/src/select.rs @@ -1,17 +1,17 @@ use gpui::{ - AbsoluteLength, Anchor, AnimationExt as _, AnyElement, App, AppContext, Bounds, ClickEvent, - Context, DefiniteLength, DismissEvent, Edges, ElementId, Entity, EventEmitter, FocusHandle, - Focusable, InteractiveElement, IntoElement, KeyBinding, Length, ParentElement, Pixels, Render, - RenderOnce, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, - Task, WeakEntity, Window, anchored, deferred, div, point, prelude::FluentBuilder, px, rems, + AbsoluteLength, Anchor, AnyElement, App, AppContext, Bounds, ClickEvent, Context, + DefiniteLength, DismissEvent, Edges, ElementId, Entity, EventEmitter, FocusHandle, Focusable, + InteractiveElement, IntoElement, KeyBinding, Length, ParentElement, Pixels, Render, RenderOnce, + SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription, Task, + WeakEntity, Window, anchored, deferred, div, point, prelude::FluentBuilder, px, rems, }; use rust_i18n::t; use crate::{ - ActiveTheme, Disableable, ElementExt as _, Icon, IconName, IndexPath, Selectable, Sizable, - Size, StyleSized, StyledExt, SurfaceContext, SurfacePreset, + ActiveTheme, Disableable, ElementExt as _, FlyoutTokens, Icon, IconName, IndexPath, Selectable, + Sizable, Size, StyleSized, StyledExt, SurfaceContext, SurfacePreset, actions::{Cancel, Confirm, SelectDown, SelectUp}, - animation::fast_invoke_animation, + animation::{FlyoutSlide, PresenceOptions, flyout_motion, flyout_presence}, global_state::GlobalState, h_flex, input::clear_button, @@ -819,10 +819,8 @@ where let bounds = self.bounds; let allow_open = !(self.open || self.options.disabled); let outline_visible = self.open || is_focused && !self.options.disabled; - let popup_radius = cx.theme().radius.min(px(8.)); - let surface_ctx = SurfaceContext { - blur_enabled: GlobalState::global(cx).blur_enabled(), - }; + let tokens = FlyoutTokens::sized(self.options.size, cx); + let surface_ctx = SurfaceContext::new(cx); let base_width = bounds.size.width.into(); let base_height = bounds.size.height.into(); let rem_size = window.rem_size(); @@ -923,10 +921,32 @@ where move |bounds, _, cx| state.update(cx, |r, _| r.bounds = bounds) }), ) - .when(self.open, |this| { - let motion = &cx.theme().motion; + .map(|this| { + let motion = cx.theme().motion.clone(); let reduced_motion = GlobalState::global(cx).reduced_motion(); - let anim = fast_invoke_animation(motion, reduced_motion); + let presence = flyout_presence( + SharedString::from(format!( + "select-popup-presence-{}", + cx.entity().entity_id() + )), + self.open, + PresenceOptions::default(), + window, + cx, + ); + if !presence.should_render() { + return this; + } + + let vertical_direction = if matches!( + placement.anchor, + Anchor::BottomLeft | Anchor::BottomRight | Anchor::BottomCenter + ) { + -1.0 + } else { + 1.0 + }; + let slide = FlyoutSlide::vertical(vertical_direction); this.child( deferred( @@ -936,7 +956,7 @@ where .w(menu_width) .child( SurfacePreset::flyout() - .with_radius(popup_radius) + .with_radius(tokens.radius) .wrap_with_bounds( v_flex().occlude().child( List::new(&self.list) @@ -948,7 +968,7 @@ where ) .with_size(self.options.size) .max_h(rems(20.)) - .paddings(Edges::all(px(4.))), + .paddings(Edges::all(tokens.inset)), ), menu_width, menu_height, @@ -961,14 +981,14 @@ where this.escape(&Cancel, window, cx); })) .map(|el| { - if let Some(anim) = anim { - el.with_animation("select-enter", anim, |el, delta| { - el.opacity(delta) - }) - .into_any_element() - } else { - el.into_any_element() - } + flyout_motion( + "select", + presence, + slide, + &motion, + reduced_motion, + el, + ) }), ), ) diff --git a/crates/ui/src/sheet.rs b/crates/ui/src/sheet.rs index 4b59f897..07163dca 100644 --- a/crates/ui/src/sheet.rs +++ b/crates/ui/src/sheet.rs @@ -1,10 +1,10 @@ use std::rc::Rc; use gpui::{ - AnimationExt as _, AnyElement, App, ClickEvent, DefiniteLength, DismissEvent, Edges, - EventEmitter, FocusHandle, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, - ParentElement, Pixels, RenderOnce, StyleRefinement, Styled, Window, WindowControlArea, - anchored, div, point, prelude::FluentBuilder as _, px, + AbsoluteLength, AnimationExt as _, AnyElement, App, ClickEvent, DefiniteLength, DismissEvent, + Edges, EventEmitter, FocusHandle, InteractiveElement as _, IntoElement, KeyBinding, + MouseButton, ParentElement, Pixels, RenderOnce, SharedString, StyleRefinement, Styled, Window, + WindowControlArea, anchored, div, point, prelude::FluentBuilder as _, px, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -13,7 +13,7 @@ use crate::{ ActiveTheme, FocusTrapElement as _, IconName, Placement, Sizable, StyledExt as _, WindowExt as _, actions::Cancel, - animation::fast_invoke_animation, + animation::{enter_animation, exit_animation}, button::{Button, ButtonVariants as _}, dialog::overlay_color, global_state::GlobalState, @@ -57,6 +57,7 @@ pub struct Sheet { children: Vec, overlay: bool, overlay_closable: bool, + pub(crate) closing: bool, } impl Sheet { @@ -73,6 +74,7 @@ impl Sheet { children: Vec::new(), overlay: true, overlay_closable: true, + closing: false, on_close: Rc::new(|_, _, _| {}), } } @@ -147,11 +149,29 @@ impl RenderOnce for Sheet { let top = cx.theme().sheet.margin_top; let reduced_motion = GlobalState::global(cx).reduced_motion(); let motion = &cx.theme().motion; - let slide_animation = fast_invoke_animation(motion, reduced_motion); + let closing = self.closing; + // While closing, the Root keeps the sheet mounted for the exit window + // and this reversed slide plays; the window is also the ceiling. + let slide_animation = if closing { + exit_animation(motion, reduced_motion) + } else { + enter_animation(motion, reduced_motion) + }; let on_close = self.on_close.clone(); let base_size = window.text_style().font_size; let rem_size = window.rem_size(); + // Slide the panel's full extent along its placement axis. A fixed + // travel shorter than the panel makes it appear part-way in and then + // settle, rather than entering from off-screen. + let travel = self.size.to_pixels( + AbsoluteLength::Pixels(if placement.is_horizontal() { + size.width + } else { + size.height + }), + rem_size, + ); let mut paddings = Edges::all(px(16.)); if let Some(pl) = self.style.padding.left { paddings.left = pl.to_pixels(base_size, rem_size); @@ -281,15 +301,23 @@ impl RenderOnce for Sheet { }) .map(move |this| match slide_animation { Some(anim) => this - .with_animation("slide", anim, move |this, delta| { - let y = px(-100.) + delta * px(100.); - this.map(|this| match placement { - Placement::Top => this.top(top + y), - Placement::Right => this.right(y), - Placement::Bottom => this.bottom(y), - Placement::Left => this.left(y), - }) - }) + .with_animation( + SharedString::from(format!("slide-{}", u8::from(closing))), + anim, + move |this, delta| { + // Enter slides in from the edge; + // closing runs the same path back out. + let progress = + if closing { 1.0 - delta } else { delta }; + let y = -travel + progress * travel; + this.map(|this| match placement { + Placement::Top => this.top(top + y), + Placement::Right => this.right(y), + Placement::Bottom => this.bottom(y), + Placement::Left => this.left(y), + }) + }, + ) .into_any_element(), None => this.into_any_element(), }), diff --git a/crates/ui/src/sidebar/group.rs b/crates/ui/src/sidebar/group.rs index 6866cfab..b88cf534 100644 --- a/crates/ui/src/sidebar/group.rs +++ b/crates/ui/src/sidebar/group.rs @@ -1,7 +1,7 @@ use crate::{ ActiveTheme, Collapsible, animation::{ - PresenceOptions, PresencePhase, SpringPreset, expand_collapse_durations, + PresenceOptions, PresencePhase, expand_collapse_durations, expand_collapse_layout_animation, keyed_presence, }, global_state::GlobalState, @@ -70,8 +70,7 @@ impl SidebarItem for SidebarGroup { let id = id.into(); let reduced_motion = GlobalState::global(cx).reduced_motion(); let motion = cx.theme().motion.clone(); - let (open_duration, close_duration) = - expand_collapse_durations(&motion, reduced_motion, SpringPreset::Mild); + let (open_duration, close_duration) = expand_collapse_durations(&motion); let label_presence = keyed_presence( SharedString::from(format!("{}-group-label-presence", id)), !self.collapsed, diff --git a/crates/ui/src/sidebar/menu.rs b/crates/ui/src/sidebar/menu.rs index 9c9b5507..9c3d9512 100644 --- a/crates/ui/src/sidebar/menu.rs +++ b/crates/ui/src/sidebar/menu.rs @@ -1,11 +1,13 @@ use crate::{ - ActiveTheme as _, Anchor, Collapsible, Icon, IconName, Selectable, Sizable as _, StyledExt, + ActiveTheme as _, Anchor, Collapsible, FlyoutTokens, Icon, IconName, Selectable, Sizable as _, + StyledExt, SurfaceContext, SurfacePreset, animation::{ - PresenceOptions, PresencePhase, PresenceTransition, SpringPreset, + PresenceOptions, PresencePhase, PresenceTransition, exit_animation, expand_collapse_durations, expand_collapse_layout_animation, keyed_presence, - subtle_reveal_transform_animation, theme_animation, + spring_animation, }, button::{Button, ButtonVariants as _}, + flyout_primary_foreground, global_state::GlobalState, h_flex, menu::{ContextMenuExt, PopupMenu, PopupMenuItem}, @@ -412,8 +414,7 @@ impl SidebarItem for SidebarMenuItem { let show_collapsed_submenu = is_submenu && is_collapsed; let reduced_motion = GlobalState::global(cx).reduced_motion(); let motion = cx.theme().motion.clone(); - let (open_duration, close_duration) = - expand_collapse_durations(&motion, reduced_motion, SpringPreset::Mild); + let (open_duration, close_duration) = expand_collapse_durations(&motion); let submenu_presence = keyed_presence( SharedString::from(format!("{}-submenu-presence", state_key)), is_open, @@ -428,15 +429,9 @@ impl SidebarItem for SidebarMenuItem { let submenu_entering = matches!(submenu_presence.phase, PresencePhase::Entering); let submenu_layout_anim = expand_collapse_layout_animation(&motion, reduced_motion, submenu_entering); - let submenu_transform_anim = - subtle_reveal_transform_animation(&motion, reduced_motion, SpringPreset::Mild); - let chevron_open_anim = - subtle_reveal_transform_animation(&motion, reduced_motion, SpringPreset::Mild); - let chevron_close_anim = theme_animation( - motion.soft_dismiss_duration_ms, - &motion.soft_dismiss_easing, - reduced_motion, - ); + let submenu_transform_anim = spring_animation(&motion, reduced_motion); + let chevron_open_anim = spring_animation(&motion, reduced_motion); + let chevron_close_anim = exit_animation(&motion, reduced_motion); let item_content_presence = keyed_presence( SharedString::from(format!("{}-item-content-presence", state_key)), !is_collapsed, @@ -453,8 +448,7 @@ impl SidebarItem for SidebarMenuItem { reduced_motion, matches!(item_content_presence.phase, PresencePhase::Entering), ); - let item_content_transform_anim = - subtle_reveal_transform_animation(&motion, reduced_motion, SpringPreset::Mild); + let item_content_transform_anim = spring_animation(&motion, reduced_motion); let item_element = h_flex() .size_full() @@ -638,11 +632,13 @@ impl SidebarItem for SidebarMenuItem { if has_suffix_controls { let list_id = SharedString::from(format!("{}-collapsed-submenu", collapsed_content_id)); - return v_flex() - .id(list_id.clone()) - .popover_style(cx) - .p_1() - .gap_1() + let tokens = FlyoutTokens::new(cx); + return SurfacePreset::flyout() + .with_radius(tokens.radius) + .apply_material(v_flex().id(list_id.clone()), cx, SurfaceContext::new(cx)) + .text_color(flyout_primary_foreground(cx)) + .p(tokens.inset) + .gap(tokens.item_gap) .w(px(220.)) .children(children.clone().into_iter().enumerate().map(|(ix, item)| { let item_id = SharedString::from(format!("{}-{}", list_id, ix)); diff --git a/crates/ui/src/sidebar/mod.rs b/crates/ui/src/sidebar/mod.rs index feb92d49..12979eb3 100644 --- a/crates/ui/src/sidebar/mod.rs +++ b/crates/ui/src/sidebar/mod.rs @@ -1,9 +1,6 @@ use crate::{ ActiveTheme, Collapsible, Icon, IconName, Side, Sizable, StyledExt, - animation::{ - PresenceOptions, PresencePhase, SpringPreset, keyed_presence, spring_preset_duration_ms, - theme_animation, - }, + animation::{PresenceOptions, PresencePhase, keyed_presence, theme_animation}, button::{Button, ButtonVariants}, global_state::GlobalState, h_flex, @@ -253,17 +250,11 @@ impl RenderOnce for Sidebar { let sidebar_id = self.id.clone(); let expanded_width = self.width; let collapsed_width = COLLAPSED_WIDTH; - let width_spring_preset = SpringPreset::Medium; - let width_spring_duration_ms = spring_preset_duration_ms(&motion, width_spring_preset); - let open_duration_ms = if reduced_motion { - motion.fast_duration_ms - } else { - width_spring_duration_ms.max(motion.fast_duration_ms) - }; + let open_duration_ms = motion.enter_duration_ms; let close_duration_ms = if reduced_motion { - motion.soft_dismiss_duration_ms + motion.exit_duration_ms } else { - width_spring_duration_ms.max(motion.soft_dismiss_duration_ms) + motion.enter_duration_ms.max(motion.exit_duration_ms) }; let presence = keyed_presence( SharedString::from(format!("{}-collapsed-presence", sidebar_id)), @@ -435,11 +426,8 @@ impl RenderOnce for Sidebar { if !transition_active { sidebar.into_any_element() } else { - let width_anim = theme_animation( - width_duration_ms, - &motion.point_to_point_easing, - reduced_motion, - ); + let width_anim = + theme_animation(width_duration_ms, &motion.standard_easing, reduced_motion); if let Some(width_anim) = width_anim { sidebar .with_animation( diff --git a/crates/ui/src/sidebar_shell/mod.rs b/crates/ui/src/sidebar_shell/mod.rs index 2045da0f..725140c4 100644 --- a/crates/ui/src/sidebar_shell/mod.rs +++ b/crates/ui/src/sidebar_shell/mod.rs @@ -38,11 +38,12 @@ //! .child(sidebar_content) //! ``` -use std::rc::Rc; +use std::{cell::Cell, rc::Rc}; use gpui::{ - AnyElement, App, BoxShadow, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, - RenderOnce, StyleRefinement, Styled, Window, div, hsla, point, prelude::FluentBuilder, px, + Animation, AnimationExt as _, AnyElement, App, BoxShadow, Hsla, InteractiveElement, + IntoElement, ParentElement, Pixels, RenderOnce, SharedString, StyleRefinement, Styled, Window, + div, hsla, point, prelude::FluentBuilder, px, }; use smallvec::SmallVec; @@ -56,6 +57,13 @@ const DEFAULT_MIN_WIDTH: f32 = 200.0; const DEFAULT_MAX_WIDTH: f32 = 400.0; const DEFAULT_RESIZER_WIDTH: f32 = 6.0; +struct SidebarShellWidthTransition { + from: Pixels, + animation_id: SharedString, + animation: Animation, + current_width: Rc>, +} + /// Creates a 3-layer shadow effect for elevated sidebar panels. /// /// This shadow configuration provides a natural depth effect with: @@ -151,6 +159,8 @@ pub struct SidebarShell { /// If `None`, the value is inherited from the parent context (e.g., WindowShell). /// If `Some(value)`, the explicit value is used. blur_enabled: Option, + /// Optional width transition applied to the complete shell. + width_transition: Option, /// Child elements rendered inside the surface. children: SmallVec<[AnyElement; 1]>, /// Style refinement for the outer container. @@ -202,6 +212,7 @@ impl SidebarShell { inset: None, top_inset: px(0.0), blur_enabled: None, // Inherit from context by default + width_transition: None, children: SmallVec::new(), style: StyleRefinement::default(), } @@ -341,6 +352,22 @@ impl SidebarShell { self } + pub(crate) fn animate_width_from( + mut self, + from: Pixels, + animation_id: impl Into, + animation: Animation, + current_width: Rc>, + ) -> Self { + self.width_transition = Some(SidebarShellWidthTransition { + from, + animation_id: animation_id.into(), + animation, + current_width, + }); + self + } + fn surface_preset(&self) -> SurfacePreset { let mut surface_preset = SurfacePreset::panel(); if let Some(elevation) = self.elevation { @@ -377,6 +404,12 @@ impl RenderOnce for SidebarShell { let bottom = inset; let sidebar_height = (window_height - (top + bottom)).max(px(0.0)); let sidebar_width = self.width; + let surface_render_width = self + .width_transition + .as_ref() + .map_or(sidebar_width, |transition| { + sidebar_width.max(transition.from) + }); // Use explicit value if set, otherwise inherit from context let blur_enabled = self @@ -387,7 +420,7 @@ impl RenderOnce for SidebarShell { .surface_preset() .wrap_with_bounds( div(), - sidebar_width, + surface_render_width, sidebar_height, window, cx, @@ -398,11 +431,6 @@ impl RenderOnce for SidebarShell { .size_full(); let resizer_half = self.resizer_width / 2.0; - let resizer_left = if self.side.is_left() { - self.width - resizer_half - } else { - -resizer_half - }; let is_left = self.side.is_left(); let on_resize_start = self.on_resize_start.clone(); @@ -428,7 +456,8 @@ impl RenderOnce for SidebarShell { .absolute() .top_0() .bottom_0() - .left(resizer_left) + .when(is_left, |this| this.right(-resizer_half)) + .when(!is_left, |this| this.left(-resizer_half)) .w(self.resizer_width) .rounded(px(999.0)) .bg(gpui::transparent_black()) @@ -455,7 +484,23 @@ impl RenderOnce for SidebarShell { ) .refine_style(&self.style); - outer + if let Some(transition) = self.width_transition { + let SidebarShellWidthTransition { + from, + animation_id, + animation, + current_width, + } = transition; + outer + .with_animation(animation_id, animation, move |this, delta| { + let width = from + (sidebar_width - from) * delta; + current_width.set(width); + this.w(width) + }) + .into_any_element() + } else { + outer.into_any_element() + } } } @@ -472,6 +517,7 @@ mod tests { assert_eq!(default_shell.resizer_width, px(DEFAULT_RESIZER_WIDTH)); assert_eq!(default_shell.inset, None); assert_eq!(default_shell.side, Side::Left); + assert!(default_shell.width_transition.is_none()); let no_shadow = SidebarShell::left(px(260.0)).elevation(ElevationToken::None); assert_eq!(no_shadow.elevation, Some(ElevationToken::None)); @@ -484,6 +530,22 @@ mod tests { assert_eq!(large_shadow.inset, Some(px(12.0))); assert_eq!(large_shadow.resizer_width, px(8.0)); assert_eq!(large_shadow.side, Side::Right); + + let animated = SidebarShell::left(px(48.0)).animate_width_from( + px(260.0), + "sidebar-shell-width", + Animation::new(std::time::Duration::from_millis(187)), + Rc::new(Cell::new(px(260.0))), + ); + let transition = animated + .width_transition + .as_ref() + .expect("width transition should be configured"); + assert_eq!(transition.from, px(260.0)); + assert_eq!( + transition.animation.duration, + std::time::Duration::from_millis(187) + ); } #[test] diff --git a/crates/ui/src/styled.rs b/crates/ui/src/styled.rs index ae7617eb..fca94354 100644 --- a/crates/ui/src/styled.rs +++ b/crates/ui/src/styled.rs @@ -1,4 +1,4 @@ -use crate::{ActiveTheme, global_state::GlobalState}; +use crate::ActiveTheme; use gpui::{ App, BoxShadow, Corners, DefiniteLength, Div, Edges, FocusHandle, Hsla, ParentElement, Pixels, Refineable, StyleRefinement, Styled, Window, div, point, px, @@ -173,30 +173,6 @@ pub trait StyledExt: Styled + Sized { font_weight!(font_extrabold, EXTRA_BOLD); font_weight!(font_black, BLACK); - /// Set as Popover style - #[inline] - fn popover_style(self, cx: &App) -> Self { - let material = &cx.theme().material; - let opacity = if cx.theme().mode.is_dark() { - material.flyout_dark_opacity - } else { - material.flyout_light_opacity - }; - let mut popover = self - .bg(cx.theme().popover.opacity(opacity)) - .text_color(cx.theme().popover_foreground) - .border_1() - .border_color(cx.theme().border) - .shadow_lg() - .rounded(cx.theme().radius); - - if GlobalState::global(cx).blur_enabled() { - popover = popover.backdrop_blur(material.flyout_blur_radius); - } - - popover - } - /// Apply Fluent caption typography (12/16 Regular). fn fluent_caption(self, cx: &App) -> Self { let t = &cx.theme().typography.caption; diff --git a/crates/ui/src/surface/flyout.rs b/crates/ui/src/surface/flyout.rs new file mode 100644 index 00000000..f3309fb4 --- /dev/null +++ b/crates/ui/src/surface/flyout.rs @@ -0,0 +1,232 @@ +//! Centralized geometry, typography and color tokens for flyout surfaces. +//! +//! Every transient surface in the library — popover, hover card, popup menu, +//! context menu, select popup, command palette, editor popovers and the collapsed +//! sidebar submenu — shares one geometry language so they read as a family: +//! +//! - one container radius (`radius`, from [`Theme::radius_lg`]), +//! - one edge inset between the container and its rows (`inset`), +//! - a row radius that is *concentric* with the container (`item_radius = radius - inset`), +//! - one horizontal rhythm for rows (`item_padding_x`, `icon_gap`, `accessory_gap`), +//! - two type steps: `label` (Fluent body) for row labels, `meta` (Fluent caption) +//! for shortcuts, subtitles, categories and section headers. +//! +//! [`SurfacePreset::flyout`](super::SurfacePreset::flyout) owns the *material* +//! (blur, noise, elevation, stroke); this module owns the *layout* on top of it. +//! Both derive from theme tokens, so a theme override moves every flyout together. + +use gpui::{App, FontWeight, Hsla, Pixels, px}; + +use super::SurfacePreset; +use crate::{ + ActiveTheme, Size, + theme::contrast::{MIN_TEXT_CONTRAST, contrast_adjusted}, +}; + +/// Padding between the flyout edge and its rows. +const INSET: Pixels = px(4.); +/// Horizontal padding inside a row. +const ITEM_PADDING_X: Pixels = px(8.); +/// Vertical gap between adjacent rows. +const ITEM_GAP: Pixels = px(2.); +/// Gap between a row's leading icon and its label. +const ICON_GAP: Pixels = px(8.); +/// Gap between a row's label and its trailing accessories (shortcut, chevron, check). +const ACCESSORY_GAP: Pixels = px(12.); +/// Height of a group label or section header row. +const SECTION_HEADER_HEIGHT: Pixels = px(24.); +/// Vertical margin around a separator rule. +const SEPARATOR_MARGIN: Pixels = px(4.); +/// Padding for prose flyouts (popover body, hover card, documentation). +const CONTENT_PADDING: Pixels = px(12.); +/// Minimum width of a menu-style flyout. +const MIN_WIDTH: Pixels = px(128.); +/// Maximum width of a menu-style flyout. +const MAX_WIDTH: Pixels = px(500.); +/// Smallest row radius that still reads as rounded. +const MIN_ITEM_RADIUS: Pixels = px(2.); + +/// Layout and text tokens shared by every flyout surface. +/// +/// Build with [`FlyoutTokens::new`] for the default (medium) density, or +/// [`FlyoutTokens::sized`] to match a control's [`Size`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FlyoutTokens { + // -- Container geometry -- + /// Corner radius of the flyout container. + pub radius: Pixels, + /// Padding between the container edge and its rows. + pub inset: Pixels, + /// Padding for prose content (popover body, hover card, documentation). + pub content_padding: Pixels, + /// Minimum width of a menu-style flyout. + pub min_width: Pixels, + /// Maximum width of a menu-style flyout. + pub max_width: Pixels, + + // -- Row geometry -- + /// Corner radius of a row, concentric with [`Self::radius`]. + pub item_radius: Pixels, + /// Height of a single-line row. + pub item_height: Pixels, + /// Horizontal padding inside a row. + pub item_padding_x: Pixels, + /// Vertical gap between adjacent rows. + pub item_gap: Pixels, + /// Gap between a leading icon and its label. + pub icon_gap: Pixels, + /// Gap between a label and its trailing accessories. + pub accessory_gap: Pixels, + /// Height of a group label or section header row. + pub section_header_height: Pixels, + /// Vertical margin around a separator rule. + pub separator_margin: Pixels, + + // -- Typography -- + /// Font size for row labels. + pub label_size: Pixels, + /// Font size for shortcuts, subtitles, categories and section headers. + pub meta_size: Pixels, + /// Font weight for group labels and section headers. + pub section_weight: FontWeight, +} + +impl FlyoutTokens { + /// Tokens at the default (medium) row density. + pub fn new(cx: &App) -> Self { + Self::sized(Size::Medium, cx) + } + + /// Tokens matching a control [`Size`]. + pub fn sized(size: Size, cx: &App) -> Self { + let theme = cx.theme(); + let radius = theme.radius_lg; + + Self { + radius, + inset: INSET, + content_padding: CONTENT_PADDING, + min_width: MIN_WIDTH, + max_width: MAX_WIDTH, + + item_radius: (radius - INSET).max(MIN_ITEM_RADIUS), + item_height: Self::item_height_for(size), + item_padding_x: ITEM_PADDING_X, + item_gap: ITEM_GAP, + icon_gap: ICON_GAP, + accessory_gap: ACCESSORY_GAP, + section_header_height: SECTION_HEADER_HEIGHT, + separator_margin: SEPARATOR_MARGIN, + + label_size: theme.typography.body.size, + meta_size: theme.typography.caption.size, + section_weight: FontWeight::MEDIUM, + } + } + + fn item_height_for(size: Size) -> Pixels { + match size { + Size::Size(height) => height, + Size::XSmall => px(22.), + Size::Small => px(26.), + Size::Medium => px(30.), + Size::Large => px(34.), + } + } +} + +/// The composited color of the flyout material, over [`Theme::background`]. +/// +/// Text inside a flyout reads against *this*, not against the window background, +/// which is what makes [`flyout_secondary_foreground`] a distinct role rather +/// than an alias for [`Theme::muted_foreground`]. +pub fn flyout_material_color(cx: &App) -> Hsla { + SurfacePreset::flyout().resolve_background(cx).resolve(cx) +} + +/// Secondary text color inside a flyout: shortcuts, subtitles, categories, +/// section headers and enabled accessory icons. +/// +/// Flyout materials sit *above* the window background — in dark mode they are +/// noticeably lighter than it — so [`Theme::muted_foreground`], which themes tune +/// against the window background, loses roughly a stop of contrast here. This +/// role keeps that token wherever it is already readable and lightens (or, on a +/// light material, darkens) it by the minimum needed to hold WCAG AA otherwise. +/// The correction is a pure function of the theme, so custom themes get it too, +/// and it is scoped to flyouts: cards and other raised surfaces that share +/// `muted_foreground` are untouched. +/// +/// Always pair it with [`flyout_primary_foreground`] for the label so the +/// two-step hierarchy is consistent across every flyout. +pub fn flyout_secondary_foreground(cx: &App) -> Hsla { + contrast_adjusted( + cx.theme().muted_foreground, + flyout_material_color(cx), + cx.theme().background, + MIN_TEXT_CONTRAST, + ) +} + +/// Primary text color inside a flyout: row labels and prose body. +/// +/// Usually [`Theme::popover_foreground`] as-is; corrected by the minimum lightness +/// change when a theme's own label color falls under AA on the flyout material +/// (deliberately dim themes such as Alduin, where even the window foreground is a +/// mid grey). +pub fn flyout_primary_foreground(cx: &App) -> Hsla { + contrast_adjusted( + cx.theme().popover_foreground, + flyout_material_color(cx), + cx.theme().background, + MIN_TEXT_CONTRAST, + ) +} + +/// Secondary text color on a *selected* row, where the row paints +/// [`Theme::primary`] behind its content. +#[inline] +pub fn flyout_selected_secondary_foreground(cx: &App) -> Hsla { + cx.theme().primary_foreground.opacity(0.8) +} + +/// Opacity applied to [`flyout_secondary_foreground`] for disabled content. +const DISABLED_OPACITY: f32 = 0.5; + +/// Text and icon color for a disabled flyout row — its label, shortcut and icons. +/// +/// One clear step below [`flyout_secondary_foreground`], so a disabled row reads +/// as unavailable rather than merely secondary, and so section headers (which +/// stay at the secondary step) separate from it. Deriving it from the corrected +/// secondary keeps the three-step ladder consistent in every theme. WCAG 1.4.3 +/// exempts disabled controls from the AA floor, which is what allows this role +/// to sit below it. +pub fn flyout_disabled_foreground(cx: &App) -> Hsla { + flyout_secondary_foreground(cx).opacity(DISABLED_OPACITY) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn item_height_follows_size() { + assert_eq!(FlyoutTokens::item_height_for(Size::XSmall), px(22.)); + assert_eq!(FlyoutTokens::item_height_for(Size::Small), px(26.)); + assert_eq!(FlyoutTokens::item_height_for(Size::Medium), px(30.)); + assert_eq!(FlyoutTokens::item_height_for(Size::Large), px(34.)); + assert_eq!(FlyoutTokens::item_height_for(Size::Size(px(41.))), px(41.)); + } + + #[test] + fn row_radius_stays_concentric_with_the_container() { + // The rounded row must be inset from the rounded container by exactly the + // edge padding, otherwise the two curves visibly disagree at the corners. + for radius in [px(4.), px(6.), px(8.), px(12.)] { + let item_radius = (radius - INSET).max(MIN_ITEM_RADIUS); + assert!(item_radius >= MIN_ITEM_RADIUS); + if radius - INSET >= MIN_ITEM_RADIUS { + assert_eq!(item_radius + INSET, radius); + } + } + } +} diff --git a/crates/ui/src/surface/mod.rs b/crates/ui/src/surface/mod.rs index 47fbfcc0..0a8ba918 100644 --- a/crates/ui/src/surface/mod.rs +++ b/crates/ui/src/surface/mod.rs @@ -13,12 +13,16 @@ //! .wrap_with_bounds(content, width, height, window, cx, ctx); //! ``` +mod flyout; + +pub use flyout::*; + use gpui::{ - App, Div, Hsla, ImageSource, IntoElement, ObjectFit, ParentElement, Pixels, Resource, Styled, - StyledImage, Window, div, img, px, + App, Div, Hsla, ImageSource, IntoElement, ObjectFit, ParentElement, Pixels, Resource, Rgba, + Styled, StyledImage, Window, div, img, px, }; -use crate::{ActiveTheme, StyledExt, ThemeShadowToken}; +use crate::{ActiveTheme, StyledExt, ThemeShadowToken, global_state::GlobalState}; const GLASS_NOISE_ASSET_PATH: &str = "surface/NoiseAsset_256.png"; const GLASS_NOISE_TILE_SIZE_BASE: f32 = 128.0; @@ -30,6 +34,15 @@ pub struct SurfaceContext { pub blur_enabled: bool, } +impl SurfaceContext { + /// Builds a context from the app's current blur setting. + pub(crate) fn new(cx: &App) -> Self { + Self { + blur_enabled: GlobalState::global(cx).blur_enabled(), + } + } +} + /// Semantic categorization of surface types. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum SurfaceKind { @@ -87,13 +100,65 @@ impl ElevationToken { } /// Source for surface background color from theme. +/// +/// Every variant derives from a theme color, so custom themes tint every surface +/// automatically. The `Elevated*` variants add the material's luminance character +/// on top of the theme color — see [`elevated`]. #[derive(Debug, Clone, Copy, Default)] pub enum SurfaceColorSource { #[default] Popover, - White, Sidebar, Background, + /// [`crate::Theme::popover`] lifted to flyout-material luminance. + ElevatedPopover, + /// [`crate::Theme::sidebar`] lifted to panel-material luminance. + ElevatedSidebar, + /// [`crate::Theme::background`] lifted toward white for the card wash. + ElevatedBackground, +} + +/// Blend fractions lifting a theme color toward the mode's elevation pole — white +/// in dark mode (surfaces rise toward light), black in light mode (materials sit a +/// step under a white window). Calibrated so the neutral default theme reproduces +/// the Fluent material values these replaced — acrylic `#2C2C2C`/`#FCFCFC` and +/// mica `#202020`/`#F3F3F3` over the default `#0A0A0A`/`#FFFFFF` window — while +/// tinted themes keep their hue instead of collapsing to those neutrals. +const FLYOUT_LIFT_DARK: f32 = 34. / 245.; +const FLYOUT_LIFT_LIGHT: f32 = 3. / 255.; +const PANEL_LIFT_DARK: f32 = 22. / 245.; +const PANEL_LIFT_LIGHT: f32 = 12. / 255.; +/// The card wash blends far toward white in both modes (it replaced a pure white +/// wash) but keeps enough of the background to carry the theme's hue. +const CARD_WASH_LIFT: f32 = 0.85; + +/// Blends `base` toward `pole` (per sRGB channel) by `amount`, keeping alpha. +fn mix_toward(base: Hsla, pole: f32, amount: f32) -> Hsla { + let base = Rgba::from(base); + let channel = |value: f32| value + (pole - value) * amount; + Hsla::from(Rgba { + r: channel(base.r), + g: channel(base.g), + b: channel(base.b), + a: base.a, + }) +} + +/// A theme color carried to a material's luminance step: lifted toward white in +/// dark mode, settled toward black in light mode. +pub(crate) fn elevated(base: Hsla, is_dark: bool, dark_lift: f32, light_lift: f32) -> Hsla { + if is_dark { + mix_toward(base, 1.0, dark_lift) + } else { + mix_toward(base, 0.0, light_lift) + } +} + +/// The flyout material's base color for a given theme popover color, before the +/// flyout opacity is applied. Shared with the theme contrast tests so they measure +/// the same surface the runtime renders. +pub(crate) fn flyout_base_color(popover: Hsla, is_dark: bool) -> Hsla { + elevated(popover, is_dark, FLYOUT_LIFT_DARK, FLYOUT_LIFT_LIGHT) } /// Background configuration with light/dark mode variants. @@ -117,11 +182,21 @@ impl Default for SurfaceBackground { impl SurfaceBackground { /// Resolves the background color based on theme mode and opacity settings. pub fn resolve(&self, cx: &App) -> Hsla { + let is_dark = cx.theme().mode.is_dark(); let base = match self.color_source { SurfaceColorSource::Popover => cx.theme().popover, - SurfaceColorSource::White => gpui::white(), SurfaceColorSource::Sidebar => cx.theme().sidebar, SurfaceColorSource::Background => cx.theme().background, + SurfaceColorSource::ElevatedPopover => flyout_base_color(cx.theme().popover, is_dark), + SurfaceColorSource::ElevatedSidebar => elevated( + cx.theme().sidebar, + is_dark, + PANEL_LIFT_DARK, + PANEL_LIFT_LIGHT, + ), + SurfaceColorSource::ElevatedBackground => { + mix_toward(cx.theme().background, 1.0, CARD_WASH_LIFT) + } }; let opacity = if cx.theme().mode.is_dark() { self.dark_opacity @@ -229,22 +304,22 @@ impl SurfacePreset { /// Creates a flyout surface preset for menus and dropdowns. /// - /// - 60px blur radius + /// - 24px blur radius /// - Subtle noise - /// - Popover background at 0.60/0.70 opacity - /// - Small elevation with subtle stroke + /// - Elevated popover background at 0.86/0.88 opacity + /// - Medium elevation with subtle stroke /// - 12px border radius pub fn flyout() -> Self { Self { kind: SurfaceKind::Flyout, - blur_radius: Some(px(60.0)), + blur_radius: Some(px(24.0)), noise_intensity: NoiseIntensity::Subtle, background: SurfaceBackground { - color_source: SurfaceColorSource::Popover, - light_opacity: 0.60, - dark_opacity: 0.70, + color_source: SurfaceColorSource::ElevatedPopover, + light_opacity: 0.86, + dark_opacity: 0.88, }, - elevation: ElevationToken::Sm, + elevation: ElevationToken::Md, stroke: Some(StrokeSpec::subtle()), transparency_factor: 1.0, radius: Some(px(12.0)), @@ -255,19 +330,19 @@ impl SurfacePreset { /// Creates a panel surface preset for sidebars and navigation. /// - /// - 120px blur radius - /// - Heavy noise - /// - Sidebar background at 0.85/0.90 opacity + /// - 48px blur radius + /// - Subtle noise + /// - Elevated sidebar background at 0.88/0.90 opacity /// - Large elevation with subtle stroke /// - 16px border radius pub fn panel() -> Self { Self { kind: SurfaceKind::Panel, - blur_radius: Some(px(120.0)), - noise_intensity: NoiseIntensity::Heavy, + blur_radius: Some(px(48.0)), + noise_intensity: NoiseIntensity::Subtle, background: SurfaceBackground { - color_source: SurfaceColorSource::Sidebar, - light_opacity: 0.85, + color_source: SurfaceColorSource::ElevatedSidebar, + light_opacity: 0.88, dark_opacity: 0.90, }, elevation: ElevationToken::Lg, @@ -283,7 +358,7 @@ impl SurfacePreset { /// /// - No blur /// - No noise - /// - White background at 0.70/0.05 opacity + /// - Theme-tinted near-white wash at 0.70/0.05 opacity /// - Small elevation with default border /// - Uses theme radius pub fn card() -> Self { @@ -292,7 +367,7 @@ impl SurfacePreset { blur_radius: None, noise_intensity: NoiseIntensity::None, background: SurfaceBackground { - color_source: SurfaceColorSource::White, + color_source: SurfaceColorSource::ElevatedBackground, light_opacity: 0.70, dark_opacity: 0.05, }, @@ -344,33 +419,34 @@ impl SurfacePreset { self } - /// Wraps content in a surface container with all configured effects. + /// Applies the surface material (radius, background, backdrop blur, stroke, elevation) + /// directly to an element. /// - /// This method creates a complete surface element with: - /// - One rounded clip shared by the background, backdrop blur, noise, and content - /// - Border/stroke styling using the same rounded geometry - /// - Elevation shadows painted outside the rounded clip - /// - Noise overlay (if blur is enabled) - pub fn wrap_with_bounds( - &self, - content: impl IntoElement, - width: Pixels, - height: Pixels, - window: &Window, - cx: &App, - ctx: SurfaceContext, - ) -> Div { + /// Use this when the element already owns its layout and only needs the material, e.g. + /// a popover body. Use [`Self::wrap_with_bounds`] instead when the surface should also + /// render the noise overlay, which requires known bounds. + pub(crate) fn apply_material(&self, mut surface: E, cx: &App, ctx: SurfaceContext) -> E + where + E: Styled + StyledExt, + { let radius = self.radius.unwrap_or(cx.theme().radius); - let scale_factor = window.scale_factor(); let blur_radius = self.resolve_blur_radius(cx); - let background = self.resolve_background(cx); + let mut background = self.resolve_background(cx); let elevation = self.resolve_elevation(cx); + let uses_opaque_fallback = !ctx.blur_enabled && blur_radius.is_some(); - let bg_color = background.resolve(cx).opacity(self.transparency_factor); - let noise_opacity = self.noise_intensity.opacity(); - let should_render_noise = ctx.blur_enabled && noise_opacity > 0.0; + if uses_opaque_fallback { + background.light_opacity = 1.0; + background.dark_opacity = 1.0; + } - let mut surface = div().relative().rounded(radius).overflow_hidden(); + let transparency_factor = if uses_opaque_fallback { + 1.0 + } else { + self.transparency_factor + }; + let bg_color = background.resolve(cx).opacity(transparency_factor); + surface = surface.rounded(radius); if bg_color.a > 0.0 { surface = surface.bg(bg_color); @@ -383,12 +459,42 @@ impl SurfacePreset { } if let Some(ref stroke) = self.stroke { - surface = surface - .border(stroke.width) - .border_color(stroke.resolve_color(cx)); + let stroke_color = if matches!( + self.background.color_source, + SurfaceColorSource::ElevatedPopover | SurfaceColorSource::ElevatedSidebar + ) && matches!(stroke.color, StrokeColor::Subtle) + { + cx.theme().control_stroke + } else { + stroke.resolve_color(cx) + }; + surface = surface.border(stroke.width).border_color(stroke_color); } - surface = elevation.apply(surface, cx); + elevation.apply(surface, cx) + } + + /// Wraps content in a surface container with all configured effects. + /// + /// This method creates a complete surface element with: + /// - One rounded clip shared by the background, backdrop blur, noise, and content + /// - Border/stroke styling using the same rounded geometry + /// - Elevation shadows painted outside the rounded clip + /// - Noise overlay (if blur is enabled) + pub fn wrap_with_bounds( + &self, + content: impl IntoElement, + width: Pixels, + height: Pixels, + window: &Window, + cx: &App, + ctx: SurfaceContext, + ) -> Div { + let scale_factor = window.scale_factor(); + let noise_opacity = self.noise_intensity.opacity(); + let should_render_noise = ctx.blur_enabled && noise_opacity > 0.0; + + let mut surface = self.apply_material(div().relative().overflow_hidden(), cx, ctx); if should_render_noise { surface = surface.child(render_noise_overlay_unclipped( @@ -414,7 +520,7 @@ impl SurfacePreset { } } - fn resolve_background(&self, cx: &App) -> SurfaceBackground { + pub(crate) fn resolve_background(&self, cx: &App) -> SurfaceBackground { if !self.use_theme_material_defaults { return self.background; } diff --git a/crates/ui/src/switch.rs b/crates/ui/src/switch.rs index 79b7d743..c83f2cf0 100644 --- a/crates/ui/src/switch.rs +++ b/crates/ui/src/switch.rs @@ -165,11 +165,11 @@ impl RenderOnce for Switch { if value_changed && !reduced_motion { let duration = Duration::from_millis(u64::from( - cx.theme().motion.fast_duration_ms, + cx.theme().motion.enter_duration_ms, )); let animation = animation_with_theme_easing( Animation::new(duration), - cx.theme().motion.point_to_point_easing.as_ref(), + cx.theme().motion.standard_easing.as_ref(), ); cx.spawn({ let toggle_state = toggle_state.clone(); diff --git a/crates/ui/src/theme/contrast.rs b/crates/ui/src/theme/contrast.rs new file mode 100644 index 00000000..d4f84d66 --- /dev/null +++ b/crates/ui/src/theme/contrast.rs @@ -0,0 +1,187 @@ +//! WCAG contrast math over theme colors. +//! +//! Theme colors are [`Hsla`] and frequently translucent, so a foreground's real +//! contrast depends on what it is composited over. Every helper here therefore +//! takes three colors: the text, the *surface* it sits on, and the opaque +//! `backdrop` behind that surface (normally [`crate::Theme::background`]). + +use gpui::{Hsla, Rgba}; + +/// WCAG AA floor for body text. +pub(crate) const MIN_TEXT_CONTRAST: f32 = 4.5; + +/// Steps used to search for the smallest lightness change that clears a floor. +/// 10 halvings resolve lightness to under 0.1%, below one 8-bit step. +const CONTRAST_SEARCH_STEPS: usize = 10; + +fn composite(foreground: Rgba, background: Rgba) -> Rgba { + let a = foreground.a + background.a * (1. - foreground.a); + if a == 0. { + return Rgba { + r: 0., + g: 0., + b: 0., + a: 0., + }; + } + let channel = + |fg: f32, bg: f32| (fg * foreground.a + bg * background.a * (1. - foreground.a)) / a; + Rgba { + r: channel(foreground.r, background.r), + g: channel(foreground.g, background.g), + b: channel(foreground.b, background.b), + a, + } +} + +fn linear_srgb(channel: f32) -> f32 { + if channel <= 0.04045 { + channel / 12.92 + } else { + ((channel + 0.055) / 1.055).powf(2.4) + } +} + +fn relative_luminance(color: Rgba) -> f32 { + 0.2126 * linear_srgb(color.r) + 0.7152 * linear_srgb(color.g) + 0.0722 * linear_srgb(color.b) +} + +/// Contrast ratio of `foreground` against `surface`, with both composited over +/// the opaque `backdrop`. +pub(crate) fn contrast_ratio(foreground: Hsla, surface: Hsla, backdrop: Hsla) -> f32 { + let backdrop = Rgba::from(backdrop); + let surface = composite(Rgba::from(surface), backdrop); + let foreground = composite(Rgba::from(foreground), surface); + let foreground = relative_luminance(foreground); + let surface = relative_luminance(surface); + (foreground.max(surface) + 0.05) / (foreground.min(surface) + 0.05) +} + +/// Returns `foreground` if it already clears `min_ratio` against `surface`, +/// otherwise the nearest variant that does. +/// +/// Only lightness moves — hue, saturation and alpha are preserved, so a theme's +/// character survives the correction. Contrast is monotonic in lightness once a +/// direction is chosen (away from the surface), so a bisection finds the +/// *smallest* correction rather than clamping to black or white. When even the +/// extreme cannot clear the floor (a mid-luminance surface), the extreme is +/// returned, which is still the most readable option available. +pub(crate) fn contrast_adjusted( + foreground: Hsla, + surface: Hsla, + backdrop: Hsla, + min_ratio: f32, +) -> Hsla { + if contrast_ratio(foreground, surface, backdrop) >= min_ratio { + return foreground; + } + + let composited_surface = composite(Rgba::from(surface), Rgba::from(backdrop)); + let composited_foreground = composite(Rgba::from(foreground), composited_surface); + let limit = + if relative_luminance(composited_foreground) < relative_luminance(composited_surface) { + 0. + } else { + 1. + }; + + let with_lightness = |l: f32| Hsla { l, ..foreground }; + if contrast_ratio(with_lightness(limit), surface, backdrop) < min_ratio { + return with_lightness(limit); + } + + let (mut near, mut far) = (foreground.l, limit); + let mut best = with_lightness(limit); + for _ in 0..CONTRAST_SEARCH_STEPS { + let mid = (near + far) / 2.; + if contrast_ratio(with_lightness(mid), surface, backdrop) >= min_ratio { + best = with_lightness(mid); + far = mid; + } else { + near = mid; + } + } + + best +} + +#[cfg(test)] +mod tests { + use gpui::hsla; + + use super::*; + + const BLACK: Hsla = Hsla { + h: 0., + s: 0., + l: 0., + a: 1., + }; + const WHITE: Hsla = Hsla { + h: 0., + s: 0., + l: 1., + a: 1., + }; + + #[test] + fn contrast_ratio_matches_wcag_extremes() { + assert!((contrast_ratio(WHITE, BLACK, BLACK) - 21.).abs() < 0.01); + assert!((contrast_ratio(BLACK, BLACK, BLACK) - 1.).abs() < 0.01); + } + + #[test] + fn contrast_ratio_accounts_for_a_translucent_surface() { + // A dark surface at 50% over white composites to mid grey, so white text on + // it loses most of the contrast it has against the surface color alone. + let translucent = BLACK.opacity(0.5); + assert!(contrast_ratio(WHITE, translucent, WHITE) < contrast_ratio(WHITE, BLACK, WHITE)); + } + + #[test] + fn passing_colors_are_returned_unchanged() { + let text = hsla(0.6, 0.2, 0.9, 1.); + assert_eq!( + contrast_adjusted(text, BLACK, BLACK, MIN_TEXT_CONTRAST), + text + ); + } + + #[test] + fn failing_colors_are_lightened_just_enough() { + // Mid grey on near-black fails; the fix must clear the floor, keep hue and + // saturation, and stop well short of pure white. + let surface = hsla(0.6, 0.1, 0.12, 1.); + let text = hsla(0.6, 0.3, 0.35, 1.); + assert!(contrast_ratio(text, surface, surface) < MIN_TEXT_CONTRAST); + + let fixed = contrast_adjusted(text, surface, surface, MIN_TEXT_CONTRAST); + assert!(contrast_ratio(fixed, surface, surface) >= MIN_TEXT_CONTRAST); + assert_eq!((fixed.h, fixed.s, fixed.a), (text.h, text.s, text.a)); + assert!(fixed.l > text.l, "should lighten against a dark surface"); + assert!(fixed.l < 1., "should not clamp to white"); + } + + #[test] + fn failing_colors_are_darkened_against_a_light_surface() { + let surface = hsla(0.1, 0.1, 0.95, 1.); + let text = hsla(0.1, 0.3, 0.75, 1.); + let fixed = contrast_adjusted(text, surface, surface, MIN_TEXT_CONTRAST); + + assert!(contrast_ratio(fixed, surface, surface) >= MIN_TEXT_CONTRAST); + assert!(fixed.l < text.l, "should darken against a light surface"); + } + + #[test] + fn unreachable_floors_fall_back_to_the_readable_extreme() { + // Correction only moves away from the surface, so text already darker than a + // surface this dark cannot reach 4.5:1 even at black (~3.0:1). Black is still + // the most readable answer in that direction, so it is what we return. + let surface = hsla(0., 0., 0.35, 1.); + let text = hsla(0., 0., 0.30, 1.); + assert!(contrast_ratio(BLACK, surface, surface) < MIN_TEXT_CONTRAST); + + let fixed = contrast_adjusted(text, surface, surface, MIN_TEXT_CONTRAST); + assert_eq!(fixed.l, 0.); + } +} diff --git a/crates/ui/src/theme/default-theme.json b/crates/ui/src/theme/default-theme.json index 9e0f4733..f09d51da 100644 --- a/crates/ui/src/theme/default-theme.json +++ b/crates/ui/src/theme/default-theme.json @@ -31,17 +31,17 @@ "shell_level": 36, "inactive_window_level": 64, "active_window_level": 128, - "surface_flyout_shadow": "sm", + "surface_flyout_shadow": "md", "surface_panel_shadow": "lg", "surface_card_shadow": "sm" }, "material": { - "flyout_blur_radius": 60.0, - "panel_blur_radius": 120.0, - "flyout_light_opacity": 0.6, - "flyout_dark_opacity": 0.7, - "panel_light_opacity": 0.85, - "panel_dark_opacity": 0.9, + "flyout_blur_radius": 24.0, + "panel_blur_radius": 48.0, + "flyout_light_opacity": 0.86, + "flyout_dark_opacity": 0.88, + "panel_light_opacity": 0.88, + "panel_dark_opacity": 0.90, "card_light_opacity": 0.7, "card_dark_opacity": 0.05, "subtle_stroke_light_opacity": 0.5, @@ -154,10 +154,10 @@ "warning.active.background": "#CA8A04", "warning.hover.background": "#eab308e6", "warning.foreground": "#000000", - "overlay": "#0000000d", + "overlay": "#0000002E", "window.border": "#e5e5e5", "disabled.foreground": "#0000005C", - "control.stroke": "#0000000F", + "control.stroke": "#0000001A", "card.background": "#FFFFFFB3", "card.foreground": "#0a0a0a", "solid.background": "#F3F3F3", @@ -293,17 +293,17 @@ "shell_level": 36, "inactive_window_level": 64, "active_window_level": 128, - "surface_flyout_shadow": "sm", + "surface_flyout_shadow": "md", "surface_panel_shadow": "lg", "surface_card_shadow": "sm" }, "material": { - "flyout_blur_radius": 60.0, - "panel_blur_radius": 120.0, - "flyout_light_opacity": 0.6, - "flyout_dark_opacity": 0.7, - "panel_light_opacity": 0.85, - "panel_dark_opacity": 0.9, + "flyout_blur_radius": 24.0, + "panel_blur_radius": 48.0, + "flyout_light_opacity": 0.86, + "flyout_dark_opacity": 0.88, + "panel_light_opacity": 0.88, + "panel_dark_opacity": 0.90, "card_light_opacity": 0.7, "card_dark_opacity": 0.05, "subtle_stroke_light_opacity": 0.5, @@ -410,10 +410,10 @@ "warning.active.background": "#5b320f", "warning.foreground": "#fefce8", "warning.hover.background": "#7b4414", - "overlay": "#ffffff08", + "overlay": "#00000038", "window.border": "#262626", "disabled.foreground": "#FFFFFF5D", - "control.stroke": "#FFFFFF12", + "control.stroke": "#FFFFFF24", "card.background": "#FFFFFF0D", "card.foreground": "#fafafa", "solid.background": "#202020", diff --git a/crates/ui/src/theme/fluent_tokens.rs b/crates/ui/src/theme/fluent_tokens.rs index b4c8e1cb..52e764a7 100644 --- a/crates/ui/src/theme/fluent_tokens.rs +++ b/crates/ui/src/theme/fluent_tokens.rs @@ -4,25 +4,17 @@ use crate::{ThemeElevation, ThemeMaterial, ThemeMotion, ThemeShadowToken, try_pa pub(crate) fn theme_motion_defaults() -> ThemeMotion { ThemeMotion { - // Fluent animation tokens: 187 / 333 / 500 ms cadence - fast_duration_ms: 187, - normal_duration_ms: 333, - slow_duration_ms: 500, - strong_invoke_duration_ms: 667, - soft_dismiss_duration_ms: 167, + // Fluent cadence: 83 fade / 167 dismiss / 187 invoke / 667 emphasis. fade_duration_ms: 83, - // Spring presets for transform-only open motion. - spring_mild_duration_ms: 187, - spring_medium_duration_ms: 240, - spring_mild_damping_ratio: 0.78, - spring_medium_damping_ratio: 0.70, - spring_mild_frequency: 2.0, - spring_medium_frequency: 1.6, - fast_invoke_easing: "cubic-bezier(0, 0, 0, 1)".into(), - strong_invoke_easing: "cubic-bezier(0.13, 1.62, 0, 0.92)".into(), - fast_dismiss_easing: "cubic-bezier(0, 0, 0, 1)".into(), - soft_dismiss_easing: "cubic-bezier(1, 0, 1, 1)".into(), - point_to_point_easing: "cubic-bezier(0.55, 0.55, 0, 1)".into(), + exit_duration_ms: 167, + enter_duration_ms: 187, + emphasis_duration_ms: 667, + // One spring for transform reveals; settles within the enter window. + spring_damping_ratio: 0.78, + spring_frequency: 2.0, + decelerate_easing: "cubic-bezier(0, 0, 0, 1)".into(), + standard_easing: "cubic-bezier(0.55, 0.55, 0, 1)".into(), + emphasis_easing: "cubic-bezier(0.13, 1.62, 0, 0.92)".into(), fade_easing: "linear".into(), } } @@ -38,8 +30,7 @@ pub(crate) fn theme_elevation_defaults() -> ThemeElevation { shell_level: 36, inactive_window_level: 64, active_window_level: 128, - // Preserve current gpui-component surface shadow behavior - surface_flyout_shadow: ThemeShadowToken::Sm, + surface_flyout_shadow: ThemeShadowToken::Md, surface_panel_shadow: ThemeShadowToken::Lg, surface_card_shadow: ThemeShadowToken::Sm, } @@ -47,12 +38,11 @@ pub(crate) fn theme_elevation_defaults() -> ThemeElevation { pub(crate) fn theme_material_defaults() -> ThemeMaterial { ThemeMaterial { - // Preserve existing surface behavior when no config is supplied - flyout_blur_radius: px(60.0), - panel_blur_radius: px(120.0), - flyout_light_opacity: 0.60, - flyout_dark_opacity: 0.70, - panel_light_opacity: 0.85, + flyout_blur_radius: px(24.0), + panel_blur_radius: px(48.0), + flyout_light_opacity: 0.86, + flyout_dark_opacity: 0.88, + panel_light_opacity: 0.88, panel_dark_opacity: 0.90, card_light_opacity: 0.70, card_dark_opacity: 0.05, @@ -66,14 +56,6 @@ pub(crate) fn theme_material_defaults() -> ThemeMaterial { layer_dark: fluent_color("#3A3A3A4C"), layer_alt_light: fluent_color("#FFFFFFFF"), layer_alt_dark: fluent_color("#FFFFFF0D"), - mica_base_light: fluent_color("#F3F3F3"), - mica_base_dark: fluent_color("#202020"), - mica_base_alt_light: fluent_color("#DADADA80"), - mica_base_alt_dark: fluent_color("#0A0A0A00"), - acrylic_base_light: fluent_color("#F3F3F3"), - acrylic_base_dark: fluent_color("#202020"), - acrylic_default_light: fluent_color("#FCFCFC"), - acrylic_default_dark: fluent_color("#2C2C2C"), } } diff --git a/crates/ui/src/theme/mod.rs b/crates/ui/src/theme/mod.rs index 96f7ff98..d2310f66 100644 --- a/crates/ui/src/theme/mod.rs +++ b/crates/ui/src/theme/mod.rs @@ -12,6 +12,7 @@ use std::{ }; mod color; +pub(crate) mod contrast; mod elevation; mod fluent_tokens; mod registry; @@ -39,25 +40,34 @@ pub enum ThemeShadowToken { Xl, } +/// Motion tokens: four durations, one spring, five easing curves. +/// +/// Every animated surface draws from this set so the system moves as one: +/// +/// - `fade` (83ms) — micro state changes: tooltips, carets, tab strips. +/// - `exit` (167ms) — every dismiss. Presence close windows and close animations +/// share this token, so an exit can never outlive its element. +/// - `enter` (187ms) — every reveal. Also the settle window for the spring, so a +/// fade and its transform partner end together. +/// - `emphasis` (667ms, overshoot) — attention accents (badges), used sparingly. +/// +/// One spring (`spring_damping_ratio`/`spring_frequency`) drives all transform +/// reveals; a mild/medium split existed before but measured within a few frames +/// of each other at 60Hz and collapsed into this single token. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ThemeMotion { - pub fast_duration_ms: u16, - pub normal_duration_ms: u16, - pub slow_duration_ms: u16, - pub strong_invoke_duration_ms: u16, - pub soft_dismiss_duration_ms: u16, pub fade_duration_ms: u16, - pub spring_mild_duration_ms: u16, - pub spring_medium_duration_ms: u16, - pub spring_mild_damping_ratio: f32, - pub spring_medium_damping_ratio: f32, - pub spring_mild_frequency: f32, - pub spring_medium_frequency: f32, - pub fast_invoke_easing: SharedString, - pub strong_invoke_easing: SharedString, - pub fast_dismiss_easing: SharedString, - pub soft_dismiss_easing: SharedString, - pub point_to_point_easing: SharedString, + pub exit_duration_ms: u16, + pub enter_duration_ms: u16, + pub emphasis_duration_ms: u16, + pub spring_damping_ratio: f32, + pub spring_frequency: f32, + /// Fast-out curve for enters: most of the travel happens immediately. + pub decelerate_easing: SharedString, + /// Symmetric curve for point-to-point moves (switches, progress, widths). + pub standard_easing: SharedString, + /// Overshoot curve for emphasis accents. + pub emphasis_easing: SharedString, pub fade_easing: SharedString, } @@ -106,14 +116,6 @@ pub struct ThemeMaterial { pub layer_dark: Hsla, pub layer_alt_light: Hsla, pub layer_alt_dark: Hsla, - pub mica_base_light: Hsla, - pub mica_base_dark: Hsla, - pub mica_base_alt_light: Hsla, - pub mica_base_alt_dark: Hsla, - pub acrylic_base_light: Hsla, - pub acrylic_base_dark: Hsla, - pub acrylic_default_light: Hsla, - pub acrylic_default_dark: Hsla, } impl Default for ThemeMaterial { diff --git a/crates/ui/src/theme/schema.rs b/crates/ui/src/theme/schema.rs index 918b3589..64f35ce1 100644 --- a/crates/ui/src/theme/schema.rs +++ b/crates/ui/src/theme/schema.rs @@ -81,23 +81,15 @@ pub struct ThemeConfig { #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] #[serde(default)] pub struct ThemeMotionConfig { - pub fast_duration_ms: Option, - pub normal_duration_ms: Option, - pub slow_duration_ms: Option, - pub strong_invoke_duration_ms: Option, - pub soft_dismiss_duration_ms: Option, pub fade_duration_ms: Option, - pub spring_mild_duration_ms: Option, - pub spring_medium_duration_ms: Option, - pub spring_mild_damping_ratio: Option, - pub spring_medium_damping_ratio: Option, - pub spring_mild_frequency: Option, - pub spring_medium_frequency: Option, - pub fast_invoke_easing: Option, - pub strong_invoke_easing: Option, - pub fast_dismiss_easing: Option, - pub soft_dismiss_easing: Option, - pub point_to_point_easing: Option, + pub exit_duration_ms: Option, + pub enter_duration_ms: Option, + pub emphasis_duration_ms: Option, + pub spring_damping_ratio: Option, + pub spring_frequency: Option, + pub decelerate_easing: Option, + pub standard_easing: Option, + pub emphasis_easing: Option, pub fade_easing: Option, } @@ -136,79 +128,43 @@ pub struct ThemeMaterialConfig { pub layer_dark: Option, pub layer_alt_light: Option, pub layer_alt_dark: Option, - pub mica_base_light: Option, - pub mica_base_dark: Option, - pub mica_base_alt_light: Option, - pub mica_base_alt_dark: Option, - pub acrylic_base_light: Option, - pub acrylic_base_dark: Option, - pub acrylic_default_light: Option, - pub acrylic_default_dark: Option, } impl ThemeMotion { fn apply_config(&mut self, config: Option<&ThemeMotionConfig>, default_theme: &ThemeMotion) { if let Some(config) = config { - self.fast_duration_ms = config - .fast_duration_ms - .unwrap_or(default_theme.fast_duration_ms); - self.normal_duration_ms = config - .normal_duration_ms - .unwrap_or(default_theme.normal_duration_ms); - self.slow_duration_ms = config - .slow_duration_ms - .unwrap_or(default_theme.slow_duration_ms); - self.strong_invoke_duration_ms = config - .strong_invoke_duration_ms - .unwrap_or(default_theme.strong_invoke_duration_ms); - self.soft_dismiss_duration_ms = config - .soft_dismiss_duration_ms - .unwrap_or(default_theme.soft_dismiss_duration_ms); self.fade_duration_ms = config .fade_duration_ms .unwrap_or(default_theme.fade_duration_ms); - self.spring_mild_duration_ms = config - .spring_mild_duration_ms - .unwrap_or(default_theme.spring_mild_duration_ms); - self.spring_medium_duration_ms = config - .spring_medium_duration_ms - .unwrap_or(default_theme.spring_medium_duration_ms); - self.spring_mild_damping_ratio = config - .spring_mild_damping_ratio - .unwrap_or(default_theme.spring_mild_damping_ratio); - self.spring_medium_damping_ratio = config - .spring_medium_damping_ratio - .unwrap_or(default_theme.spring_medium_damping_ratio); - self.spring_mild_frequency = config - .spring_mild_frequency - .unwrap_or(default_theme.spring_mild_frequency); - self.spring_medium_frequency = config - .spring_medium_frequency - .unwrap_or(default_theme.spring_medium_frequency); - self.fast_invoke_easing = config - .fast_invoke_easing + self.exit_duration_ms = config + .exit_duration_ms + .unwrap_or(default_theme.exit_duration_ms); + self.enter_duration_ms = config + .enter_duration_ms + .unwrap_or(default_theme.enter_duration_ms); + self.emphasis_duration_ms = config + .emphasis_duration_ms + .unwrap_or(default_theme.emphasis_duration_ms); + self.spring_damping_ratio = config + .spring_damping_ratio + .unwrap_or(default_theme.spring_damping_ratio); + self.spring_frequency = config + .spring_frequency + .unwrap_or(default_theme.spring_frequency); + self.decelerate_easing = config + .decelerate_easing .as_ref() - .unwrap_or(&default_theme.fast_invoke_easing) + .unwrap_or(&default_theme.decelerate_easing) .clone(); - self.strong_invoke_easing = config - .strong_invoke_easing + self.standard_easing = config + .standard_easing .as_ref() - .unwrap_or(&default_theme.strong_invoke_easing) + .unwrap_or(&default_theme.standard_easing) .clone(); - self.fast_dismiss_easing = config - .fast_dismiss_easing + self.emphasis_easing = config + .emphasis_easing .as_ref() - .unwrap_or(&default_theme.fast_dismiss_easing) - .clone(); - self.soft_dismiss_easing = config - .soft_dismiss_easing - .as_ref() - .unwrap_or(&default_theme.soft_dismiss_easing) - .clone(); - self.point_to_point_easing = config - .point_to_point_easing - .as_ref() - .unwrap_or(&default_theme.point_to_point_easing) + .unwrap_or(&default_theme.emphasis_easing) .clone(); self.fade_easing = config .fade_easing @@ -260,7 +216,7 @@ impl ThemeElevation { } impl ThemeMaterial { - fn apply_config( + pub(crate) fn apply_config( &mut self, config: Option<&ThemeMaterialConfig>, default_theme: &ThemeMaterial, @@ -335,46 +291,6 @@ impl ThemeMaterial { .as_ref() .and_then(parse_config_color) .unwrap_or(default_theme.layer_alt_dark); - self.mica_base_light = config - .mica_base_light - .as_ref() - .and_then(parse_config_color) - .unwrap_or(default_theme.mica_base_light); - self.mica_base_dark = config - .mica_base_dark - .as_ref() - .and_then(parse_config_color) - .unwrap_or(default_theme.mica_base_dark); - self.mica_base_alt_light = config - .mica_base_alt_light - .as_ref() - .and_then(parse_config_color) - .unwrap_or(default_theme.mica_base_alt_light); - self.mica_base_alt_dark = config - .mica_base_alt_dark - .as_ref() - .and_then(parse_config_color) - .unwrap_or(default_theme.mica_base_alt_dark); - self.acrylic_base_light = config - .acrylic_base_light - .as_ref() - .and_then(parse_config_color) - .unwrap_or(default_theme.acrylic_base_light); - self.acrylic_base_dark = config - .acrylic_base_dark - .as_ref() - .and_then(parse_config_color) - .unwrap_or(default_theme.acrylic_base_dark); - self.acrylic_default_light = config - .acrylic_default_light - .as_ref() - .and_then(parse_config_color) - .unwrap_or(default_theme.acrylic_default_light); - self.acrylic_default_dark = config - .acrylic_default_dark - .as_ref() - .and_then(parse_config_color) - .unwrap_or(default_theme.acrylic_default_dark); return; } diff --git a/crates/ui/src/theme/tests.rs b/crates/ui/src/theme/tests.rs index 8a78f345..5e831f74 100644 --- a/crates/ui/src/theme/tests.rs +++ b/crates/ui/src/theme/tests.rs @@ -6,9 +6,12 @@ use std::{ use gpui::Hsla; -use super::{Colorize as _, ThemeColor, ThemeConfig, ThemeSet, try_parse_color}; +use super::{ + Colorize as _, ThemeColor, ThemeConfig, ThemeMaterial, ThemeSet, + contrast::{MIN_TEXT_CONTRAST, contrast_adjusted, contrast_ratio}, + try_parse_color, +}; -const MIN_TEXT_CONTRAST: f32 = 4.5; const MIN_CHART_CONTRAST: f32 = 3.; const MIN_CHART_COLOR_DISTANCE: f32 = 0.05; const MIN_INTERACTION_STATE_DISTANCE: f32 = 0.02; @@ -97,20 +100,6 @@ fn linear_srgb(channel: f32) -> f32 { } } -fn relative_luminance(color: [f32; 4]) -> f32 { - 0.2126 * linear_srgb(color[0]) + 0.7152 * linear_srgb(color[1]) + 0.0722 * linear_srgb(color[2]) -} - -fn contrast_ratio(foreground: Hsla, surface: Hsla, background: Hsla) -> f32 { - let background = hsla_to_rgba(background); - let surface = composite(hsla_to_rgba(surface), background); - let foreground = composite(hsla_to_rgba(foreground), surface); - let foreground_luminance = relative_luminance(foreground); - let surface_luminance = relative_luminance(surface); - (foreground_luminance.max(surface_luminance) + 0.05) - / (foreground_luminance.min(surface_luminance) + 0.05) -} - fn oklab(color: Hsla, background: Hsla) -> [f32; 3] { let color = composite(hsla_to_rgba(color), hsla_to_rgba(background)); let red = linear_srgb(color[0]); @@ -221,6 +210,138 @@ fn bundled_themes_meet_text_contrast_floor() { ); } +/// The flyout material composited over `background`, as +/// [`crate::flyout_material_color`] resolves it at runtime. Derives the base color +/// through [`crate::surface::flyout_base_color`] — the same function the renderer +/// uses — so these tests measure the surface that is actually painted. +fn flyout_material(config: &ThemeConfig, colors: &ThemeColor) -> Hsla { + let mut material = ThemeMaterial::default(); + material.apply_config(config.material.as_ref(), &ThemeMaterial::default()); + + let is_dark = config.mode.is_dark(); + let opacity = if is_dark { + material.flyout_dark_opacity + } else { + material.flyout_light_opacity + }; + crate::surface::flyout_base_color(colors.popover, is_dark).opacity(opacity) +} + +/// Flyout materials (popover, menu, select popup, command palette, editor popovers) +/// sit *above* the window background and are lighter than it in dark mode, so text +/// on a flyout has less contrast than the same text on `background`. This checks the +/// two text roles used inside flyouts against the flyout material itself rather than +/// against `background`, which is what [`bundled_themes_meet_text_contrast_floor`] +/// covers. +/// +/// Both text roles are the corrected ones the runtime paints: +/// [`crate::flyout_primary_foreground`] (usually `popover.foreground` untouched; +/// corrected only in deliberately dim themes such as Alduin) and +/// [`crate::flyout_secondary_foreground`], which corrects `muted.foreground` — +/// raw, that token is sub-AA on the flyout material in every bundled dark theme. +#[test] +fn bundled_themes_meet_flyout_text_contrast_floor() { + let mut failures = Vec::new(); + + for (path, config) in bundled_theme_configs() { + let colors = resolve_colors(&config); + let flyout = flyout_material(&config, &colors); + + for (name, foreground) in [ + ( + "flyout label", + contrast_adjusted( + colors.popover_foreground, + flyout, + colors.background, + MIN_TEXT_CONTRAST, + ), + ), + ( + "flyout secondary", + contrast_adjusted( + colors.muted_foreground, + flyout, + colors.background, + MIN_TEXT_CONTRAST, + ), + ), + ] { + let ratio = contrast_ratio(foreground, flyout, colors.background); + if !ratio.is_finite() || ratio < MIN_TEXT_CONTRAST { + failures.push(format!( + "{} / {} / {name}: {ratio:.2}:1", + path.display(), + config.name + )); + } + } + } + + assert!( + failures.is_empty(), + "bundled theme flyout contrast failures:\n{}", + failures.join("\n") + ); +} + +/// Pins *why* [`crate::flyout_secondary_foreground`] corrects rather than aliasing +/// `muted.foreground`: on dark materials the aliased token is routinely sub-AA, and +/// the correction both fixes those and leaves already-readable themes untouched. +/// +/// Note it is not every dark theme — Catppuccin Macchiato and macOS Classic Dark +/// already clear the floor on their own flyout materials and are returned unchanged. +#[test] +fn bundled_theme_flyout_secondary_corrects_only_where_needed() { + let mut dark_needing_correction = 0; + + for (path, config) in bundled_theme_configs() { + let colors = resolve_colors(&config); + let flyout = flyout_material(&config, &colors); + let corrected = contrast_adjusted( + colors.muted_foreground, + flyout, + colors.background, + MIN_TEXT_CONTRAST, + ); + + if corrected == colors.muted_foreground { + // Left alone only when it already passes. + assert!( + contrast_ratio(colors.muted_foreground, flyout, colors.background) + >= MIN_TEXT_CONTRAST, + "{} / {}: sub-AA secondary was left uncorrected", + path.display(), + config.name + ); + continue; + } + + // Corrections move away from the material: lighter on dark, darker on light. + if config.mode.is_dark() { + assert!( + corrected.l > colors.muted_foreground.l, + "{} / {}: correction should lighten on a dark material", + path.display(), + config.name + ); + dark_needing_correction += 1; + } else { + assert!( + corrected.l < colors.muted_foreground.l, + "{} / {}: correction should darken on a light material", + path.display(), + config.name + ); + } + } + + assert!( + dark_needing_correction > 0, + "no dark theme needed a correction, so the role is an alias and can be removed" + ); +} + #[test] fn default_theme_semantic_active_states_remain_distinct() { let (_, config) = bundled_theme_configs() diff --git a/crates/ui/src/time/date_picker.rs b/crates/ui/src/time/date_picker.rs index 80509de7..255a1ad9 100644 --- a/crates/ui/src/time/date_picker.rs +++ b/crates/ui/src/time/date_picker.rs @@ -13,7 +13,7 @@ use rust_i18n::t; use crate::{ ActiveTheme, Disableable, Icon, IconName, Sizable, Size, StyleSized as _, StyledExt as _, actions::{Cancel, Confirm}, - animation::fast_invoke_animation, + animation::enter_animation, button::{Button, ButtonVariants as _}, global_state::GlobalState, h_flex, @@ -443,7 +443,7 @@ impl RenderOnce for DatePicker { .when(state.open, |this| { let motion = &cx.theme().motion; let reduced_motion = GlobalState::global(cx).reduced_motion(); - let anim = fast_invoke_animation(motion, reduced_motion); + let anim = enter_animation(motion, reduced_motion); this.child( deferred( diff --git a/docs/docs/components/dropdown_button.md b/docs/docs/components/dropdown_button.md index 4897d535..749e291a 100644 --- a/docs/docs/components/dropdown_button.md +++ b/docs/docs/components/dropdown_button.md @@ -10,6 +10,10 @@ A [DropdownButton] combines an optional action button with a menu trigger. In sp The component supports [Button] variants, [Sizable] sizes, borderless styling, custom trigger icons, loading, disabled, and selected states. +The split control uses one outer silhouette and one 1 px seam. Its menu trigger stays +narrower than the primary action while retaining the action's height. Ghost and +explicitly borderless modes use a stable 4 px gap so selection never shifts layout. + ## Import ```rust @@ -112,12 +116,23 @@ DropdownButton::new("dropdown") ## Accessibility - Split mode exposes two focus stops: primary action, then menu trigger. +- Each split segment gets its own visible focus ring so keyboard users can tell which action will run. +- Loading and disabled split controls remove both segments from interaction; loading remains visible on the primary action. - Icon-only triggers should always include `tooltip(...)`. +## Motion + +DropdownButton keeps its geometry stable across default, selected, loading, and +disabled states. Menu enter and exit motion belongs to the shared [Popover] and +[PopupMenu] layers rather than the trigger, so split controls do not stack a second +animation on top. The shared layers honor the application's reduced-motion setting. + ## Platform support Bordered, borderless, split, and icon-only modes use shared GPUI primitives and behave consistently on macOS, Windows, and Linux. [Button]: https://docs.rs/gpui-component/latest/gpui_component/button/struct.Button.html [DropdownButton]: https://docs.rs/gpui-component/latest/gpui_component/button/struct.DropdownButton.html +[Popover]: https://docs.rs/gpui-component/latest/gpui_component/popover/struct.Popover.html +[PopupMenu]: https://docs.rs/gpui-component/latest/gpui_component/menu/struct.PopupMenu.html [Sizable]: https://docs.rs/gpui-component/latest/gpui_component/trait.Sizable.html diff --git a/docs/docs/components/menu.md b/docs/docs/components/menu.md index 2bd8cef6..11545724 100644 --- a/docs/docs/components/menu.md +++ b/docs/docs/components/menu.md @@ -8,6 +8,15 @@ summary: "Context menus and popup menus with support for icons, shortcuts, subme The Menu component provides both context menus (right-click menus) and popup menus with comprehensive features including icons, keyboard shortcuts, submenus, separators, checkable items, and custom elements. Built with accessibility and keyboard navigation in mind. +## Visual treatment + +- Popup surfaces use the theme's translucent Acrylic flyout material: backdrop blur, subtle noise, a 1 px stroke, restrained elevation, and a 6 px radius. When backdrop blur is unavailable or disabled, the material falls back to an opaque background. +- Default rows are 30 px high with 8 px horizontal padding. Compact application-menu rows remain 26 px high. The shell uses 6 px padding and 4 px row spacing for clearer grouping. +- Icons, labels, checks, shortcuts, and submenu chevrons share fixed columns so mixed item types stay aligned. +- Hover uses the neutral list-hover token. Keyboard and pointer selection use the theme primary color with its paired foreground color. +- Section labels use smaller medium-weight text; separators use a single 1 px rule with 4 px vertical spacing. +- Disabled items keep the muted foreground token while retaining row alignment. + ## Import ```rust @@ -450,8 +459,10 @@ Button::new("settings") ## Motion - Root popup menu surface is static to avoid open-time flicker on app-menu and large scrollable menus. -- Nested submenu: open/close uses keyed presence state and side-aware horizontal slide + opacity. -- Reduced motion: popup and submenu transitions are disabled and visibility updates are immediate. +- New row selection gets a 1.5 px spring translation while its selected color appears immediately. +- Nested submenu open uses a side-aware spring translation plus monotonic opacity. Close uses a short point-to-point translation and monotonic opacity. +- Context menu open uses a 4 px spring translation plus monotonic opacity; close uses a 2 px point-to-point translation plus monotonic opacity. +- Reduced motion disables selection, context-menu, and submenu transitions; visibility updates are immediate. - Dropdown menu cache reset: menu entity disposal is delayed to match dismiss timing (or immediate under reduced motion) to avoid flicker. ## Keyboard Shortcuts diff --git a/docs/docs/components/popover.md b/docs/docs/components/popover.md index b457e751..f9f5b0ce 100644 --- a/docs/docs/components/popover.md +++ b/docs/docs/components/popover.md @@ -8,6 +8,8 @@ summary: "A floating overlay that displays rich content relative to a trigger el Popover component for displaying floating content that appears when interacting with a trigger element. Supports multiple positioning options, custom content, different trigger methods, and automatic dismissal behaviors. Perfect for tooltips, menus, forms, and other contextual information. +Default popovers use the theme's translucent Acrylic flyout material: backdrop blur, theme opacity, a subtle stroke, and flyout elevation. When backdrop blur is unavailable or disabled, the material falls back to an opaque background. Content receives 12 px padding by default. + ## Import ```rust @@ -186,11 +188,13 @@ And the `Popover` has implemented the [Styled] trait, so you can use all the sty Popover::new("custom-popover") .appearance(false) .trigger(Button::new("custom").label("Custom Style")) - .bg(cx.theme().accent) - .text_color(cx.theme().accent_foreground) - .p_6() - .rounded_xl() - .shadow_2xl() + .bg(cx.theme().secondary) + .text_color(cx.theme().secondary_foreground) + .border_1() + .border_color(cx.theme().border) + .p_2() + .rounded(cx.theme().radius) + .shadow_sm() .child("Fully custom styled popover") ``` @@ -224,7 +228,7 @@ Popover::new("controlled-popover") ## Motion -- Enter: popover content uses theme fast-invoke timing with opacity + small vertical translation. +- Enter: popover content uses the theme mild spring for a restrained 4px vertical translation, while opacity remains monotonic. - Exit: popover content uses point-to-point timing for a monotonic dismiss (no bounce overshoot). - Reduced motion: transitions are disabled and state changes render immediately. - Anchor-aware offset: top anchors drift downward on enter; bottom anchors drift upward. diff --git a/docs/docs/components/sidebar.md b/docs/docs/components/sidebar.md index 100400e1..7c26f286 100644 --- a/docs/docs/components/sidebar.md +++ b/docs/docs/components/sidebar.md @@ -89,7 +89,11 @@ SidebarToggleButton::new() ### Floating Sidebar (SidebarShell + Sidebar) `FloatingSidebar` composes `SidebarShell` with `Sidebar`, handling resize internally. -It defaults to an 8px inset, uses the theme's panel elevation, and can be resized when expanded. +It defaults to an 8px inset, the theme's translucent Mica panel material, panel elevation, and can be resized +when expanded. Backdrop blur follows the global surface policy; call `.blur_enabled(false)` to opt out. +When blur is unavailable or disabled, the material falls back to an opaque background. +Collapse and expand animate the complete panel and its content together using theme motion tokens; +reduced-motion mode applies the final width immediately. Call `.elevation(...)` to override the complete panel shadow. Larger elevations may need a larger inset near window edges to avoid native-window clipping; the inset is never adjusted automatically. diff --git a/docs/docs/theme.md b/docs/docs/theme.md index 8c4e6f1d..b3936784 100644 --- a/docs/docs/theme.md +++ b/docs/docs/theme.md @@ -19,6 +19,67 @@ cx.theme().foreground So if you want use the colors from the current theme, you should keep your component or view have [App] context. +## Flyout tokens + +Transient surfaces — Popover, HoverCard, PopupMenu, ContextMenu, Select popup, +CommandPalette, the editor popovers and the collapsed sidebar submenu — share one +geometry language via `FlyoutTokens`. `SurfacePreset::flyout()` supplies the +material (blur, noise, elevation, stroke); `FlyoutTokens` supplies the layout on +top of it, so all flyouts stay in the same family when a theme changes. + +```rs +use gpui_component::{FlyoutTokens, flyout_primary_foreground, flyout_secondary_foreground}; + +let tokens = FlyoutTokens::new(cx); // medium density +let tokens = FlyoutTokens::sized(size, cx); // match a control's `Size` +``` + +Key relationships: + +- `radius` comes from `theme.radius_lg`; rows use `item_radius = radius - inset`, + so a row's corner stays concentric with the container's. +- `inset` is the padding between the container edge and its rows; a row's label + therefore sits at `inset + item_padding_x` from the container edge. Headers, + footers and search fields inside a flyout should use that same value so every + surface has a single left rail. +- Two type steps only: `label_size` (Fluent body) for row labels, `meta_size` + (Fluent caption) for shortcuts, subtitles, categories and section headers. +- The material's tint is theme-derived: the flyout base color is `popover` + lifted to the material's luminance step (panels lift `sidebar`, the card wash + lifts `background`), so custom themes tint every surface automatically. +- Text roles come from `flyout_primary_foreground` / `flyout_secondary_foreground` + (with `flyout_selected_secondary_foreground` on a selected row and + `flyout_disabled_foreground` for disabled rows) rather than ad-hoc + `foreground.opacity(..)` values. Both roles are contrast-corrected against the + composited flyout material — the primary role only in deliberately dim themes, + the secondary role in most dark themes — and the disabled role sits one clear + step below the secondary, so labels, supporting text and disabled content stay + three distinct levels in every theme. + +## Motion tokens + +`theme.motion` is four durations, one spring, and four easing curves — every +animated surface draws from this set so the system moves as one: + +- `fade_duration_ms` (83) — micro state changes: tooltips, carets, tab strips. +- `exit_duration_ms` (167) — every dismiss. Presence close windows use the same + token as the close animations, so an exit always plays to completion before + its element unmounts. +- `enter_duration_ms` (187) — every reveal, and the settle window for the + spring, so a fade and its transform partner end together. +- `emphasis_duration_ms` (667) — the overshoot accent (badges), used sparingly. +- `spring_damping_ratio` / `spring_frequency` (0.78 / 2.0) — the one spring for + transform reveals. +- Easings: `decelerate_easing` for enters, `standard_easing` for + point-to-point moves and exits, `emphasis_easing` for the overshoot, + `fade_easing` (linear). + +Flyouts share one motion grammar: enter = spring slide from the trigger plus a +standard fade over `enter`; exit = standard fade over `exit`. Use +`animation::enter_animation` / `exit_animation` / `spring_animation` / +`standard_animation` / `fade_animation` rather than raw durations, and +`animation::enter_duration` / `exit_duration` for presence windows. + ## AppShell integration AppShell can initialize the registry, persist mode/name in its diff --git a/vendor/gpui b/vendor/gpui index 6646f575..1eeb173f 160000 --- a/vendor/gpui +++ b/vendor/gpui @@ -1 +1 @@ -Subproject commit 6646f575c0dc5febbba6791df805e2b6bc2ff3de +Subproject commit 1eeb173f71fad65c25cc8ea22319715703f05b5b